SKELETON — the agentteams Sandboxing map (single source of structure + facts)¶
This is the core outline for the sandboxing subsystem: the shared spine every edition (R/D/S/E) projects. It is a focused deep-dive on ONE layer of the broader Security Guide (its Part VI) — how workspace write-confinement, read-exclusion, egress control, and the runtime deny-hook are requested, decided, emitted, wired, enforced, tamper-tracked, and honestly bounded — plus (SB24) the opt-in orchestrator-only write policy and its
agentteams_runnerMCP server, which ride on that confinement.It fixes two things the editions may not diverge on — the section structure (stable IDs
SB1…) and the canonical facts each section asserts. How deep and in what voice each edition renders a section is set byaudience-profiles.md; what is true is set here.Editing rule: change the skeleton first, then project into every edition. Never add a fact, drop a section, or reorder the spine in a book alone.
How to read this map¶
- ID — stable (
SB3,SB18). An edition marks each section it renders with the ID so the map and the books cross-check. - Canonical facts — the invariant claims. Every edition that includes the section states these (adapted in depth/voice) and states nothing that contradicts them.
- Source — the repo file(s) the facts rest on, with line ranges (collected in
SOURCES.md). No fact ships without one. - Status marker — ✅ implemented & enforced in code/tests · ⚙ design / procedural / operator
action only, not a deterministic code control. Compound (
✅/⚙) marks a concept partly each. Overclaiming a ⚙ as ✅ is a fact error. - Dial — per-edition depth
R/D/S/E(Full/Core/Light/Skip; seeaudience-profiles.md). - The honest-ceiling doctrine (binding on every edition). Every control is stated with what it buys and what it cannot. A boundary is described as "engages as tested," never "secure" or "unbypassable." The four load-bearing ceilings of THIS subsystem, which no edition may drop:
- Emitted by default, inert until wired. As of 2026-W39 the default profile is
confined: agentteams emits an OS write-confinement boundary (as a settings/config example the operator must merge), but it enforces nothing until wired. The deny-hook still stays fail-open by default — only an explicitconfined/exclusiveflips it fail-closed, not the defaultedconfined. Opt out withcooperative. - Inert until wired. Every emitted boundary is an example/launcher the operator must
activate (merge settings, set
GOOSE_SANDBOX, or WRAP the process). agentteams never writes an operator's live config or auto-invokes the launcher. An emitted-but-unwired boundary confines nothing. - Verified only on Linux. The launcher's Linux (
bwrap) branch is proven by a live-kernel deny test. As of 2026-W36 the SAME launcher also has a macOS (build_macos,sandbox-exec) branch — so macOS is now emittable framework-neutrally — but it is enforcement-UNVERIFIED untilsandbox/mac-escape-tests.shpasses unnested (with its positive controls) on a real mac; Windows still has no emittable boundary. - Closes nothing absolutely. T6 (a same-host key-holder / operator shell) and host-as-TCB stay bounded, never closed; seccomp/Landlock is a further layer not yet added.
The pipeline at a glance (canonical graph G1)¶
Every sandbox request flows through five stages. The rest of this map details each.
flowchart LR
R["REQUEST<br/>privilege_profile /<br/>*:sandbox token<br/>(SB4–SB6)"]
D{"DECIDE<br/>is_sandbox_capable?<br/>(SB7–SB9)"}
E["EMIT<br/>the boundary artifact<br/>(SB10–SB13)"]
W["WIRE<br/>operator activates<br/>(SB14–SB15)"]
EN["ENFORCE<br/>OS + PreToolUse hook<br/>(SB16–SB17)"]
A1["advisory:<br/>unenforced-host<br/>(FATAL, SB8)"]
A2["advisory:<br/>manual-wire<br/>(NON-FATAL, SB8)"]
R --> D
D -- "capable, native boundary<br/>(claude any OS / goose macOS)" --> E
D -- "POSIX launcher case<br/>(Linux non-claude / macOS non-claude+non-goose)" --> A2 --> E
D -- "not capable<br/>(Windows / other)" --> A1
A1 -- "--allow-unenforced-confinement" --> WN["proceed with WARNING<br/>NO boundary emitted here"]
A1 -- "default (generate)" --> X["FAIL CLOSED<br/>refuse, emit nothing"]
E --> W --> EN
EN -. "tamper-tracked by" .-> I["integrity manifest<br/>(SB18–SB19)"]
Reading G1's honest ceiling: reaching EMIT means a boundary artifact exists — not that a boundary is in force. WIRE (operator action) and, for the launcher (Linux
bwrapor macOSbuild_macos), actually wrapping the process, are what make it enforce (SB14). A2 is one of two per-POSIX-platform manual-wire advisories (SB8). The dotted edge to the integrity manifest is not part of the per-team runtime path; it is how agentteams' OWN source protects the emitters from silent edits (SB18).
Part I — What sandboxing is and why¶
SB1 — What "sandboxing" means here ✅/⚙¶
Canonical facts.
1. In agentteams, sandboxing is the runtime OS-confinement layer: workspace write-confinement
(the agent may write only inside declared roots), optional read-exclusion (deny reads of
credential paths + sibling workspaces), egress control (deny/proxy/host network), and a
PreToolUse deny-hook that gates destructive commands. It is OWASP LLM06 ("Excessive Agency")
containment: an agent that follows injected instructions and holds edit/execute is boxed so a
steered action cannot escape the workspace.
2. It is one layer of the layered security stack, not the whole of it. The governance layers
(constitution, @security sentinel, clearance/waiver/grant, CLI gates, content scanner) are always
active and are documented in the Security Guide; this
guide is only the OS-confinement + deny-hook layer.
3. Design-time, not runtime-of-the-app. The confinement boxes the agent that builds an app at
design/build time. It is not shipped inside the produced app; an app that serves LLM output to
end users must add its own runtime governance.
Source. SECURITY.md §threat-model; agentteams/host_features.py;
agentteams/frameworks/_sandbox_emit.py; agentteams/templates/universal/sandbox/confine-run.sh.
Dial. R Full · D Core · S Full · E Light.
SB2 — The in-scope adversary and the two surfaces ✅/⚙¶
Canonical facts.
1. The realistic in-scope adversary is an agent with legitimate write/execute access acting on
injected instructions — not a remote network attacker. The sandbox's job is to make that agent's
blast radius the declared workspace, not the whole host.
2. Two surfaces enforce, and they compose: (a) OS confinement (Claude Code's native sandbox; Apple
Seatbelt for goose on macOS; or the framework-neutral launcher — bwrap on Linux, sandbox-exec on
macOS) boxes the filesystem + network; (b) the PreToolUse constitutional-gate.py hook boxes
specific destructive command spellings an agent's Bash tool might run. Neither is the boundary alone.
3. Content is data (C-4). The sandbox exists partly because a file under review, a fetched page, or
a brief may carry injected text; confinement limits what a mis-followed instruction can do.
Source. .claude/CLAUDE.md (C-4); agentteams/templates/universal/hooks/constitutional-gate.py;
SECURITY.md §design-time-vs-runtime.
Dial. R Full · D Core · S Full · E Light.
SB3 — The opt-in posture (binding ceiling) ✅¶
Canonical facts.
1. As of 2026-W39 the default privilege_profile is confined: agentteams emits an OS
write-confinement boundary by default (as a settings/config example the operator must merge), but the
deny-hook is still emitted fail-OPEN (_FAIL_CLOSED_ON_ERROR = False) — its fail-closed flip
requires an explicit confined/exclusive, not the defaulted confined. The emitted boundary
enforces nothing until wired, and cooperative opts out of emitting it entirely.
2. Reading "layered confinement" as "enforced out of the box" is the overclaim this fact prevents. Out
of the box, the boundary is emitted but dormant until wired.
Source. agentteams/host_features.py:184 (DEFAULT_PRIVILEGE_PROFILE = "confined");
agentteams/frameworks/_sandbox_emit.py:116 _sandbox_feature_enabled;
agentteams/templates/universal/hooks/constitutional-gate.py:230 (_FAIL_CLOSED_ON_ERROR = False).
Dial. R Full · D Full · S Core · E Light (mandatory ceiling #1).
Part II — The request¶
SB4 — Three privilege profiles ✅¶
Canonical facts.
1. privilege_profile has three values, each a superset of the last:
- cooperative (the opt-out) — no boundary emitted; the agent is trusted, fail-open hook.
- confined (default as of 2026-W39) — workspace write-confinement: the agent writes only inside
workspace_write_roots (default ["."], the generated tree). Network deny-by-default is a
property of the mechanisms that emit an egress directive — goose Seatbelt (deny network*) and
the Linux launcher (--unshare-net); the claude mechanism emits no egress directive, so
claude network confinement is Claude Code's own product default (SB10, same unverified caveat).
- exclusive — confined plus read-exclusion (P3a) for the claude (denyRead) and
goose (deny file-read*) mechanisms: OS-deny reads of a curated credential set +
operator-supplied protected_read_paths (sibling workspaces), plus an operator inbound-hardening
advisory (P3b). Nuance for the launcher (SB12), both OS branches: it masks the default credential set
(~/.ssh …) on every wrap regardless of profile; only the extra sibling-workspace
--exclude reads are exclusive-specific there.
2. An unknown profile value fails closed (hard error at parse), never silently downgrades to
cooperative — a typo cannot ship an unconfined team that looks confined.
Source. agentteams/host_features.py validate_privilege_profile;
agentteams/frameworks/_sandbox_emit.py _exclusive_read_deny_paths;
agentteams/schemas/project-description.schema.json (privilege_profile, workspace_write_roots,
protected_read_paths).
Dial. R Full · D Full · S Core · E Light.
SB5 — Host-feature tokens and profile expansion ✅¶
Canonical facts.
1. The request is recorded two ways, both read: the privilege_profile field AND a *:sandbox
host-feature token (claude:sandbox, goose:sandbox). A confined/exclusive profile expands to
the framework-appropriate token (expand_privilege_profile): goose → goose:sandbox; every other
framework → claude:sandbox.
2. _sandbox_feature_enabled / the framework-neutral _sandbox_confinement_requested treat either
source as "confinement requested" so a confined manifest emits on the convert/render paths too,
not only interactive generate.
3. A :sandbox token is rejected for a namespace with no emitter (bridge namespaces): a validating
token that confined nothing is the silent-false-confinement failure this rejection prevents.
Source. agentteams/host_features.py expand_privilege_profile, merge_profile_features,
validate; agentteams/frameworks/_linux_sandbox_emit.py _sandbox_confinement_requested.
Dial. R Full · D Core · S Core · E Skip.
SB6 — The manifest is the request record ✅¶
Canonical facts.
1. build_manifest carries privilege_profile, workspace_write_roots, protected_read_paths, and
the resolved host_features into the render/emit pipeline. The manifest is the single object the
decision (SB7) and every emitter (SB10–SB13) read; there is no out-of-band sandbox state.
Source. agentteams/analyze.py build_manifest; agentteams/schemas/team-manifest.schema.json.
Dial. R Full · D Core · S Light · E Skip.
Part III — The decision¶
SB7 — is_sandbox_capable: the capability matrix ✅¶
Canonical facts.
1. is_sandbox_capable(framework_id, platform) is the single platform-aware decision function. Its
matrix (framework-neutral on BOTH POSIX platforms as of 2026-W36):
- Linux → True for any framework (the launcher's bwrap branch wraps any process).
- macOS → True for any framework (the SAME launcher's build_macos/sandbox-exec branch,
emitted by macos_sandbox_output_files; enforcement-UNVERIFIED until mac-escape-tests.sh passes).
- claude → True everywhere (Claude Code configures its own Seatbelt/bubblewrap sandbox).
- Windows / any other → False (no emittable boundary).
2. SANDBOX_CAPABLE_FRAMEWORKS is a convenience set sampling only ("claude","goose") for the current
host; the authoritative, framework-complete answer is is_sandbox_capable itself.
Source. agentteams/host_features.py:222 is_sandbox_capable (linux + darwin framework-neutral).
Dial. R Full · D Full · S Full · E Light.
flowchart TD
Q["confinement requested?<br/>(profile confined/exclusive OR *:sandbox)"] -->|no| COOP["cooperative:<br/>no boundary (SB3)"]
Q -->|yes| P{"platform?"}
P -->|linux| FW{"framework == claude?"}
FW -->|yes| CNL["claude: native settings-block sandbox (SB10),<br/>No advisory. ALSO emits the launcher (SB12/SB13),<br/>but native is claude's intended boundary — on Linux<br/>that native arm is VERIFIED only with the documented<br/>bwrap AppArmor profile on Ubuntu (SB20)"]
FW -->|no| ML["manual-wire advisory (NON-FATAL)<br/>+ emit bwrap launcher (SB12)"]
P -->|darwin| DF{"framework?"}
DF -->|claude| CN["claude: native settings-block<br/>sandbox (SB10). No advisory."]
DF -->|goose| SB["goose: native Seatbelt profile +<br/>inert config example (SB11). No advisory."]
DF -->|"other (codex/copilot/agents-md)"| MM["macOS manual-wire advisory (NON-FATAL, SB8)<br/>+ emit launcher build_macos branch (SB12).<br/>UNVERIFIED until mac-escape-tests passes (SB20)"]
P -->|win32/other| W2{"framework == claude?"}
W2 -->|yes| CW["claude: native (product-arm<br/>unverified on Windows)"]
W2 -->|no| UH["unenforced-host advisory (FATAL, SB8)"]
SB8 — Three advisories: one fatal, two non-fatal ✅¶
Canonical facts.
1. privilege_profile_advisory returns one of three codes, or None:
- privilege-profile-unenforced-host (FATAL) — no boundary is emittable here: Windows,
and any non-claude framework on any other non-POSIX target. (Since 2026-W36 macOS is emittable
framework-neutrally, so a confined codex/copilot/agents-md team on a mac is NO LONGER fatal —
see the macOS manual-wire code below.) resolve_host_features_and_advise raises
PrivilegeConfinementError (fail-closed) unless --allow-unenforced-confinement, which downgrades
it to a persisted warning and emits no boundary on that host.
- privilege-profile-linux-launcher-manual-wire (NON-FATAL) — on Linux, for every
framework except claude: enforcement IS available (the emitted launcher) but is not
auto-applied; the operator must WRAP the invocation. Never fail-closes; always warns + persists.
- privilege-profile-macos-launcher-manual-wire (NON-FATAL, 2026-W36) — on macOS, for
every framework except claude AND goose: the SAME launcher's build_macos branch is emitted
(sandbox-exec + a generated Seatbelt profile, RLIMIT_CPU/NPROC caps, a loopback-only proxy DNS
contract, a non-exhaustive setuid-exec denylist). Also not auto-applied (WRAP the invocation);
never fail-closes. Its message carries the honest macOS residuals (memory UNCAPPED, no syscall
filtering, setuid denylist ≠ NoNewPrivs, loopback-only proxy) and the enforcement-UNVERIFIED
gate (SB20). claude AND goose are excluded — each has its own auto-applied native macOS boundary
(Claude Code Seatbelt; goose GOOSE_SANDBOX via _goose_sandbox_emit, SB11).
- None — a boundary wired through the framework's own config surfaces no extra advisory:
claude native everywhere; goose Seatbelt on macOS. Claude/Linux caveat: claude's
native arm is its intended boundary and is now VERIFIED on Linux only under a precondition (Ubuntu
needs Claude Code's documented bwrap AppArmor profile, SB20) while the verified launcher
rides along un-advised — "no advisory for claude" ≠ "fully covered."
2. resolve_host_features_and_advise fail-closes on the fatal code only (fatal = code ==
"privilege-profile-unenforced-host"). Both manual-wire codes warn + persist, never raise — fail-
closing on them would wrongly refuse a genuinely enforceable POSIX target.
3. Why the manual-wire codes exist (a closed gap). Making Linux — then macOS — capable for every
framework would otherwise SUPPRESS the old unenforced-host advisory for codex/copilot/agents-md,
whose launcher is inert until wrapped. The non-fatal advisories restore the honest signal so an
operator who requested confined, saw no error, and found sandbox/confine-run.sh is told nothing
is confined until they wrap the process — per POSIX platform.
Source. agentteams/host_features.py:325 privilege_profile_advisory, :376
(unenforced-host), :404 (linux manual-wire), :426 (macos manual-wire); agentteams/cli/artifacts.py:330
resolve_host_features_and_advise, :381 (fatal gate).
Dial. R Full · D Full · S Full · E Core (the "you must wire it / off systems it is advice not a lock").
SB9 — Fail-closed by default on generate ✅¶
Canonical facts.
1. On the interactive generate path a fatal (unenforceable) confinement request refuses to emit an
inert boundary rather than ship a config that looks protective — the fail-safe-defaults principle.
--allow-unenforced-confinement opts into proceeding with the advisory instead.
2. The --convert-from/--fleet/render paths keep an advisory-not-raise default (they emit via
_sandbox_feature_enabled reading privilege_profile directly), so an unenforceable target there
degrades to a warning rather than a non-zero exit.
Source. agentteams/cli/artifacts.py:381; agentteams/frameworks/_sandbox_emit.py:116.
Dial. R Full · D Full · S Core · E Light.
Part IV — The mechanisms¶
SB10 — Mechanism A: Claude native settings-block sandbox ✅/⚙¶
Canonical facts.
1. For the claude framework, _sandbox_emit._build_sandbox_block emits a sandbox block into
.claude/settings.hooks.example.json: allowWrite (the write roots), denyWrite (the control-plane
files, denied even inside a write root), the allowUnsandboxedCommands: false escape-hatch closure,
and — under exclusive — denyRead plus allowRead (which re-opens the write roots so granted paths
stay readable). Claude Code applies it natively once the operator merges the example into their live
settings.json. The block emits no network/egress directive — claude network confinement is
Claude Code's own product default (unverified).
2. The block is inert until merged — agentteams ships an example, never writes the operator's
settings.json. verify_sandbox_wiring (P1-3) is the read-only, output-only check that the block
was actually merged (it reports booleans, never echoes live-settings secrets).
3. Honest ceiling. Claude Code's mechanism is verified, and its Linux product arm is verified end-to-end on Linux (2026-09-30: Ubuntu, Claude Code 2.1.251, bubblewrap 0.11.1, socat 1.8.1.1) only after installing Claude Code's documented /etc/apparmor.d/bwrap profile, which disables Ubuntu's bwrap-userns-restrict host-wide; without it sandboxed commands fail closed
(other distros / Claude Code versions untested) — a separate mechanism from the bwrap launcher of SB12.
Source. agentteams/frameworks/_sandbox_emit.py:176 _build_sandbox_block;
agentteams/frameworks/claude.py:249 (gate), :320 verify_sandbox_wiring.
Dial. R Full · D Full · S Core · E Light.
SB11 — Mechanism B: the native macOS boundaries (goose Seatbelt; claude Seatbelt) ✅/⚙¶
Canonical facts.
1. On macOS, two frameworks have their OWN auto-applied native boundary — distinct from the
framework-neutral launcher macOS branch (SB12): claude (Claude Code's own Seatbelt) and
goose. For goose, goose_sandbox_output_files emits a sandbox.sb Seatbelt profile
(sandbox-exec) that deny file-write* outside the write roots, deny network* by default (an
egress-proxy allow for ONE endpoint is behind an explicit flag), and — for exclusive — deny
file-read* of the protected set. It ships an inert config.yaml.agentteams.example carrying
GOOSE_SANDBOX; the operator merges it.
2. An explicit sys.platform == "darwin" guard ensures goose's Seatbelt path emits ONLY on macOS.
Off macOS it returns [] — not "no boundary": on Linux the neutral launcher (SB12) is the boundary.
Because claude and goose carry these native macOS boundaries, they are excluded from the macOS
manual-wire advisory (SB8); every OTHER framework on macOS relies on the launcher's build_macos
branch instead (SB12).
3. Honest ceiling. Both native Seatbelt paths are enforcement- and profile-syntax-UNVERIFIED
off a mac host — a green emission test means "the profile is shaped right," never "it denies on a
real mac."
Source. agentteams/frameworks/_goose_sandbox_emit.py:333 goose_sandbox_output_files, :350
(darwin guard); _seatbelt_path_expr; agentteams/host_features.py:414-440 (claude/goose macOS-advisory exclusion).
Dial. R Full · D Core · S Core · E Light (mandatory ceiling #3 — macOS UNVERIFIED).
SB12 — Mechanism C: the framework-neutral launcher (Linux bwrap + macOS sandbox-exec) ✅/⚙¶
Canonical facts.
1. The SAME provider-agnostic launcher — repo-root sandbox/confine-run.sh, NOT under .goose/,
NOT framework-gated — carries two OS branches, and base.extra_output_files emits
linux_sandbox_output_files(...) + macos_sandbox_output_files(...) (only one is non-empty per host,
so it is emitted once). It is emitted for a confined/exclusive team of any framework.
2. Linux branch (bwrap) — enforcement-VERIFIED (SB20). Once WRAPped
(sandbox/confine-run.sh --scratch DIR --egress deny -- <agent cmd>): --ro-bind / / (read-only
root), --bind $SCRATCH (the only writable path), --unshare-net on --egress deny, --tmpfs
masks over the credential set (~/.ssh …, on every wrap regardless of profile) plus --exclude
siblings (exclusive). NoNewPrivs is bubblewrap's default (not an emitted flag → not covered by
the SB18 pin). --egress proxy runs inside a pre-created netns.
3. macOS branch (build_macos, 2026-W36) — enforcement-UNVERIFIED (SB20). Once WRAPped:
sandbox-exec + a generated path-agnostic Seatbelt profile (deny-write outside scratch, deny
reads of the credential/--exclude set, deny-network / loopback-only proxy), RLIMIT_CPU/NPROC
caps applied via ulimit before exec (kernel limits, --cpu-max/--nproc-max), and a
non-exhaustive setuid-exec denylist (deny process-exec*, compensating hardening — NOT
NoNewPrivs). Honest macOS residuals: memory is UNCAPPED (--mem-max is interface-only for
Linux parity; a hard cap needs a VM/container/Linux host); no syscall filtering (no
seccomp/Landlock analogue); sole-proxy egress is loopback-only (SBPL cannot pin a remote proxy
IP — remote-address control lives out-of-band in PF). The --cpu-max/--nproc-max/--mem-max
flags are no-ops on Linux (Linux reaches those caps via cgroups OOB).
4. Both branches are inert until the operator WRAPs the invocation — the emit step only writes the
file (this is why SB12 is ✅/⚙: ✅ as emitted, ⚙ for the operator action that activates it; ceiling
SB14). On non-POSIX/other OS the launcher FAIL CLOSEDs rather than run a command labeled "confined".
5. The launcher content is emitted verbatim (byte-for-byte) from a shipped template asset
(templates/universal/sandbox/confine-run.sh), generic (all params at RUN time); the emit path lands
it repo-root-relative at the correct ../ depth (sandbox_launcher_rel_path) and marks it
executable. On macOS macos_sandbox_output_files also ships sandbox/mac-escape-tests.sh (the
on-host deny test that must pass before the macOS boundary is called "confined") and two INERT Tier-B
examples (dedicated-uid-provisioning.example.sh, pf-per-tenant-anchor.example.conf).
Source. agentteams/frameworks/_linux_sandbox_emit.py:112 linux_sandbox_output_files, :154
macos_sandbox_output_files; agentteams/frameworks/base.py extra_output_files (linux + macos),
sandbox_launcher_rel_path; agentteams/templates/universal/sandbox/confine-run.sh (both branches),
mac-escape-tests.sh; agentteams/atomicio.py (shebang → +x).
Dial. R Full · D Full · S Full · E Light.
flowchart TD
REQ["confined / exclusive team"] --> M{"which mechanism<br/>(by SB7 decision)"}
M -->|"claude (any OS)"| A["A: native settings-block sandbox (SB10)"]
M -->|"goose (macOS)"| B["B: native Seatbelt sandbox.sb (SB11)"]
M -->|"any framework, Linux —<br/>bwrap branch (VERIFIED, SB20)"| C["C: launcher sandbox/confine-run.sh"]
M -->|"non-claude/non-goose, macOS —<br/>build_macos branch (UNVERIFIED, SB20)"| C
A --> WD["WRITE-confine: ALL<br/>(allowWrite / ro-bind / seatbelt deny-write)"]
B --> WD
C --> WD
A --> NA["NETWORK: A emits NO egress directive —<br/>Claude Code product default (unverified, SB10)"]
B --> NBC["NETWORK: B & C deny by default<br/>(deny network* / --unshare-net / seatbelt)"]
C --> NBC
A --> RAB["READ-exclude: A & B only under EXCLUSIVE<br/>(denyRead / deny file-read*)"]
B --> RAB
C --> RC["READ-exclude: C masks the credential set ALWAYS<br/>(~/.ssh … tmpfs/seatbelt, any profile);<br/>--exclude adds siblings under exclusive"]
C --> MAC["macOS-branch residuals (SB12/SB20):<br/>mem UNCAPPED · no syscall filtering ·<br/>setuid denylist ≠ NoNewPrivs · loopback-only proxy"]
WD --> INERT["INERT until the operator activates it:<br/>merge settings / set GOOSE_SANDBOX / WRAP the process (SB14)"]
NA --> INERT
NBC --> INERT
RAB --> INERT
RC --> INERT
MAC --> INERT
SB13 — Framework-neutral wiring, no harness preference ✅¶
Canonical facts.
1. The launcher is emitted from base.extra_output_files (as linux_sandbox_output_files +
macos_sandbox_output_files, one non-empty per host) so every framework adapter emits it on both
POSIX platforms;
(claude, codex, copilot-vscode/-cli, agents-md, goose) — no harness is preferred. Only claude.py
and goose.py override extra_output_files, and both call super(); there is no double-emit.
2. The neutral path resolves to repo-root sandbox/confine-run.sh via sandbox_launcher_rel_path():
../../ for 2-deep agents dirs (claude/copilot/goose/codex), ../ for 1-deep (agents-md).
Source. agentteams/frameworks/base.py:139; agentteams/frameworks/codex.py,
agentteams/frameworks/agents_md.py (rel-path overrides); agentteams/frameworks/claude.py,
agentteams/frameworks/goose.py (super()).
Dial. R Full · D Core · S Light · E Skip.
Part V — Wiring & runtime enforcement¶
SB14 — Inert until wired (binding ceiling) ⚙¶
Canonical facts.
1. Every emitted boundary is inert until the operator activates it — the standing "ship an example,
never clobber the operator's live config" convention. Activation differs by mechanism: merge the
settings block (claude); set GOOSE_SANDBOX (goose macOS); WRAP the invocation
(sandbox/confine-run.sh --scratch DIR --egress deny -- <agent cmd> — the SAME command on Linux
(bwrap) and macOS (sandbox-exec/build_macos)).
2. For the Linux launcher there is no framework config that references it — the operator must change how
they LAUNCH the agent. This is why SB8's non-fatal manual-wire advisory exists: to say so at
generation time.
Source. agentteams/frameworks/hooks_emit.py (example-not-settings convention);
agentteams/templates/universal/sandbox/confine-run.sh (usage header);
agentteams/host_features.py:404 (linux manual-wire).
Dial. R Full · D Full · S Core · E Core (mandatory ceiling #2).
SB15 — Verifying the wiring took effect ✅¶
Canonical facts.
1. Read-only, output-only verifiers close the "looks confined, enforces nothing" gap without echoing
live-config secrets: claude.verify_sandbox_wiring (settings merged?) and
_goose_sandbox_emit.verify_goose_sandbox_wiring — the latter is platform-honest: on Linux
it verifies sandbox/confine-run.sh is present and returns ENFORCEABLE (noting it must still be
wrapped); on Windows it is exit-neutral "NOT ENFORCEABLE HERE"; on macOS it checks the
Seatbelt .goose/sandbox.sb.
Source. agentteams/frameworks/claude.py:320 verify_sandbox_wiring;
agentteams/frameworks/_goose_sandbox_emit.py:392 verify_goose_sandbox_wiring.
Dial. R Full · D Full · S Light · E Skip.
SB16 — The PreToolUse constitutional-gate hook ✅/⚙¶
Canonical facts.
1. A second surface — the emitted constitutional-gate.py PreToolUse hook — routes destructive Bash
command spellings (repo/ref/worktree/filesystem/infrastructure/database deletion; the
"delete-authorization gate") to the operator for authorization BEFORE they run (C-5).
2. It is a best-effort, cooperative speed-bump, not a boundary. It does NOT gate Write/Edit
content deletion, MCP/non-Bash deletes, interpreter-mediated deletion it does not pattern-match,
alias/quote/variable obfuscation, or harnesses that do not honor PreToolUse (or auto-approve under
headless). A green delete-gate test means "these spellings are gated," never "deletion is prevented."
3. PR merges route to the operator too (since PR #173). The same ask fires on a pull-request merge:
gh [flags] pr [flags] merge (flags may sit on either side of pr, e.g. gh pr -R o/r merge), the
REST merge endpoint pulls/<n>/merge, and the GraphQL mergePullRequest /
enablePullRequestAutoMerge mutations — the last two matched in the command text whatever client
sends them (gh api, curl, wget, httpie, an interpreter one-liner). A "merge" inside a PR body,
gh pr view … mergeable, or a later git merge does not prompt.
4. Same ceiling for the merge rule. It is the same cooperative speed bump: a case-insensitive
pattern match over one Bash command string, so an obfuscated or split string (the path assembled
from variables, an encoded GraphQL body, a script file that does the call) still evades it, and a
merge made through a non-Bash tool is not seen at all.
Source. agentteams/templates/universal/hooks/constitutional-gate.py:63 (delete gate), :75-85
(PR-merge rule), :157-161 (Bash-only, case-insensitive match);
agentteams/templates/universal/security.template.md (scope + limits);
tests/test_constitutional_gate_hook.py:226-228 (benign non-merges), :280-292 (merge spellings incl.
curl/wget/httpie/python).
Dial. R Full · D Full · S Core · E Light.
SB17 — Fail-open default, fail-closed under confinement ✅¶
Canonical facts.
1. The hook defaults fail-OPEN (_FAIL_CLOSED_ON_ERROR = False): a gate crash is a harness allow,
so a buggy gate never bricks a cooperative session. Under an explicit confined/exclusive, emission flips the
sentinel to _FAIL_CLOSED_ON_ERROR = True (unless --allow-fallback-fail-open), so a crash emits a
deny rather than a silent allow — the operator opted into a boundary a crash must not drop.
Source. agentteams/templates/universal/hooks/constitutional-gate.py:230
(_FAIL_CLOSED_ON_ERROR = False); agentteams/frameworks/claude.py _apply_fail_closed_policy.
Dial. R Full · D Full · S Core · E Light.
flowchart TD
CMD["agent Bash command"] --> H["constitutional-gate.py<br/>PreToolUse hook"]
H --> MATCH{"matches a delete<br/>idiom / S-9 pattern?"}
MATCH -->|no| ALLOW["allow"]
MATCH -->|yes| ASK["ask — route to operator (C-5)"]
H -.->|"hook itself crashes"| FC{"_FAIL_CLOSED_ON_ERROR?"}
FC -->|"False (fail-open; gate flips only on explicit confined/exclusive)"| ALLOW2["fail-OPEN → allow<br/>(never brick a trusted session)"]
FC -->|"True (confined/exclusive)"| DENY["fail-CLOSED → deny<br/>(operator opted into a boundary)"]
SB24 — The orchestrator-only write policy and the agentteams_runner MCP server ✅/⚙¶
(Appended ID; it sits in Part V because it is an enforcement layer that rides on SB10–SB14's session
sandbox.)
Canonical facts.
1. An opt-in switch. write_policy: "orchestrator-only" in the brief turns it on; without it every
agent file renders byte-identical (apply returns the content unchanged). write_policy.resolve is
the single resolver native generation and --interop-from --description share. It refuses the
switch on any framework but claude, goose and codex; with privilege_profile: "cooperative"; and
without an explicit confined/exclusive (the default leaves the gate hook fail-open, SB17); and
it refuses a legacy Goose tool scoping. write_policy_frameworks can scope it to some of those
frameworks; the others render without it, with a notice. Codex is accepted only on Linux/macOS
and holds only when launched through .codex/confined-run.example.sh (the launcher masks the key; a
generated role gate, codex-role-gate.py, limits spawned agents to read-only tools) — generation says
it cannot verify that launch. Copilot and agents-md are not covered.
2. Narrowing + sections. Under the switch every non-orchestrator agent's canonical tools: line is
narrowed to read/search/todo (always keeping read; no shell, no dispatch) before any adapter
reads it, a legacy one-line allowed-tools: is removed, and the agent gets a fenced "Write Policy:
Return Proposals, Never Write" section that overrides any write wording in its body: return a
change-proposal / delete-proposal / command-request. The orchestrator (and Goose's
bridge-orchestrator) keeps its tools and gets "Applying Proposals" instead.
3. The out-of-session runner holds the key. The orchestrator's agentteams --apply-proposal /
--run-request only queue requests; the operator's agentteams --serve-requests, started outside
every agent session, is the only process that holds the ledger key (read from a 0600 key file under
~/.config/agentteams/keys/, which the session sandboxes read-deny; it refuses to start with a key in
its environment), applies writes, and runs commands — each command and gate inside an OS sandbox built
from confined_programs (no usable sandbox → refuse, unless the brief's logged
allow_unconfined_runs). It refuses a brief without the switch, pins the brief, the operator
confined_programs file and the grants file at start (a change stops it), and serves the queue one
request at a time. A request's channel (orchestrator vs MCP) comes from the directory it arrived
in, never from a field the sender writes; the MCP channel may queue only stage-proposal and
run-request, and its commands are capped at 120 s.
4. The agentteams_runner MCP server (mcp_grants). An agent the brief grants it gets exactly the
granted subset of five tools — read_file_hashed, write_file, delete_file, run_command,
request_status — and nothing else. The server is a keyless queue client: it writes only request
and ack files under .agentteams-queue/, never the project, and is not a trust boundary (the runner
re-validates identity from the nonce, scopes, allowlist, gates and base hashes). Generation installs it
at .agentteams/bin/agentteams-runner-mcp.py (session-write-denied); agents launch it with an
absolute system python3 (/usr/bin, /usr/local/bin, /opt/homebrew/bin, never PATH or a home
dir) and -I -S; the server derives the project root from that install location; the runner checks
the installed copy against a pinned SHA256 on every poll and stops serving on a mismatch.
read_file_hashed applies its deny list (VCS dirs, the control plane and queue, credential names and
suffixes) to both the requested and the resolved path, so an in-project link to .env is refused.
5. Staged by default; direct only on a verified operator grant. Generated agents always launch the
server as staged. A write is checked, gated and stored under .agentteams/staged/ for the
orchestrator (--list-staged, --show-staged, --apply-staged, which re-runs every check against
the file as it is now, --reject-staged). The runner alone upgrades a staged change-proposal to a
direct write, and only for an agent holding an operator Ed25519-signed grant that verified at
start (purpose-tagged; bound to the agent, tools, scopes, gates and the server's sha256; ≤30 days;
≤500 writes; at most 3 active grants, or none is used; read from an operator-owned file outside the
project, custody-checked). An unsigned approval: "direct" acts as staged. Deletes are always
staged. A direct grant is treated as a constraint-relaxing change (Rule 15), hence the asymmetric
signature and the caps.
6. Audit and wiring check. AR_WRITE_POLICY (audit_agent_contract, importing the generator's
read-only token set so they cannot drift) flags any non-orchestrator agent that can write, dispatch,
or carries any capability key beyond its canonical runner block, generated or adopted.
--check-wiring adds runner_mcp.wiring_problems for a granted team: no config scope may define a
server named agentteams_runner (shadowing); Claude's live settings.json must deny edits to
.agentteams/** and .claude/agents/** and reads of .agentteams/** and .agentteams-queue/**,
enable the sandbox and denyWrite .agentteams and .claude, and settings.local.json may not
switch the sandbox off or allow unsandboxed commands; Goose's .goose/sandbox.sb must deny writes to
.agentteams and .goose/recipes. The layer's modules — write_policy, the audit, the runner and
its policy/staging halves, runner_mcp and the server, mcp_direct_grants, the Codex role gate —
are pinned in the integrity manifest (SB18).
7. Interop (PR #174). --interop-from DIR --description BRIEF now carries write_policy and
mcp_grants into an import under the same resolve checks, narrows each imported agent's scopes,
gives it its section exactly once (a contradicting section is refused), and installs the pinned server
— for claude and goose imports only (codex is refused: interop does not emit its launcher).
--convert-from and bridge subagent stubs (Claude, Goose) apply none of this, so they refuse a
team under the switch. For claude and goose, they name the --interop-from … --description … --overwrite
command; for other convert targets, they say to regenerate natively. The switch
is read from the target team's references/build-log.json (native generation records write_policy
there) or from --description. A build-log that exists but is unreadable fails closed. A bridge-only
project with no native build-log has no record, so it is detected only through --description.
8. Status and ceiling. ✅ in code and tests: the narrowing, sections, refusals, runner, queue
channels, staging, Ed25519 grants and caps, server deny list and pin, AR_WRITE_POLICY,
--check-wiring's checks, and interop. ⚙ not yet verified: that Claude Code and Goose actually
launch the inline server and honour it on a live host — the tests drive the server as a subprocess,
not through either harness (the mathAgents M6 pilot is that test). Staged mode cannot run an
agent's own edit-test loop: a staged write has not landed, so a command it runs sees the old file.
--check-wiring checks configuration, not behaviour. Ceiling: the runner and the operator's
host are the TCB — the layer bounds a mis-steered agent; whoever can read the ledger key or holds
the operator's signing key on the same host is out of scope (ceiling #4, SB21). It inherits SB14: it
holds only where the session sandbox is wired and in force.
Source. agentteams/write_policy.py:29-40 (token sets), :49, :84, :109 (sections), :164
enabled, :179-240 resolve, :263-307 narrow_tools, :309-339 apply;
agentteams/proposal_runner.py:1-19 (runner model), :43-73 (channels, MCP kinds, serial queue, 120 s
cap), :190-237 (installed-server check), :268-282 (switch required, env-key refusal, key file),
:402-433 (direct upgrade; deletes never), :504-521 (pins re-checked per poll);
agentteams/proposals.py:109-115 (key file custody); agentteams/runner_mcp.py:21-27 (tools, install
path, -I -S, SHA256), :67 (system pythons), :145-168 install_files, :232-296 wiring_problems;
agentteams/data/agentteams-runner-mcp.py:14-24 (safety model), :46, :56-62 (deny list),
:261-269 (requested + resolved path), :357-367 (root from install location);
agentteams/proposal_staging.py:31-33, :101-128 apply_direct, :166 apply_staged;
agentteams/mcp_direct_grants.py:1-18, :37-48 (caps), :174-221 verify, :224-259
active_grants; agentteams/proposal_policy.py:220-245 (mcp_grants validation; unsigned direct =
staged); agentteams/frameworks/claude.py:151-155, agentteams/frameworks/goose.py:280-286 (launched
staged); agentteams/audit_agent_contract.py:33-34, :778 _check_write_policy;
agentteams/cli/standalone_modes.py:204-261 (--check-wiring); agentteams/interop_write_policy.py:1-55,
:192 install_server, :228-310 (convert/bridge refusal); build_team.py _write_run_log (write_policy); agentteams/integrity.py:54-68 (pins); tests/test_runner_mcp.py (server driven
as a subprocess).
Dial. R Full · D Core · S Full · E Light (ceiling #4 in plain words).
Part VI — Integrity, provenance & drift¶
SB18 — The boundary content is tamper-tracked ✅¶
Canonical facts.
1. enforcement-integrity.json pins a sha256 of every enforcement module. For sandboxing it pins both
the emitters (_sandbox_emit.py, _linux_sandbox_emit.py) and the launcher asset
(templates/universal/sandbox/confine-run.sh) — because the bwrap flags that ARE the boundary live in
the .sh, so pinning the .py alone would leave the boundary content untracked. A silent edit
dropping --unshare-net trips --verify-integrity (red-team probe E4) instead of passing unnoticed.
2. The manifest is regenerated deliberately (--write-integrity-manifest) only after an INTENDED control
change; the diff IS the control. integrity.py pins itself, so removing an entry is detectable.
Source. agentteams/integrity.py:65 (_sandbox_emit.py), :71 (_linux_sandbox_emit.py), :77
(confine-run.sh); references/enforcement-integrity.json.
Dial. R Full · D Full · S Core · E Light (mandatory ceiling: "tamper-evident, not tamper-proof").
SB19 — Cross-repo single-source-of-truth & drift protocol ✅/⚙¶
Canonical facts.
1. The launcher is emitted verbatim so a consuming project (e.g. baseAgent) keeps a byte-identical
copy guarded by a sha256 pin; agentteams is the single source of truth. A byte change is a
coordinated event: agentteams pings the consumer, both re-pin, and the consumer re-runs its live-kernel
deny test. The launcher header is consumer-neutral (no consumer-specific paths or netns baked in);
its --netns default is a neutral token a consumer overrides explicitly at invocation rather than
by editing the file.
Source. agentteams/templates/universal/sandbox/confine-run.sh (provenance header);
agentteams/frameworks/_linux_sandbox_emit.py (verbatim emission).
Dial. R Full · D Core · S Light · E Skip.
flowchart LR
SRC["agentteams template asset<br/>confine-run.sh (source of truth)"] -->|"emit verbatim"| ART["emitted<br/>sandbox/confine-run.sh"]
SRC -->|"sha256 pinned in"| MAN["enforcement-integrity.json (SB18)"]
ART -->|"consumer keeps<br/>byte-identical copy"| CON["consumer repo<br/>(sha-pin drift test)"]
EDIT["intended byte change"] --> SRC
EDIT -.->|"ping + both re-pin +<br/>consumer re-runs deny test"| CON
MAN -.->|"--verify-integrity trips on<br/>an UNINTENDED edit (probe E4)"| STOP["fail-closed / flagged"]
Part VII — Honest ceilings, verification & red-team¶
SB20 — What is verified, and what is not ✅/⚙¶
Canonical facts.
1. Launcher Linux (bwrap) branch: enforcement-VERIFIED. A live-kernel deny test proves
write-outside---scratch, credential/sibling read, and raw egress are all denied for a real process
(incl. a real goose process), reproduced on stock bwrap.
2. Launcher macOS (build_macos) branch: enforcement-UNVERIFIED (2026-W36). Its intended on-host
deny test is sandbox/mac-escape-tests.sh, which must pass unnested, with its positive
controls, on a real mac before the macOS boundary may be called "confined" — wiring-verified ≠
enforcement-verified. Known gap (as shipped): that test hard-targets a confine-run.macos-ref.sh
wrapper that agentteams does not currently emit (it exits early if absent), so the gate is not
yet runnable against the emitted launcher — macOS therefore cannot become "verified" until the
test/wrapper mismatch is resolved (logged for the launcher/test owner). Its honest residuals ride
along in the advisory (SB8): memory UNCAPPED, no syscall filtering, setuid denylist ≠ NoNewPrivs,
loopback-only proxy.
3. Native macOS Seatbelt (goose/claude): also unverified — the goose/claude Seatbelt profiles are
enforcement/profile-syntax-unverified off a mac. Claude Code's native Linux product arm: VERIFIED
(2026-09-30), under a precondition — on Ubuntu with kernel.apparmor_restrict_unprivileged_userns=1,
bubblewrap 0.11.1, socat 1.8.1.1 and Claude Code 2.1.251, tests/test_os_sandbox_product_enforcement.py
passed write confinement, the switch denyWrite, denyRead (incl. ~/), the signing-key directory
read-deny, the .claude read-only bind + rename refusal, and the fail-closed refusal was observed live —
only after installing Claude Code's documented /etc/apparmor.d/bwrap profile, which disables
Ubuntu's bwrap-userns-restrict host-wide (without it sandboxed commands fail closed). Other distros,
Claude Code versions, and bypassPermissions remain unverified.
Each of these is a distinct mechanism with a distinct verdict — never conflated.
4. Cross-guide reconciliation (done, 2026-09-01). The sibling
Security Guide's earlier "verified on macOS only" framing
was stale (it predated the launcher). It has since been corrected to match current source (Linux
deny-tested; macOS unverified) across its skeleton, four editions, and audience-profiles.md, and it
cross-links here. The two guides now agree; the only nuance this guide adds is the newer macOS
build_macos branch (fact 2).
Source. agentteams/templates/universal/sandbox/confine-run.sh (status header);
agentteams/templates/universal/sandbox/mac-escape-tests.sh (the macOS deny test);
agentteams/host_features.py (Linux VERIFIED comment);
docs_src/api-reference/workspace-privilege-scoping.md (Linux verification verdict).
Dial. R Full · D Core · S Full · E Core (mandatory ceiling #3).
SB21 — What sandboxing does NOT close ✅/⚙¶
Canonical facts.
1. T6 / host-as-TCB stay bounded, never closed. A same-host operator shell or a key-holding peer is
out of scope for these controls; the sandbox boxes a mis-steered agent, not a determined local
principal.
2. seccomp/Landlock is a further layer NOT yet added — the bwrap launcher is filesystem + netns +
NoNewPrivs confinement, not syscall filtering.
3. The PreToolUse hook's uncovered surfaces (SB16.2) remain the operator's responsibility — the PR-merge
rule (SB16.3–4) included: an obfuscated or split merge call still evades it.
4. The write-policy layer (SB24) does not close them either. Its runner and the operator's host are
the TCB: a same-host principal who can read the ledger key or holds the operator's grant-signing key is
out of scope (this is ceiling #4 again, not a new one). It holds only where the session sandbox is
wired (SB14); its live launch by Claude Code/Goose is not yet verified; it covers claude and goose,
codex only through its launcher + role gate, and not Copilot.
Source. agentteams/templates/universal/sandbox/confine-run.sh (policy header);
agentteams/templates/universal/security.template.md (delete-gate limits);
agentteams/templates/universal/hooks/constitutional-gate.py:75-85; agentteams/write_policy.py:179-240;
agentteams/proposals.py:109-115; agentteams/mcp_direct_grants.py:1-18.
Dial. R Full · D Core · S Full · E Core (mandatory ceiling #4 restated).
Part VIII — Synthesis & reference matter¶
SB22 — The end-to-end synthesis ✅/⚙¶
Canonical facts.
1. A confined team's life: request (profile/token, SB4–SB6) → decide (is_sandbox_capable +
advisory, SB7–SB9) → emit the mechanism artifact (SB10–SB13) → wire (operator activation,
SB14–SB15) → enforce (OS + fail-open/closed hook, SB16–SB17), with the emitters + launcher asset
tamper-tracked (SB18–SB19) and every claim honestly bounded (SB20–SB21). No single stage is
the boundary; confinement is the composition, and it engages as tested only when opted-in and wired.
2. An optional layer on top (SB24). Under the opt-in write_policy: "orchestrator-only", which
requires an explicit confined/exclusive profile, only the orchestrator writes; other agents return
proposals or go through the agentteams_runner MCP server, and an out-of-session runner — the only key
holder — applies, stages or runs them. It relies on the wired session sandbox to keep the key and the
control plane out of the agents' reach.
Source. all of the above.
Dial. R Full · D Core · S Full · E Light.
flowchart TD
subgraph REQUEST
r1["privilege_profile:<br/>cooperative/confined/exclusive"] --> r2["+ workspace_write_roots,<br/>protected_read_paths, *:sandbox token"]
end
subgraph DECIDE
d1["is_sandbox_capable(framework, platform)"] --> d2["advisory: none / manual-wire / unenforced-host"]
end
subgraph EMIT
e1["claude settings block"]:::m
e2["goose Seatbelt .sb"]:::m
e3["launcher confine-run.sh<br/>(bwrap Linux / build_macos macOS)"]:::m
end
subgraph WIRE_ENFORCE["WIRE + ENFORCE"]
w1["operator activates<br/>(merge / GOOSE_SANDBOX / WRAP)"] --> w2["OS confinement in force"]
w3["PreToolUse hook:<br/>fail-open (coop) / fail-closed (confined)"]
end
REQUEST --> DECIDE
d2 -->|"fatal + default"| FCX["FAIL CLOSED (refuse)"]
DECIDE --> EMIT
EMIT --> WIRE_ENFORCE
INT["enforcement-integrity.json:<br/>pins emitters + launcher asset"] -.->|tamper-track| EMIT
CEIL["honest ceilings:<br/>opt-in · inert-until-wired · Linux-verified-only · closes-nothing-absolutely"] -.-> WIRE_ENFORCE
WP["optional write_policy orchestrator-only (SB24):<br/>agents propose · out-of-session runner holds key,<br/>stages / applies / runs confined"] -.->|"relies on the wired sandbox"| WIRE_ENFORCE
classDef m fill:#eef,stroke:#557;
SB23 — Reference tables & glossary ✅¶
Canonical facts.
1. The capability matrix (SB7 — POSIX framework-neutral: any framework capable on both Linux and
macOS; claude everywhere; Windows none), the three advisory codes (SB8 — one fatal
unenforced-host, two non-fatal manual-wire, one per POSIX platform), the mechanisms and their emit
paths (SB10–SB12 — the native settings block, the native macOS Seatbelt paths, and the dual-branch
launcher bwrap+sandbox-exec), the pinned modules (SB18), the write-policy layer's frameworks,
modes and commands (SB24 — claude/goose, codex via its launcher, not Copilot; staged by default, direct
only on a verified operator grant, deletes always staged), and the four load-bearing ceilings (top of
this map) are the quick-reference surface. Editions R and D carry the full tables; S carries the matrix
+ ceilings; E carries the four ceilings in plain words.
Source. this SKELETON.
Dial. R Full · D Full · S Core · E Light.