Sabline 8.8.0

Confinement

From 8.4 a process that runs a Sabline program asks the operating system to hold the budget it was given, before the program's first statement runs. The budget is still enforced by the interpreter, at the moment each effect is attempted, exactly as before. Confinement is the second guard under it: if a defect in Sabline itself - in the interpreter, in a builtin, in the budget's own checks - attempts an effect the budget does not grant, the kernel refuses it, and the fault is a crash inside a box and not an escape.

Shell
sabline program.vel                     # confined, where the system can
sabline program.vel --confine strict    # and no granted module widens it (9.0)
sabline program.vel --require-confinement full   # or refuse to run (9.0)
sabline program.vel --no-confine        # not confined, and stderr says so
sabline doctor                          # what this machine offers
sabline eval --confinement-probe        # what a confined worker could do

It is on by default on the command line, in sabline.run(timeout=...), in sabline.Pool, on the HTTP door, on the MCP server and under sabline eval. --no-confine (and confine=False in the library) turns it off and writes one line to stderr. It is a flag of the operator's command line: a program's words after --, a door's request and an MCP tool call cannot carry it.

One derivation #

sabline/confine.py's os_policy(budget) is the only place a budget becomes an OS policy. It reads nothing of the machine, so the same budget gives the same policy everywhere, and a receipt records its sha256. The policy says which paths may be read and which written (or that a direction is not restricted), whether the network is closed, open, or open to named hosts, that no process is started, and which granted Python modules widened it.

The full table - every budget item, and what Linux, macOS and Windows enforce for it - is in THREAT_MODEL.md, under What the operating system enforces, and check_confine.py holds that table to the module word for word. In short:

LinuxmacOSWindows
MechanismLandlock and seccomp-bpfa sandbox profile (sandbox_init, the call sandbox-exec makes)a job object, a token with its privileges removed, a low integrity level
Files readheld to the fs:read grants and what the interpreter itself readsrefused under the home directory and /Volumes onlynot held
Files writtenheld to the fs:write grantsheld to the fs:write grantsheld only when the budget grants no write at all
Networkno socket without net; with Landlock ABI 4 or later, TCP ports of the grantsclosed without net; open with itnot held
A new processrefusedrefusedrefused
The level of a run under iofullpartialpartial

@N counts stay in the language on every system. So does the host in a net:HOST grant: no kernel here can hold a host name, and a run with one says partial.

The level, and its reason #

Every confined run reports one of three levels, with the reason:

  • full - every file, network and process limit of this run's budget is held by the kernel.
  • partial - some are, and the reason names each that is not.
  • none - nothing is: --no-confine, a granted module that widens the policy to nothing enforced, a system that offers nothing, or a run that is exempt (below).

The level is what was applied and held, not what was asked for. It is in three places:

  • The receipt. run_parameters.confinement is the level, confinement_reason why, confinement_layers what was applied (landlock-abi3, seccomp, sandbox-profile, job-object, token-privileges-removed, low-integrity), and os_policy_sha256 the digest of the OS policy the budget derives. sabline receipts diff names a run whose level is not one an earlier run of the same program had, and says when it is weaker.
  • The audit. sabline audit --json has confinement: for the program's safe_command, the level and reason on each of the three systems where every layer that system has is there, and widened_by, the granted Python modules that widen the OS policy and to what. It reads nothing of the machine, so the audit stays the same bytes wherever it is made. The command line's audit prints what this machine would give.
  • sabline doctor. What this machine offers, and the level a run under the default budget gets on it.

sabline eval requires full or partial, and refuses to run at none.

Python modules widen it #

A granted ffi module does whatever that module does, so the OS policy is widened to what the module needs: nothing for ffi:math or ffi:json, any path for ffi:sqlite3 or ffi:pathlib, any host for ffi:socket or ffi:urllib, and nothing enforced for ffi:os, ffi:subprocess, plain ffi and any module the table in THREAT_MODEL.md does not name. The audit says which, and a run under such a budget says none and why.

