confinement — AgentTeamsModule¶
Orchestrator-only-writes pilot, phase P4b. This module runs each command and gate that the out-of-session runner starts inside an OS sandbox.
Source:
agentteams/confinement.py(integrity-pinned). Used byproposals.run_requestandproposals.apply_proposalwithconfine=True, which theproposal_runneralways sets.
Configuration (operator file or brief)¶
"confined_programs": {"lean-prover": {"exec": ["~/.elan"], "write": [".lake"]}},
"allow_unconfined_runs": false
exec. Absolute or~/paths. Programs or toolchain roots, matched as subpaths.- The command's own program must lie under one.
- Give the toolchain root, not only its
bin: Homebrew Python, for one, re-executes a binary inside its framework. - macOS: most developer tools in
/usr/bin(git,make,clang) are Apple's xcrun launcher, which execs the real tool from the active developer directory. Whenexecnames one,wrapalso allows the real tool byliteralpath (xcrun --find) and, for git,git --exec-pathas a subpath. On a Command Line Tools host,/usr/bin/gitadds(literal "/Library/Developer/CommandLineTools/usr/bin/git")and(subpath "/Library/Developer/CommandLineTools/usr/libexec/git-core"). Only a declared/usr/binitself adds whole tool directories.- Each added path and every parent must be root-owned and not group- or world-writable, or it is dropped
with a notice. One that overlaps a write root or
TMPDIRis dropped too. - If xcrun is missing or fails, nothing is added and a one-line notice names the launcher, whose confined
calls then fail with exit 71.
--check-wiringreports each allowlist's launchers. - The runner warns once per run when the command, or
lake(which runs git), needs such a launcher. If a write root also covers a package store, it refuses the run unless the agent's entry setsallow_unresolved_launcher_with_package_store: true. - Linux is unchanged (decision C).
- Each added path and every parent must be root-owned and not group- or world-writable, or it is dropped
with a notice. One that overlaps a write root or
write. Project-relative directories.- Never the project root,
.agentteams,.agentteams-queue,.gitor the control plane. - Never containing an
execpath, or a command could build a binary there and then run it. - Changes inside a write root count as declared, so a build needn't list every output.
- A write root that equals, contains or lies inside a package store (
.lake/packages,node_modules,site-packages,.venv,venv,vendor,.cargo/registry,Pods,.gradle) gets a warning from--install-confinedand from the runner at each run, and the run is refused when an unresolved launcher is needed too (see above). Tools may delete and re-create these when a probe fails inside the sandbox, so narrow the root to build outputs (lean/.lake/build, notlean). It is a warning, not a refusal. - Gates. A gate may set
exec. The default is its own program plus a shebang interpreter. Gates write only their temp dir. P5c: the operator file may set it instead, under the reserved top-level keygate_exec:{"gate_exec": {"lean-draft-scan": ["/opt/anaconda3"]}, "lean-prover": {...}}. That keeps a gate interpreter's machine path out of the committed brief too. It is refused for a gate the brief doesn't define or whose brief entry already setsexec. In the operator filegate_execis never read as an agent; no agent may take that name, and the brief's ownconfined_programsmay not use it. --install-confinedbinds eachgate_execentry to its gate's argv, by recordinggate_argv_sha256: {gate: sha256 of the argv}in the reviewed JSON. A gate whose argv in the brief has changed since install is refused until the file is reinstalled, so a reworked gate never silently keeps the old exec. An entry without its binding is refused too.gate_argv_sha256is reserved likegate_exec.- At run time a gate is refused if any of its exec paths overlaps any agent's confined write root (inside it, or containing it, since exec paths match as subpaths), where a command could plant a binary for the gate to run. The check folds case on macOS.
- Where it lives (P5b). Prefer the operator-owned file
~/.config/agentteams/confined/<project>-<hash>.json(agentteams --confined-path) over the brief, so machine paths stay out of the committed brief. - Install it with
agentteams --install-confined FILE --description BRIEF. That validates it against the brief's gates and prints the exact JSON it would install with its sha256. Nothing is written until you rerun it with--confirm-review-sha256 <sha256>. It then writes those bytes with mode 0600 in a 0700 directory. A hash that doesn't match, because the file changed after review, is refused. - Write roots are literal directories. A
*,?or[is refused, because the runner's containment checks treat write roots as paths. - The runner reads it at start and pins its hash. A change stops the runner, like a brief change does.
- It refuses a file that is a symlink, not yours, group- or world-writable, inside the project (case-folded on
macOS), over 64 KiB, or not a JSON object. It also refuses when the brief sets
confined_programstoo. - The file is opened with
O_NOFOLLOW | O_NONBLOCKand re-checked withfstat. A file swapped in after the checks (same name, different inode) is refused, and a FIFO can't hang the runner. - Agents can't write it. Session sandboxes write-confine to the workspace, and the emitted
permissions.denycarriesEdit(~/.config/agentteams/confined)andEdit(~/.config/agentteams/confined/**)for the built-in Edit/Write tools. Under the switch the orchestrator writes only the candidate JSON and gives the user the one--install-confinedcommand. It writes no script. - Residual: whether Claude Code applies those deny rules to case variants (
~/.Config/...on APFS) or to a symlink alias made inside the project is not verified on the product. The custody checks can't tell an agent from the operator (same uid). The session sandbox and the deny rules are the boundary. allow_unconfined_runs. Decision A: off by default. With no usable sandbox the runner refuses; with this set, it runs unconfined and logs each run in the ledger as"confined": "unconfined-opt-out".
What the sandbox enforces¶
macOS (Seatbelt, sandbox-exec -p) |
Linux (bwrap) | |
|---|---|---|
| Writes | Only the write roots, a private TMPDIR and /dev nodes |
The root is read-only; the write roots and TMPDIR are bind-mounted writable |
| Inside a write root | .agentteams, the queue, .git and the control plane are denied |
Re-mounted read-only |
Key directory ~/.config/agentteams/keys |
Read-denied | Masked with an empty tmpfs |
| Programs | Only under exec (process-exec deny-by-default) |
Not restricted (decision C) |
| Leftover processes | killpg after exit; any survivor fails the run. A setsid escapee isn't caught |
New PID namespace and --die-with-parent, so nothing survives |
available() checks for a sandbox by applying a trivial profile. Inside an already-sandboxed process
sandbox-exec can't nest, so it reports none and the runner refuses, unless opted out.
Run-time checks (before anything runs)¶
- Write roots. Each must be an existing directory inside the project, with no symlink anywhere below the
project root. A root swapped for a link to
~is refused. - No overlap. Exec paths and writable places (write roots and the run's
TMPDIR) must not overlap in either direction./, home and ancestors of the project are refused as exec paths. - Protected paths. A write root may not cover a
protected_pathsentry or the brief, since changes inside roots count as declared. - The exemption only counts under a sandbox. Changes inside roots count as declared only when a sandbox
actually ran. Under the
allow_unconfined_runsopt-out every change must be declared. Gates run under the opt-out are marked"confined": "unconfined-opt-out"in the ledger. - No environment key. The runner refuses to start while a ledger-key environment variable is set in its own environment.
Residual risk (named, accepted for the pilot)¶
- Reads and network are open. Only the key directory is read-denied, and Seatbelt keeps
(allow default)for network andmach-lookup. Confined code can read the workspace and the home directory, and can reach the network. - Agent-supplied code runs inside the bounds.
lake env lean --stdinwith agent-supplied input is arbitrary code:#evalcan read any readable file, and its output returns to the agent as stdout. It needs no exec right. Lean FFI ordlopenof a library built in a write root also gets around the exec allowlist. The write and key limits still hold. - A Python toolchain in
execlets any Python run. The argv allowlist still pins which script the orchestrator asks for. - macOS
setsidescapee. A grandchild that callssetsidand closes its pipes escapeskillpg. It stays sandboxed, but can keep writing inside the write roots after the final snapshot. On Linux the PID namespace prevents this. - Linux gaps. No program allowlist (decision C), no network isolation, and nested
.gitdirectories inside a write root aren't write-denied. - Gates with
#!/usr/bin/envor#!/bin/shshebangs need an explicitexecthat includes the real interpreter.
Verified live (macOS, 2026-10-06)¶
- A declared program writes its root.
- Writes to the project, the ledger and outside the project are refused by the kernel.
- An undeclared program (
/bin/ls) is refused. - A detached survivor fails the run.
- A gate can't write the project.
- A nested
.gitinside a write root is write-denied. - A symlinked write root, an over-broad exec path and a root covering a protected path are refused before anything runs.
- A real
lake buildsucceeds withexec: ["~/.elan"]andwrite: [".lake"], oncelake-manifest.jsonis committed.
Public surface¶
| Name | Purpose |
|---|---|
available() -> str \| None |
"seatbelt", "bwrap" or None, found by actually applying a trivial profile. |
kind() -> str \| None |
The platform's sandbox, without probing. |
wrap(argv, *, sandbox, root, cwd, exec_paths, write_roots, tmp_dir, resolver=None) -> list[str] |
Wrap a command; on Seatbelt, expands xcrun launchers first. |
xcrun_resolver(timeout=XCRUN_TIMEOUT) / expand_exec_paths(exec_paths, resolver, *, platform=None, ..., lstat=None) -> Expansion |
macOS: resolve xcrun launchers (injectable), and the pure expansion: extras tagged literal/subpath, unresolved, dropped. |
ownership_problem(path, lstat=None) |
Why a path can't be an exec allowance (it or a parent not root-owned, or group/other-writable), or None. |
expansion_notices(exp) / unresolved_launchers(program, exec_paths, resolver) / launcher_warning(program, unresolved) / launcher_report(exec_lists, resolver) |
The profile-build notices, the runner's once-per-run WARNING, and the --check-wiring lines. |
launcher_store_refusal(agent, unresolved, store_warnings, jail) |
The runner's refusal for an unresolved launcher plus a package-store write root, unless OPT_IN_LAUNCHER_STORE is set. |
package_store_warnings(write_roots, root=None) -> list[str] |
Warnings for write roots over a package store (PACKAGE_STORES). |
seatbelt_profile(...) / bwrap_argv(...) |
The profile text or argv prefix. |
load_confined(raw, gates, control_plane_of) |
Validate confined_programs and gates' exec. |
confined_file_for(root) -> Path |
P5b: the operator-owned file's path for a project. |
read_confined_file(root) -> tuple[dict, str] \| None |
P5b: read it with custody checks, as (confined_programs, sha256). |
confined_bytes(data) -> bytes |
P5b: the exact bytes installed (sorted, indented JSON): what the operator reviews and hashes. |
install_confined_file(root, data) -> Path |
P5b: write those bytes atomically, mode 0600. Callers validate and confirm the review hash first. |
exec_allows(program, exec_paths) / gate_exec_paths(gate, program) |
Exec allowlist helpers. |
check_roots(root, exec_paths, write_roots, tmp_dir=None) -> list[str] |
Run-time checks of one agent's confinement against the real project (real write roots, exec breadth, exec/write overlap). |
exec_inside_write_roots(root, exec_paths, write_roots) -> str \| None |
A gate exec path overlapping any agent's write root, in either direction (case-folded on macOS). |
ConfinementError |
An unsafe path, or a bad entry. |