Runners: what happens during a run
The boundary each agent run gets on a machine: who enforces it, what it allows, what it refuses, and what it does not cover.
One live run per slot
A machine's capacity is its slots, and a slot carries at most one live run. The limit is enforced by a unique index in the database: a second concurrent attempt on the same slot inserts nothing and is refused, naming how many runs the slot carries and how many it may. There is no counter to race. More concurrency on a host means more seats; two further ceilings — per host and per vendor account — are described in the Administrator's runbook.
When a slot looks busy, the engine reads the blocking run, checks whether its process is alive, finalises a genuinely dead holder by its run identifier, and retries once; otherwise it reports the current holder. It never waits indefinitely.
What confinement is for
An agent runs as a process on some machine. The useful question is never "was a sandbox option switched on" — an option is a claim. The question is: which mechanism, enforced by the operating system, refuses a write outside a named set of directories — and does the run record say what enforced, so the claim can be reproduced?
Two rules carry through everything below:
- The record names what enforced, not what was requested. A run record names the enforcing mechanism and the directories left writable.
- When a boundary cannot be enforced, the run does not happen. A machine that cannot confine is a refusal with a named reason, never a warning followed by an unconfined run.
Confinement governs writes. What a run may connect to is a separate control — egress control, closed by default.
Who confines what
| what is running | machine | enforced by |
|---|---|---|
| an engine-confined agent | control machine, macOS | Apple Seatbelt, invoked by absolute path, policy passed inline |
| an engine-confined agent | control machine, Linux | bubblewrap: read-only root, writable locations added back explicitly |
| an engine-confined agent | remote Linux machine | Landlock, applied on the machine before the agent starts |
| an engine-confined agent | remote macOS machine | Seatbelt, applied on the machine before the agent starts |
| an agent the engine does not confine on that platform | any | the agent's own sandbox, or none — the run record says the engine makes no claim. See Sandboxing |
| the engine's own agent loop | control machine | itself, under the same composer |
operator-declared commands (proc) | any | none, by scope: executables you name |
Which backends are engine-confined on which platform is the sandboxing matrix.
On Linux, the engine confines codex itself and switches codex's own sandbox off — but only after it
has proven its own jail exists; otherwise the run is refused.
Rules that hold on every row:
- Fail closed. A machine that cannot enforce — no sandbox utility, no Landlock, an unrecognised operating system — is a named refusal.
- The setting is closed.
run.confinementtakesengine(default),container(local machine only) orvendor(the agent's own sandbox; the run record says the engine makes no claim). Any other value is refused before anything else is checked, so a typo surfaces on the next run of any shape. A single dispatch can also ask for--confinement container. - Bypass options cannot be requested. The engine's instruction format has no field that could ask an agent to disable its own safety mechanisms, and a second check refuses known bypass tokens on the final command line. The same check refuses silent model substitution and silent session resumption.
The boundary on the control machine
The engine composes one policy, expressed two ways so the platforms cannot drift apart:
| platform | mechanism |
|---|---|
| macOS | Apple Seatbelt, via the sandbox utility resolved at its absolute system path first (PATH is consulted only if that file is absent); policy passed inline |
| Linux | bubblewrap: root filesystem read-only, /dev and /proc provided, /tmp replaced by a private tmpfs, a private process namespace, writable locations added back as explicit bind mounts |
| any other platform | no mechanism — refusal |
Writes are denied by default and allowed only to:
| allowed to write | why |
|---|---|
| the working directory | the task's own subject |
| the run's output directory | where the run's own artifacts and captures land |
| the host's build cache | the toolchain's cache lies outside the working directory; it is shared by runs on that host |
| a temporary directory per stream | created outside the workspace tree, shared by that stream's runs, for toolchain and test scratch space |
on macOS, the Apple toolchain's module cache and *.lock files in the per-user temp directory | the Apple build tools need them |
| the agent tool's session-environment directory, and this run's own session-file directory | without them the agent has no shell and the session cannot be resumed; never the configuration directory, never the home directory, never another run's sessions |
measured leaves of the seat's profile, for codex, qwen-code and droid | the exact files those tools must write at start-up |
| narrow temporary-file prefixes | scratch files under the system temp directory; the prefix, never the directory |
| version-control state | the worktree's metadata, the shared object store, and the branch's reference — as literal paths on macOS, as the branch's own namespace directory on Linux — never the whole reference namespace |
- Grants are derived, not configured. The engine reads the working directory to determine which version-control paths a run needs; nothing is declared by an operator, so nothing can be mis-declared.
- ⚠ A branch outside a namespace cannot be committed on local Linux. The bind-mount model cannot
grant a not-yet-existing lock file without its parent, so such a run fails at
git commit. On a remote machine the same case is refused before the run starts. - Symbolic links are resolved before the rules are built, so a grant names the real path.
Reads
Reads are open. What is hidden depends on the lane:
- remote runs and runs under the
saphan-agentharness hide the machine's signing-key directory; - when seat issuance is configured, local runs also hide seat-token files and other runs' capture directories;
- the engine's own agent loop hides its credential files and the configuration that names them.
⚠ A local run under the default legacy harness hides nothing else — including the machine's
signing-key directory. Anything readable by the runner's user is readable by the agent, including
other seats' profiles on the same machine, and a metered seat's stored API key.
The boundary on remote machines
- The capability is measured per machine.
saphan machine probe-confinementconnects over the machine's recorded transport and writes the measured level to its signed record. No option accepts a typed value; the probe is the only writer. - The boundary is the engine's own program, re-entered on the machine. On Linux it applies Landlock to itself and then starts the agent inside it; on macOS it launches the agent under Seatbelt. The policy — what is writable, what is denied — is recorded on the run.
- The decision is a gate. A remote run is composed only when the machine's record shows a level at or above the floor. A machine never probed, measured incapable or below the floor is a named refusal, and nothing runs unconfined on someone else's machine.
- Why there is a floor. Below Landlock's third level the kernel does not cover file truncation, so a "confined" agent could still destroy any file it can open. On Linux the floor is re-checked against the live kernel when the boundary is applied; macOS has no versioned floor.
- Writable on a remote run: the run's working directory, the version-control paths it needs, the session directory and the seat's measured profile leaves. The agent's scratch space lives inside the working directory.
- Token refresh happens before the jail starts, from the engine's own unconfined process, so a vendor's sign-in does not expire mid-run because the jail refused a credential write.
The engine's own agent loop
The engine's built-in agent loop re-executes itself through the same confinement composer that jails agent tools, so the policies cannot drift apart. Beyond the write boundary:
- Every declared lane credential file, and the configuration that names them, is denied for reading inside the jail.
- The credential crosses into the jailed child over a private file descriptor, never the environment.
- Every subprocess the loop's tool calls spawn gets an environment scrubbed of the credential variable, including when your own shell already exported it.
Permission classes
Permission classes are declarative JSON policies with a narrow surface: allowed commands, write scope and network posture. Accepted schema versions are an explicit set; a file naming another version fails to load. Command matching is exact-token on the base name — never prefix or substring. Where a credential is required, the policy names the variable and the engine supplies the value at launch; values do not live in policy files. Paths must be absolute. See Permission classes.
Refusal rather than degradation
| condition | result |
|---|---|
| confinement setting is not a recognised value | refuse, checked first |
| macOS without the sandbox utility | refuse |
| Linux without bubblewrap (control-machine runs) | refuse |
| remote machine never probed for confinement | refuse, with the probe command as the remedy |
| remote machine measured incapable, or below the floor | refuse, naming the measured level |
| the jail cannot be prepared | refuse |
| unrecognised platform | refuse |
Each carries a named class. There is no exemption path, no override flag and no best-effort mode.
Software supply chain
Saphan Studio ships as a small set of static binaries (saphan, saphan-agent, saphan-server,
saphan-gateway), built with path trimming for reproducibility. Delivery to a runner computes
SHA-256 on the receiving machine and compares it with what was sent. The agent binary's digest and
version are re-resolved on the machine that will execute it and recorded; they are compared, and a
mismatch refused, when you pin a digest (saphan machine set-binary --agent-digest). See Documented limits for what this does not prove.
The control binary checks its own custody
saphan measures the custody of the executable it is running and refuses to start if that binary
can be replaced by someone other than its custodian. The refusal happens before anything else runs and
carries the class custody-binary-unsafe.
The check is about who can replace the binary, not about a mode number:
| clause | refused when |
|---|---|
| others may not write the binary | o+w is set |
| the group may not write the binary | g+w is set |
| the owner and group are outside the caller's reach | the binary is held by a third, unprivileged identity — or the caller is not the owner and the file's group is its only path to execute |
| the parent directory is safe | the directory is group- or world-writable and not sticky, owned by a third identity, or not a directory — a binary in a writable directory can be replaced by rename |
The installer writes 0755, which passes — including a root-owned 0755 binary with any group, as
containers use. ⚠ 0550 is owner-only: root-owned, it runs only as root.
⚠ This is a barrier against misconfiguration and accidental use, not against an attacker who can already write to the binary. It does not see ACLs or the directories above the immediate parent. When it refuses, it prints the exact commands to fix it — see the Administrator's runbook.
Seats: separating agents that share a machine
A seat is a named executor identity assigned to one machine and one execution slot, for one agent backend, with its own configuration home.
Sandboxing: which agent is confined, where
For each agent backend and platform: whether the engine confines it, and what happens when it does not.