The grant is what is read, not the use: such a module widens the policy whether or not the program imports it, and one the table does not name does so whether or not it exists, since nothing is imported to find out. So from 8.7 a command-line run whose grants turn the operating system layer off says so on stderr, once, as the program is about to run - one line, whenever the budget's OS policy (the one sabline audit prints, and the receipt's os_policy_sha256 names) is enforced: false:

Sabline
sabline: plain ffi grants any module, so the operating system layer is off for this run: the budget is the only boundary
sabline: ffi:os is granted, so the operating system layer is off for this run: the budget is the only boundary
sabline: ffi:nosuchmod is not in the confinement table, so the operating system layer is off for this run: the budget is the only boundary

A grant of several says them together, on the same one line. --no-confine says its own line instead. Nothing else changes: the run, its exit status and its receipt are what they were, and sabline.run(), sabline replay, a Pool and sabline serve write nothing new - the receipt is where they say it.

--confine strict: a granted module widens nothing (9.0) #

--confine strict on the command line - confine="strict" for sabline.run, a Pool or a PoolRegistry - takes the policy from the budget alone. A granted ffi module widens nothing: the operating system is asked to hold exactly the budget's fs: and net: grants and what the interpreter itself reads, whatever modules are granted, so a module that needs a path or a host needs it in the budget. The kernel checks the system call, not the code that made it, so a module's own native code, its own ctypes calls and its own os.system are held as the runtime's are:

Shell
sabline 19d.vel --allow io,ffi:nativefmt --confine strict
# the vendored library's native fopen outside every grant fails; the run
# goes on, and its receipt says the platform's level, not none

It is opt-in, and the default does not move: without it a module the table does not name still widens the policy to nothing enforced, and the receipt still says so. A module that does I/O the budget does not grant - sqlite3's journal beside its database, a library reading its own data files - fails under it with an operating-system error, as it should. Under strict the OS policy document carries "strict": true, so its digest in the receipt is not that of the same budget without it, and the receipt's reason says no module widened it. It closes the competitor benchmark's 19d where the platform holds the write: on Linux and macOS, and on Windows when the budget grants no write (decisions/0006, section e).

--require-confinement: refuse, rather than warn (9.0) #

--require-confinement full (or partial) - require_confinement= in the library and for a pool - refuses a run whose confinement, once applied, is below that level: E326, before its first statement, with the level and the reason it got. The receipt records the level and the refusal, so a refused run is accounted for like any other. It is the level that was applied, never a prediction, and it is not --confine strict: strict is about a granted module not widening the policy, this is about whether the platform can hold the budget at all. They compose - under strict a budget with ffi is confined more often, so this refuses less.

What it refuses depends on the platform, and today plainly so:

LinuxmacOSWindows
fullruns where Landlock and seccomp hold every limit of the budgetrefuses every run: the level is partial at best, since Apple has deprecated sandbox profilesrefuses every budget that restricts reads or the network - io itself does - because this runtime cannot hold either
partialrefuses only a run nothing holds: ffi:os, ffi:subprocess, plain ffi or an unnamed module without --confine strictthe samethe same

On Windows that is the Python runtime's limit, not the platform's: an AppContainer holds reads and the network, and the spike plan/9.0.md asked for showed a Rust binary can start in one and be given exactly its granted directories (rt/spikes/appcontainer/). When sabline-rt confines its own runs (9.0, M4), full is a level a Windows budget can have; until then this row says what is true.

sabline.run with no limit runs in the caller's process, which Sabline does not confine, so a level asked of it is always refused. Pass a limit, or use a Pool. Beside --no-confine - or confine=False - either option asks for two opposite things, and is refused as a usage error.

With SABLINE_REFERENCE_RUNTIME=rust, a run asked for either stays the Python package's and says so on stderr: sabline-rt does not ask the operating system for anything until M4's port, so a level asked of it would be a level it ignored.

sabline-rt confines its own runs (9.0) #

SABLINE_REFERENCE_RUNTIME=rust sends the run inside each command to sabline-rt (decisions/0002), which from 9.0's M4 asks the operating system for the same policy, in its own process, before the program's first statement. The derivation is the same one twice over: os_policy is ported into the crate, and the agreement gate compares the two documents - the policy, its digest, what it asks the system to hold - for every budget it has, under --confine strict, without it and under --no-confine, before either runtime asks a kernel for anything. The seccomp filter, the Landlock access masks for each ABI and the macOS profile are compared the same way, on every platform and not only on the one that applies them: a system call number typed wrong in one runtime is a difference on the commit that types it.

What it applies is this page's table, layer for layer: Landlock and seccomp-bpf on Linux, sandbox_init on macOS, the job object and the token's privileges on Windows - and, on Windows, an AppContainer the package cannot enter.

The Windows AppContainer (9.0) #

A process cannot enter a container after it has started, so the run's own process is started inside one by the process that was asked for the run. sabline-rt exec, asked for confinement on Windows, is therefore two processes. The outer one reads the request, loads the program to learn which files it reads - a lookahead, never an answer: the child's own load is what refuses anything - creates an AppContainer profile named for this run, grants its SID one ACE per file and per granted directory, and starts a child sabline-rt exec --contained SID inside the container with its own standard output and error and a pipe for its standard input. It pumps the request line and the rest of its own input through, waits, takes every ACE back and deletes the profile. It is a byte pump and nothing else: the frames the run writes go straight to whoever asked for the run.

The inner process does not take the parent's word for it. --contained SID is a claim; the run asks the kernel - this token's TokenIsAppContainer and TokenAppContainerSid - and makes the claim only when the kernel agrees and the SID is the one named. A sabline-rt started with --contained outside a container reports that it is not held, and holds nothing back.

So a Windows run whose budget restricts both directions and names no loopback host is full where a container can hold it: a read, a write or a connection outside the grants is refused by the kernel, which check_confine.py --runtime rust measures with the fault-injection hook. A Windows Server is not such a machine - a process inside a container there can open the file it was granted and still not be told what path that file has - and the container is left off, with the reason in the receipt, rather than a run being refused a file its budget allows. docs/os-enforces.md is the table, cell by cell, with the reason on each one the two runtimes differ on.

A container is left off for a budget it would refuse something the budget allows, and the receipt's reason says which: a budget granting a loopback host, since Windows isolates a container from the loopback address unless an administrator exempts it; a budget that restricts neither reads nor writes, since a container reaches only what it is given an ACE for; and a system where a process inside a container cannot resolve a path as the process that started it does. That last one is asked rather than assumed: before a run is handed to a container, the container is asked to resolve a spelling of its granted paths that is not their canonical form - the 8.3 form, where the volume keeps one - and if any answer differs the container is left off. A run with no such spelling to ask about leaves it off too: what cannot be asked is not claimed. A no is remembered for that build of the crate, so a system whose containers cannot resolve stops paying for one it will not use; a yes is not, because a remembered yes would be a claim that outlived its measurement. It matters because every fs: grant is checked against what a path resolves to - the host resolves the grant, the run resolves what the program asks for - so a run that resolved a short name like RUNNER~1 differently from its host would be refused a file its budget allows. A wrong answer is not a safe one. Such a run gets what Windows gave before 9.0. A run that is killed before its parent can take the ACEs back leaves them naming the SID of a profile that is gone, which nothing can hold again; the next run sweeps them, from a record each run holds open while it holds the grants.

A grant wider than a thousand files leaves the container off. An AppContainer reaches a file only through that file's own rights, and Windows' inheritance is a copy and not a lookup: giving a directory an inheritable ACE writes it into every file under it, and taking it back writes them all again. That is about half a millisecond a file, charged to the run's own deadline, and it changes the rights of the user's files while the run lasts. So a grant over a tree larger than that is a container left off, with the reason in the receipt, rather than a run that pays for a tree nobody counted.

Two runs at once are two containers with an ACE each on the same file, and adding one is read the rights, add to them, write them back: without a lock the second write is of rights read before the first, and one of the two runs is then refused a file its budget grants - a wrong answer, and not a safe one. So every grant and every revoke takes a machine-wide lock first, for the length of that one write and not for the run, and a run whose turn never comes says so and is not contained. A revoke that could not be made leaves the run's record in place, so the next run's sweep takes that ACE back.

Two things a run through sabline-rt holds that the package's cannot, and one it does not:

  • A sabline.run() with no limit can be confined through sabline-rt, because the program runs in another process. In the package it runs in the caller's own, which Sabline never confines, so a level asked of it is refused (E326); through sabline-rt it is given.
  • A command confines itself too. The network, a granted Python module and a tool are called in the process that started sabline-rt, so that process is held to the same policy - after sabline-rt has been started, since the policy lets no process be started. For a pool that process is its worker, which was already confined; for sabline run it is the command's own, which ends with the run.
  • An unbounded sabline.run()'s host is not confined, for the same reason its own run never was: it is the caller's process. A run there whose budget grants net, ffi or tool says so in its receipt's reason, and its level is partial rather than full, because a connection opened in another process is not something this policy bounds.

check_confine.py --runtime rust asks every question on this page of sabline-rt: the honesty test with its fault-injection hook, what still works, what is reported, what a program cannot reach, what is tried against the confinement itself, and both of 9.0's options. On Windows it also asks the container's own promises: that every ACE is taken back when a run ends, that a run killed while holding them is swept by the next, that --contained outside a container claims nothing, and that each budget the container is left off for says so in its receipt. Two parts stay the package's and say so: the policy and the filter, which the agreement gate already compares; and the escape targets, which are measured on a Sabline whose Python budget checks are knocked out.

What is not confined #

  • An in-process sabline.run() - one with no timeout and no max_memory_mb - runs in the caller's process. Landlock, seccomp, a sandbox profile and a lowered token cannot be taken off again, so Sabline does not put them on a process that is not its own. Pass a limit, or use a Pool.
  • The REPL, sabline test and sabline bench run many programs in one process.
  • A Pool made without import_root holds writes, the network and processes, not reads: its workers compile each program they are sent, and an import may name any .vel file. Give the pool an import_root, as both doors do, and reads are held too.
  • Compiling, proving and native code generation happen before the policy is applied in a single run, because imports are read from wherever they are. The program's first statement runs after it.

What it does not make true #

Confinement is as good as the kernel, and it narrows a process without making it another user. A host in a net: grant, a credential location under a broad grant, env, a hard link or a bind mount that was inside a granted path before the run, and timing, are outside it on every system; macOS and Windows are partial, for the reasons THREAT_MODEL.md gives; and Apple has deprecated sandbox profiles. Run code you have not read as a different user, or in a container or a virtual machine, when the stakes warrant it.

How it is tested #

check_confine.py runs on every leg of CI. Its fault-injection hook (SABLINE_FAULT_INJECT) makes the runtime itself, from Python and not from Sabline, read a file, write one, open a connection, start a process and send a signal outside the budget. Under confinement, what that system's row of the table says is held is refused by the kernel and the run ends with E319 naming the layers; what the row says is not held goes through; and with --no-confine every one goes through. All three outcomes are asserted, so the table is held to be true in both directions.

Under --confine strict it holds the five cases decisions/0006 set: 19d's native fopen, a module that opens and writes a file through the C library, and os.system's process, each refused by the kernel with nothing made; the hook's write, E319 with an ffi grant as without; and, without strict, 19d writing its file as it always has, its receipt saying none. Under --require-confinement it holds E326 on each platform where the level is below the one asked for, the run when it is not, the hook never tried by a refused run, and the library's and a pool's refusals.

It also runs every escape target of check_sandbox.py, and the file and ffi targets of check_adversarial.py, on a Sabline whose budget checks have been knocked out, and records which the kernel stops and which only the language does (tests/confine/kernel-linux.json, kernel-windows.json).