Workspace Privilege Scoping¶
Opt-in workspace write-confinement for generated teams. It lets a project declare that its agents may only write inside their own workspace, enforced by the host's OS-level sandbox rather than by agents choosing to comply.
This covers the privilege model (see the investigation report
references/plans/workspace-privilege-scoping.report.md): P1, complete write
confinement on the Claude Code target (below); P2, signed cross-workspace capability
grants (see "Cross-workspace grants"); and P3, read-exclusion hardening + inbound
hardening guidance (see "Read-exclusion & cross-team exclusion (P3)"). All three are
independently optional — see the selection matrix directly below.
Selecting P1 / P2 / P3 (all optional)¶
| You want | Select | What you get |
|---|---|---|
| no OS boundary — agents trusted | privilege_profile: cooperative (opt out of the default) |
nothing emitted |
| P1 (default since 2026-W39) — confine my team's writes | privilege_profile: confined (now the default) |
OS write-confinement to the workspace (Bash + subprocesses), emitted as an inert settings/config example (never live) until you merge it |
| P2 — cross-workspace reach by grant | issue grants (any sandbox profile) | holder's allowWrite widens to signed-granted paths |
| P3 — read-exclusion + inbound hardening | privilege_profile: exclusive |
P1 plus OS read-exclusion of protected paths (P3a) plus an operator inbound-hardening advisory (P3b) |
exclusive is a superset of confined. P2 is orthogonal (issue grants or don't).
Enforcement runs on Claude Code's two documented sandbox backends — macOS (Seatbelt)
and Linux / WSL2 (bubblewrap) — from the same emitted config (no per-OS agentteams
logic). The macOS mechanism was additionally verified by a direct Seatbelt spike. On
Linux, a direct bwrap kernel-deny was observed working once (bwrap 0.11.1,
Ubuntu 26.04 aarch64, one protected path, a hand-built invocation): python3 ran inside
the sandbox and the protected read returned denied. That confirms only the kernel
mechanism — the still-untested link is argument construction: whether Claude Code
correctly derives the bwrap arguments from agentteams' denyRead JSON (a kernel that
denies when handed correct hand-built args says nothing about whether Claude Code's
derived args are correct). That link has since been tested (2026-09-30): Claude Code's
native sandbox is verified end-to-end on Linux for one host configuration — Ubuntu with
kernel.apparmor_restrict_unprivileged_userns=1, bubblewrap 0.11.1, socat 1.8.1.1, Claude Code
2.1.251 — and only after installing Claude Code's documented unconfined /etc/apparmor.d/bwrap
profile (see "Fail closed: failIfUnavailable and the Linux dependencies" below). Other distros and Claude Code versions are untested.
Native Windows has no OS sandbox
agentteams can configure and stays advisory-only.
What it does¶
When enabled, agentteams injects a sandbox block into the Claude settings example
it already ships (.claude/settings.hooks.example.json):
{
"sandbox": {
"enabled": true,
"filesystem": {
"allowWrite": ["."],
"denyWrite": [
".claude/agents/references/agent-privilege.json",
".claude/hooks/constitutional-gate.py",
".claude/agents/references/authorized-verify-keys",
".claude"
]
},
"allowUnsandboxedCommands": false,
"failIfUnavailable": true
}
}
Once the operator merges this into their .claude/settings.json, Claude Code's
native sandbox (macOS Seatbelt / Linux + WSL2 bubblewrap) confines every Bash
command and child process file write to the allowWrite roots. Writes outside are
denied by the kernel — not by an agent's judgement, and without agentteams having to
parse shell command lines.
Fail closed: failIfUnavailable and the Linux dependencies¶
Security fix (2026-09-30). Blocks emitted before this fix set enabled: true and
allowUnsandboxedCommands: false but not failIfUnavailable, and that combination
fails open. Measured on Linux (Claude Code 2.1.251, bubblewrap 0.11.1, socat absent):
Claude Code printed "Sandbox disabled … Commands will run WITHOUT sandboxing", and a write to
$HOME succeeded. allowUnsandboxedCommands: false only closes the per-command
dangerouslyDisableSandbox escape; it does not stop Claude Code dropping the whole sandbox
when the sandbox cannot start.
The emitted block now sets failIfUnavailable: true on macOS and Linux targets. Claude
Code documents this key (code.claude.com/docs/en/sandboxing) as "intended for managed
deployments that require sandboxing as a security gate". Measured on the same host, it made
Claude Code exit 1 with "sandbox required but unavailable … refusing to start", and nothing
was written. The trade-off is intended: a host that lacks the sandbox dependencies can no
longer start Claude Code in a project whose settings.json merged the block. A block
generated on native Windows omits the key, because Claude Code has no OS sandbox there
and the key would stop it starting at all; there the block stays advisory. The platform is taken
from the generating host, so a block generated on Windows (or any host other than
Linux or macOS) and then run on Linux still fails open. Regenerate it on the target
platform, or add "failIfUnavailable": true by hand. --update on a Linux or macOS host
prints a notice while a merged block lacks the key.
If you merged an earlier block, re-run agentteams --update and re-merge the sandbox
block into .claude/settings.json, or add the one line "failIfUnavailable": true to your
merged sandbox object. --update prints a notice while the merged block lacks it.
Linux dependencies. Claude Code's Linux/WSL2 sandbox needs bubblewrap (bwrap) and
socat on PATH. scripts/install-sandbox-deps.sh (in the agentteams repository) installs
both via apt or dnf, smoke-tests bwrap, and diagnoses (never changes) the AppArmor
user-namespace restriction below.
Other measured Linux behaviours (2026-09-30, Ubuntu):
- A missing
denyWritepath breaks bwrap for every command ("Can't mkdir …: Not a directory"). That is fail-closed per command, not a refusal to start, sofailIfUnavailabledoes not change it. Generation emits everydenyWritepath. - A missing
denyReadpath is tolerated, so theexclusivedefaults (~/.kube,~/.azure, …) do not need to exist. - With
kernel.apparmor_restrict_unprivileged_userns=1(the Ubuntu default), Claude Code's seccomp step fails ("apply-seccomp … nested userns is capability-restricted"), so every sandboxed command fails closed. The block is Ubuntu'sbwrap-userns-restrict: every process bwrap launches runs underunpriv_bwrap, which denies capabilities, and Claude Code's seccomp helper runs there from an in-memory file. A profile onclaudetherefore does not help, and neither doesenableWeakerNestedSandbox. Claude Code's documented fix is an unconfinedprofile bwrap /usr/bin/bwrap. On Ubuntu it collides by name with the one inbwrap-userns-restrict, and it lifts AppArmor confinement from everything bwrap launches on the host.scripts/test-sandbox-apparmor-userns.shtries each candidate (the documented profile and the sysctl; alocal/unpriv_bwrapallow cannot beat its deny) temporarily, probes a real sandboxed command, and reverts. It persists one only with--persist, and only if that candidate's probe passed.
Product-arm verdict (2026-09-30): VERIFIED on Linux, on one host configuration. On Ubuntu with
kernel.apparmor_restrict_unprivileged_userns=1, bubblewrap 0.11.1, socat 1.8.1.1 and Claude Code
2.1.251, after scripts/test-sandbox-apparmor-userns.sh --persist documented installed Claude
Code's documented unconfined /etc/apparmor.d/bwrap profile, RUN_CLAUDE_SANDBOX_ITEST=1 pytest
tests/test_os_sandbox_product_enforcement.py (which fails loudly unless the sandbox is
demonstrably operational) gave 8 passed, 1 skipped: write confinement (P1), the switch denyWrite
(D-3), denyRead including ~/ expansion (P3, P3-3), the signing-key directory read-deny (PF1),
the .claude read-only bind and rename refusal (F-4). The skipped
test_fail_if_unavailable_refuses_to_start_without_deps needs a host without the deps; the
refusal was observed live on the same host without socat ("sandbox required but unavailable …
refusing to start", exit 1). Precondition: that profile disables Ubuntu's
bwrap-userns-restrict for bwrap host-wide; without it every sandboxed command fails closed.
Still unverified: other distros and Claude Code versions, and behaviour under
bypassPermissions.
Verified at two levels:
- Mechanism (2026-08-20): a bash redirect and a Python child process both fail with "Operation not permitted" when writing outside the root (raw Seatbelt).
- Product, end-to-end (2026-08-26, macOS): real Claude Code (
claude -p) launched against the emittedsandboxblock enforces it — a write to$HOME(outsideallowWriteand outside the session temp dir) is DENIED, adenyWriteofreferences/agent-privilege.jsonblocks an agent from flipping theenforce_decision_signingswitch, and adenyReadsecret cannot be read, while legitimate in-root writes/reads still succeed. Encoded as the opt-in regression guardtests/test_os_sandbox_product_enforcement.py(run withRUN_CLAUDE_SANDBOX_ITEST=1). One caveat learned here: the sandbox allows writes to the working dir and the session temp dir, so an escape test must target a non-temp path (e.g.$HOME) — a/tmptarget is allowed and proves nothing.
P3-3 resolved (2026-08-26): the ~-relative denyRead form — the default agentteams
emits — was empirically verified to enforce end-to-end. A denyRead: ["~/.secret"] blocks a
read of that path while a different HOME file (not denied) stays readable, so Claude Code
expands ~ before the Seatbelt deny — the tilde form is not a silent no-op. (At the raw
Seatbelt level a literal unresolved path still no-ops, which is why the expanduser opt-in
remains available as defense-in-depth, but it is not required for correctness in the product.)
Covered by tests/test_os_sandbox_product_enforcement.py::test_p3_3_tilde_denyread_expands_and_enforces.
Linux status (verified 2026-08-26 on a real VM — Ubuntu 26.04 aarch64, bwrap 0.11.1).
- Mechanism: verified. A direct bwrap test denies a write outside the read-write bind, allows
the in-bind write, and hides an unbound read — the kernel enforces denyRead/allowWrite-equivalent
confinement.
- Product arm (Claude Code deriving+enforcing the emitted config): blocked on a stock VM. Claude
Code's Linux sandbox needs nested unprivileged user namespaces, which stock Ubuntu 26.04 restricts
(apply-seccomp: setgroups nested userns), so the sandbox cannot initialize there. This is a Claude
Code + host-kernel matter, not an agentteams config defect.
- Fail-closed at the seccomp stage only. When bwrap starts but the seccomp step cannot initialize,
Claude Code refuses to run the Bash command regardless of allowUnsandboxedCommands. That is
NOT true of every failure: with a sandbox dependency missing (e.g. socat), Claude Code disabled
the sandbox and ran commands unsandboxed (measured 2026-09-30) unless failIfUnavailable: true is
set — see "Fail closed" above. A positive Linux product run requires a host that permits Claude
Code's nested-userns sandbox.
Do not read the above as "Linux product arm verified" — it is not; the mechanism is verified and the
failure mode is fail-closed. Full evidence:
tmp/by-week/2026-W35/security-followups/linux-verification-verdict-2026-08-26.md.
Two distinct Linux mechanisms — do not conflate their verdicts. This section is about the
claudeframework's own Linux sandbox (Claude Code's native bubblewrap backend, wired through the emitted settings block): its mechanism is verified, and its product arm is verified on Ubuntu with Claude Code 2.1.251 only after installing Claude Code's documented bwrap AppArmor profile (on stock Ubuntu without it, sandboxed commands fail closed). That is a SEPARATE path from the framework-neutralsandbox/confine-run.shbwrap launcher documented under "End-to-end" below — a standalone launcher agentteams emits for confined/exclusive teams of any framework, whose enforcement is VERIFIED by a live-kernel deny test. The two verdicts describe different mechanisms and different preconditions; neither implies the other.
Two properties matter for the privilege model:
.claude/is auto-protected even insideallowWrite, so a confined agent cannot edit its own boundary. A human editing outside an agent session is unaffected — the "preserve normal human access" requirement is met for free.- The control plane is
denyWrite-protected (D-3). Theenforce_decision_signingswitch (references/agent-privilege.json) lives inside the write root and is not under.claude/, so the auto-protection above does not cover it — adenyWriteentry does.denyWritetakes precedence overallowWrite(deny-over-allow; the Seatbelt write-deny mechanism is verified intests/test_os_sandbox_enforcement.py). The gate hook is included belt-and-suspenders — the.claude/auto-protection above is itself not yet empirically verified. Globs are unsupported, so these are exact paths; the switch and hook are written by the operator at generate/update time (outside any sandbox), never by the in-sandbox agent, so denying them costs no legitimate workflow.Linux runtime note (D-3). The
denyWritebinding is conditional on two things at runtime: (a) the operator has merged this example into the live.claude/settings.json(the example is inert on its own), and (b) eachdenyWritepath resolves to a file that exists — on Linux, Claude Code's bwrap backend binds each deny path and a missing one makes the whole sandbox fail to initialize (macOS Seatbelt tolerates a missing deny path). A normally-generatedconfined/exclusiveteam emits both control files, so both exist; a hand-assembled or partial team must ensure they are present.tests/test_workspace_privilege_scoping.py::test_every_denywrite_control_file_is_emittedguards that generation keeps emitting every path thedenyWritelist names. - The decision-signing verify-key store is
denyWrite-protected too. The directory<agents dir>/references/authorized-verify-keys/holds the operator's Ed25519 public verify keys (<key-id>.pub.pem). It is denied so an in-sandbox agent cannot plant its own key. The store must exist for bwrap to start, so generation emits a frozenREADME.mdsentinel into it, in the same step that emits the deny. agentteams never writes, rewrites or deletes a*.pub.pem.denyWritebinds Bash only (below), so the Write/Edit-tool route is closed by thepermissions.denyEdit(...)rules. Whether Claude Code honours a directorydenyWriteentry is unverified until a real-host test passes. The goose Seatbelt profile denies the same directory with(subpath …), which covers everything under it. - The control plane cannot be renamed away (F-4, 2026-09-30). A deny follows a path, so
an agent that may write the project root could
mv .claude .claude.old, recreate.claude/with its own settings, hooks and switch, and have the next session read the planted tree. Captured Claude Code 2.1.251 argv shows eachdenyWriteentry becomes a--ro-bindplaced after the read-write root bind, sodenyWritenow also names the whole.claudedirectory: it becomes a read-only mount point, its rename failsEBUSY, and a planted.claude/settings.local.jsonis refused. A deny only removes capability, whatever the write roots, andpermissions.denyis unchanged (noEdit(/.claude/**), which would block agents' own Write-tool edits there). For a team generated under a non-default--output, the config dir is derived from the same switch path as the other entries, and the existing deny-path-mismatch warning names it. Several frameworks in one project: see the sibling team-dir bullet below (.codex,.github/agentsand.gooseare denied when present). The goose Seatbelt profile deniesfile-write-unlinkandfile-write-createon each control-plane ancestor literal (.goose,.goose/recipes,.goose/recipes/references,.claude,.claude/hooks) — unverified, no macOS host has run it.sandbox/confine-run.sh(Linux) read-only binds every control-plane path it finds under a writable root and self-binds each ancestor so its rename failsEBUSY; see the launcher section. Status: mechanism-verified (raw bubblewrap probes intests/test_sandbox_ancestor_rename.py); for the Claude arm, product-verified on Linux (2026-09-30, Claude Code 2.1.251:test_f4_captured_argv_…found.claudeand everydenyWriteentry read-only bound in the real bwrap argv, andtest_f4_config_dir_cannot_be_renamed_…saw a sandboxedmv .claudefail), under the AppArmor precondition above. Tracked follow-up: pinning the control plane outside the write root (defence in depth; needed where the chain is not mount-protected, i.e. Seatbelt). - Sibling team dirs:
.github/agents(copilot) and.codex/agents(codex), 2026-09-30. These dirs hold the same trust roots as.claude/agents: the switch, the verify-key store, the three rosters and thebuild-log.jsonteam marker. They emit no sandbox of their own, so the other arms protect them:
| Arm | What it protects | Status |
|---|---|---|
Claude denyWrite |
.codex (whole), .github/agents (never .github), .goose (whole), each only when that team is present at generation (real dir, no symlink, with a team marker) |
product-verified, Claude Code 2.1.251, Linux (test_sibling_*: captured argv, mv .github EBUSY, .github/workflows writable, negative control); re-run after upgrades |
Claude permissions.deny |
Edit(...) on each sibling trust-root file, plus .goose/sandbox.sb and .codex/config.toml, always emitted |
emitted, product-unverified; unverified under bypassPermissions |
sandbox/confine-run.sh |
the four team dirs; entries required once the marker exists; .codex/config.toml protect-if-present; .github/workflows never bound |
mechanism-verified (raw bubblewrap via the launcher) |
| goose Seatbelt | the copilot and codex roots; .codex ancestors always, .github ancestors only with a copilot team present |
UNVERIFIED (no macOS host) |
Claude Code self-binds the parent of every denyWrite path read-write before it ro-binds the
path. That is why denying .github/agents also makes .github non-renameable while
.github/workflows stays editable. The behaviour is undocumented, and the product itest pins
it.
Copilot and codex teams get the roster stubs and the store sentinel whenever the switch is emitted, whatever their own profile. A stub reads like an absent file.
Before deleting a sibling team, remove its entry from .claude/settings.json. A missing
deny path makes bwrap fail every command ("Can't mkdir … Not a directory"). Every generate,
--update and --check names such stale entries, and also names present teams that the live
block lacks.
Residuals:
- A planted fake marker causes a launcher refusal (denial of service only).
- Copilot *.agent.md and copilot-instructions.md stay Edit-tool writable, so copilot is
NOT fully covered.
- Bash cleanup in .github/agents is blocked.
- The .codex whole-deny is untested against Codex CLI's own writes.
- .agents (agents-md) is out of scope.
- The operator's private signing key is read-denied in every profile (F-1). Every emitted
sandbox — confined as well as exclusive, and a cooperative team with claude:sandbox —
puts ~/.config/agentteams/keys in denyRead (a confined block gets that entry and no
allowRead). The goose Seatbelt profile and sandbox/confine-run.sh deny the same directory.
Keys provisioned before this change sit directly in ~/.config/agentteams/; the ones present
on the generating host are denied by exact path for now (the sandbox takes no globs), and
provision-operator-signing-key.sh --migrate (an operator helper in the agentteams source
repository's references/authorized-verify-keys/, not emitted into generated projects) moves
them into keys/ without overwriting or deleting anything. A workspace write root at or inside keys/ is refused at generation, since
a narrower allowRead would re-open it. Only the key file is covered: the environment
variables AGENTTEAMS_DECISION_ED25519_KEYFILE and AGENTTEAMS_*_SIGNING_KEY are inherited by
Claude-sandboxed commands (the launcher scrubs its environment; Claude Code does not). No
relaxing or elevated authorization accepts an HMAC-only signature, which is what keeps an
inherited HMAC key a residual rather than a bypass. Since rc.11 a decision row whose signed sig_scheme
is ed25519 (a HALT-RETRACTED included) is verified only against the team's verify key, never by
HMAC, so a team whose clearances are all operator-signed needs no AGENTTEAMS_DECISION_SIGNING_KEY in
any render or agent environment.
- permissions.deny binds the built-in tools. Claude Code's sandbox.filesystem applies
only to Bash and its child processes; the built-in Read, Edit and Write tools obey
permissions instead (measured: the Write tool overwrote a denyWrite path outside
.claude/). So the same branch that emits the sandbox block adds permissions.deny:
Read(~/.config/agentteams/keys/**) and Read(~/.config/agentteams/*.pem) (the legacy glob,
in gitignore syntax — used here only), and project-anchored Edit(/…) rules for the switch,
the verify-key store (Edit(/.claude/agents/references/authorized-verify-keys/**)), and
(PR-D) the team's security-approvers.txt, authorized-managers.txt and
management-authority.json, the project-root references/security-approvers.txt,
.claude/settings.json, .claude/settings.local.json and .claude/hooks/** (which subsumes
the single gate-hook rule). The roster, settings and hooks rules are emitted,
product-unverified: they are stricter than Claude Code's own .claude ask, which was
measured under acceptEdits only. They block an agent's tool edits of those files; you merge
settings outside the session. The HMAC grant route is not closed here: an in-sandbox Bash
process can still create the project-root roster (it is protect-if-present in the launcher);
that route is addressed separately by the Ed25519-grants change.
Edit(...) covers Edit, Write and MultiEdit; Claude Code reports that a NotebookEdit(path)
rule "is not matched by file permission checks", so none is emitted. A Read(...) rule covers
Grep and Glob on a best-effort basis; there is no Grep(...)/Glob(...) form. Residuals:
the rules are inert until merged; they are unverified under bypassPermissions; hooks and MCP
servers run unsandboxed; goose has no Claude-style permission system (its Seatbelt profile
binds every process goose runs). A denyRead path that does not exist was tolerated by bwrap on
Claude Code 2.1.251 (Linux). The Claude sandbox arm is verified on Linux (see the product-arm
verdict above); these permissions.deny rules are a separate layer — the Edit rules for the
switch and verify-key store and the key-dir Read rule were exercised live (PR #77), but the
.claude/settings*.json and .claude/hooks/** rules have not been.
- Operator-only rosters (PR-D, 2026-09-30). security-approvers.txt (decision and waiver
approvers), authorized-managers.txt and management-authority.json in the team's
references/ name who may approve or manage. An agent that wrote one could name itself.
Coverage per arm: Claude Bash — already inside the .claude denyWrite (the Claude
denyWrite list is unchanged); built-in tools — the Edit(...) rules above; goose Seatbelt —
added to the control-plane deny (unverified, no macOS host; a deny on a missing path also
stops the agent creating it; the project-root roster gets no ancestor rule, so a goose agent
can still create a project references/ dir); Linux launcher — added to its control plane.
So that every entry exists, a sandboxed claude or goose team gets comment-only stubs of the
three files, written only when absent (O_CREAT|O_EXCL|O_NOFOLLOW: an existing roster or a
planted symlink is never touched, and the stubs never go through the overwrite path). A stub
reads exactly like an absent file in every reader (default approvers; no manager; no
management authority). A team with no sandbox, and no management settings, gets no new file.
- In-sandbox --update refuses before writing (3b). A sandboxed session sees the team dir
read-only, so an --update there used to fail file by file and leave a partial tree. It now
probes the team dir first (a create+unlink of a temp file, plus a non-truncating,
non-blocking, no-follow write-only open of each existing control-plane file, so a planted FIFO
cannot hang it) and exits 2 with one message, writing nothing. --dry-run reports that a real
run would be refused. A path that does not exist yet is "can't tell", not "writable".
- A missing integrity manifest is a finding where one is expected. integrity.verify used
to return nothing when references/enforcement-integrity.json was absent. It now reports a
manifest-missing finding when the repository tracks the manifest in git or the imported
scanner sits inside the project below its top level (an in-project .venv);
--verify-integrity exits 1 on it, and a confined (fail-closed) gate hook answers ask, not
deny. A consumer project whose scanner is installed elsewhere is unchanged.
- allowUnsandboxedCommands: false closes the dangerouslyDisableSandbox
escape hatch.
How to enable it¶
Via the privilege_profile field (recommended)¶
In the project description:
| Profile | Meaning |
|---|---|
cooperative (opt-out; default before 2026-W39) |
Agents are trusted to respect the workspace; no OS boundary emitted. |
confined (default since 2026-W39) |
Expands to the claude:sandbox host feature — emits the sandbox block confining writes to workspace_write_roots, as an inert settings/config example (never live) until merged. |
exclusive |
confined plus P3: OS read-exclusion (denyRead) of a default deny set + your protected_read_paths, and a P3b inbound-hardening advisory. See "Read-exclusion & cross-team exclusion (P3)" below. |
Note on
exclusive: its emitted enforcement is OUTBOUND — it seals your team's reads. It does not by itself stop other teams from reading your workspace; that inbound property is operator filesystem hardening (P3b).
workspace_write_roots (optional, default ["."]) overrides the confined roots.
. means the whole generated project tree is the workspace.
Write roots are validated, and widening needs the operator. The brief is agent-writable, so a sandboxed agent could add roots that the operator's next
--updatewould emit. These are refused in every emitter, through the pinnedframeworks/_write_roots.py: -/,~,$HOMEand their ancestors; - the signing-key directory, and anything at, inside or above it; - home credential and persistence paths; -~user; - the project control plane (.git,.claude,.goose,.codex,.github/agents,.github/hooks,sandbox,.agentteams) and any ancestor of the project; - shell, SBPL and glob metacharacters.A new root outside the project (absolute,
~/…,../…) needs--accept-write-root PATHon the operator's command line, unless the live.claude/settings.jsonallowWritealready holds it. Every added root is printed asSANDBOX WIDENINGwith its source, and--check-wiringreports a widening as a widening, never as "re-merge". Signed grant roots skip acceptance but not the bans. SeeSECURITY.md(2026-09-30 advisory) for the residuals.Typos fail closed. Only
cooperative,confined, andexclusiveare accepted. A misspelled profile (e.g."exclusve") is rejected at build with a non-zero exit — it is never silently downgraded to unconfined, because a value that looks like a confinement request while granting none is the worst outcome. A missing profile is not a typo: it defaults toconfined(since 2026-W39 — write-confinement emitted as an inert example; setcooperativeto opt out). The disruptive constitutional-gate fail-closed flip still requires an explicitconfined/exclusive, so the default never changes a wired team's live hook on--update.
Via the host-feature token directly¶
The effective feature set is the union of --target-host-features and the
privilege_profile expansion, so the two are consistent when both are given.
cooperative never strips an explicitly-passed claude:sandbox token.
Cross-workspace grants (P2)¶
P1 confines a team to its own workspace. P2 lets one team reach into another's workspace — but only by an explicit, signed, scoped, expiring, use-counted grant the other team issues. Absent a valid grant, there is no reach.
The model¶
A grant authorizes team A (the holder) to write a specific path in team B's workspace.
It is a signed row in the holder's references/capability-grants.log.csv — the
holder holds the grants issued to it (a bearer-capability model), so the holder's own
generation can read them. Rows are hash-chained: each carries a signed prev_digest
linking it to its predecessor, so deleting or reordering a signed row is detected at every
read (per-row signatures stop forgery; the chain stops silent deletion). A broken chain
fails closed — --verify-grants reports it, and generation-time widening applies no grants. issuer_team records who authorized it. When team A is next
generated or updated with the sandbox on, the granted path (if it permits write and the
row is operator Ed25519-signed, see below) is merged into A's sandbox allowWrite — so
A's kernel-enforced boundary now includes
exactly that foreign path (verified against Seatbelt: a granted foreign dir is writable,
an ungranted one is still denied).
# Operator-only: Ed25519-sign a write grant into the HOLDER's workspace. Use the same
# --framework/--output (or --project) as the holder's --update. Needs the operator key file
# (AGENTTEAMS_DECISION_ED25519_KEYFILE, kept in ~/.config/agentteams/keys/), the matching
# public key at <team dir>/references/authorized-verify-keys/<key_id>.pub.pem, and an
# approver on <team dir>/references/security-approvers.txt:
agentteams --sign-grant grant-spec.json --framework claude --project /path/to/A
# audit a workspace's grant ledger (read-only, never consumes):
agentteams --verify-grants --framework claude --project /path/to/A
A grant spec is JSON: issuer_team, holder_team, target_path, permitted_ops,
expires_at, max_uses, approver, ticket_id, reason_code, the optional
issuer_root, and (for --sign-grant) key_id.
Only an operator Ed25519 signature widens
allowWrite(PR-E). A grant whosepermitted_opsincludewriteis honoured only when it is Ed25519-signed (--sign-grant) and verifies against the holder team dir's write-protected storereferences/authorized-verify-keys/<key_id>.pub.pem. An HMAC-signedwritegrant is refused at issue (--issue-grant), in--verify-grants, and on the widening path, with a migration message. There is no transitional flag. The reason: a Claude-sandboxed agent inheritsAGENTTEAMS_GRANT_SIGNING_KEYfrom the environment that launched it, so it could HMAC-sign its own grant, which the operator's next--updatewould turn into real OS write reach. The Ed25519 private key sits in the read-denied~/.config/agentteams/keys/. The signed payload starts with the purpose tagagentteams-grant-v1, so an operator signature over a security decision can never be replayed as a grant, and the reverse is refused too. HMAC (--issue-grant) remains for grants that do not widen anything (for exampleread).Which team verifies. Grants are verified against the team dir that the
--updatewrites: in a multi-framework project, the team whose--outputthe update targets (today only a Claude sandbox consumes grants, so normally.claude/agents). The ledger itself is read from that run's workspace root.Migrating an existing HMAC write grant. Run
agentteams --verify-grants(each affected grant is reported as refused), provision an operator key pair if you have none, install the public key in the holder team dir'sreferences/authorized-verify-keys/, then re-issue each grant with--sign-grant(same spec pluskey_id) and re-run the holder's--update. Each team's identity is itsteam_id(theprivilege_profilesibling field; defaults to the slugified project name — keep it unique across your workspaces).Anchor an absolute grant to the issuer's tree (
issuer_root, optional but recommended). When the spec declaresissuer_root(an absolute path), it is signed into the grant and an absolutetarget_paththat resolves outside it is refused — at issue and on the generation-time widening path (P2-4 containment). Because the anchor is signed, a tampered or widenedissuer_rootinvalidates the grant. Omit it and the grant still works, bounded by thetarget_pathshape checks below (backward compatible).An approver roster is mandatory, and it is the team dir's. A grant is only honoured when its
approveris on the holder team dir'sreferences/security-approvers.txt(the same roster decisions and waivers use, protected by the sandbox's.claudewrite deny). A project-rootreferences/security-approvers.txtis ignored for grants, because an in-sandbox agent can create it; when only a project-root roster exists, agentteams warns and names the file to move it to. There is no fallback. If the team-dir roster is absent or empty, issuing, verifying, and generation-time widening all fail closed — the grant is refused rather than cleared by a built-insecurity/@securitydefault. (Without this, a grant naming@securityas its own approver could self-clear.) Create the roster with at least one named approver before issuing or honouring any grant.
target_pathis also validated at issue: an empty path,/, a home-rooted~/…path, or a..-escaping relative path is refused before it can enter the ledger and widen a boundary. A malformedexpires_atis likewise rejected at issue, not left to fail later.Honest ceiling. A widening grant is only as strong as the operator's Ed25519 private key and the write protection of the team dir's verify-key store and roster. Whoever holds the private key can mint any grant. The HMAC scheme that non-widening grants still use defends only against a keyless actor: whoever holds
AGENTTEAMS_GRANT_SIGNING_KEYcan forge an HMAC grant, which is why HMAC can no longer widen anything (history:references/plans/decision-c1-grant-signing.report.md).
Enforcement is generation-time, by design¶
A freshly-issued grant is inert until the holder re-runs an update. Widening happens when A is (re)generated — there is deliberately no runtime path by which a running agent widens its own OS boundary. Issuing a grant is thus an explicit, auditable step, not a silent runtime hole.
Two consequences of the generation-time model to plan around:
- Revocation has latency. There is no revocation list and no runtime revocation
between generations. Removing a grant row (or dropping its approver from the roster)
stops it applying only at the holder's next generation; until then an
already-widened
allowWritepersists. Prefer a shortexpires_atto bound the window rather than relying on after-the-fact removal. max_usesis validated, not consumed. It is checked at each generation (an exhausted grant is rejected), but generation-time widening does not decrement the counter — expiry is the only active bound. An operator who setsmax_uses=1expecting a single use gets a grant that behaves as reusable-until-expiry; the CLI prints a NOTE to that effect at issue. Again: useexpires_atfor a tight window.
Trust model (read this before relying on it)¶
A grant that widens allowWrite must be signed with the operator's Ed25519 key
(--sign-grant; the signing extra provides the cryptography backend, and without it
the widening path fails closed). Agents hold only the public verify keys, in a store the
sandbox write-denies, so an agent cannot mint a widening grant even though it inherits
AGENTTEAMS_GRANT_SIGNING_KEY. Non-widening grants may still be HMAC-SHA256-signed with
that shared key, which defends only against a keyless actor.
Preconditions and non-goals:
- The Ed25519 private key lives in ~/.config/agentteams/keys/ (read-denied by every emitted
sandbox) and never enters an agent session. Never export AGENTTEAMS_GRANT_SIGNING_KEY
or AGENTTEAMS_DECISION_ED25519_KEYFILE into the shell that launches claude: a
Claude-sandboxed command inherits the environment (only sandbox/confine-run.sh scrubs it).
- A grant never overrides a @security HALT — it widens a write boundary, it does
not lift a stop (C-2 parity).
- Filesystem-local only; no cross-machine grant delivery.
- Holder identity is a name (team_id), safe only because grants are consumed at
operator-run generation time, not at agent runtime.
Read-exclusion & cross-team exclusion (P3)¶
The user goal behind "P3" is an inbound property: only my team accesses my workspace. Be clear about what agentteams can and cannot do here, because the mechanism it emits is the outbound dual.
P3a — read-exclusion (emitted, OS-enforced)¶
Selecting privilege_profile: exclusive adds sandbox.filesystem.denyRead to the
emitted sandbox block: the team (and its Bash/child processes) is OS-denied from
reading a curated default deny set plus any protected_read_paths you list. The
default set is the credential stores an agent rarely needs to act as during its build
work — ~/.ssh, ~/.aws, ~/.gnupg, ~/.kube, ~/.config/gcloud, and ~/.azure
(cloud-provider creds).
It deliberately excludes files that routine agent tooling authenticates with, because
denying them breaks the toolchain: registry-auth files (~/.npmrc, ~/.pypirc,
~/.netrc, ~/.docker/config.json — authenticated npm/pip/git/docker) and
~/.config/gh (the GitHub CLI token, read by gh on every call — and the generated PR
agents shell out to gh). Add any of these via protected_read_paths if your team does
not use that toolchain.
Files, not environment variables.
denyReaddenies filesystem reads. A secret already exported into the agent's environment (e.g. a signing key placed in an env var) is not covered — this control keeps a credential file off-limits, it does not scrub the process environment.
allowRead re-opens the write roots (which include any P2-granted paths) so a granted
write target stays readable, and — since "more specific wins" — the workspace stays
readable even if a protected_read_paths entry denies one of its ancestors.
The denylist shape is what makes this practical: a read-allowlist starves the toolchain
(a direct Seatbelt spike had python abort under one), whereas a read-denylist keeps the
toolchain and denies the listed paths — that Seatbelt deny mechanism was verified directly
(with absolute paths). The ~/-relative deny entries rely on Claude Code's documented
sandbox path syntax (~/ = home); operators who prefer no ambiguity may list absolute
paths instead.
This seals your team's reads (it cannot snoop on or exfiltrate the listed paths /
sibling workspaces). When every team runs exclusive and lists the others in
protected_read_paths, teams cannot read each other → mutual isolation.
Verifying enforcement on your machine¶
Because the emitted denyRead uses home-relative (~/) entries and agentteams does not
run Claude Code, it cannot itself prove the paths resolve and deny on your host. Confirm
it yourself after merging the block and enabling the sandbox: create a throwaway marker
file under one of the denied roots, then attempt to read it from inside a sandboxed Bash
command. A denial (operation not permitted) confirms the boundary is live on your machine;
a successful read means the entry did not resolve — switch that entry to an absolute path.
Do this once per host/OS, since the home path resolves per user at sandbox-apply time.
(The ~/ form is Claude Code's documented sandbox path syntax; the underlying kernel deny
mechanism was verified directly — macOS Seatbelt with absolute paths — but the config-layer
~ resolution is Claude Code's, so a one-time self-check is the honest confirmation.)
Opt-in: emit absolute paths instead (resolve_deny_read_abspath)¶
If you cannot run — or do not want to rely on — that per-host ~-expansion self-check, set
resolve_deny_read_abspath: true in the project description. Every ~/ denyRead entry is
then resolved to an expanduser'd absolute path at generation time, so enforcement no
longer depends on Claude Code expanding ~ before the OS deny. This closes the silent-no-op
risk (P3-3) directly: an unresolved entry can no longer look protective while denying nothing.
The trade-off is portability. An absolute path names the generating machine's home, so a
team generated on one host will not carry its deny paths to another — regenerate on the
host that runs the team. This fits the same single-operator/both-repos trust model P2
already assumes. The default (false) keeps the portable ~/ form and a byte-identical
emitted block, so nothing changes for existing teams. Note this removes the ~ dependency;
it does not by itself prove the OS denies the path — that still owes the in-sandbox
escape test above (or the RH-4 harness).
Opt-in: protect other harnesses' prompt roots (protect_prompt_roots)¶
Some files are read as instructions by another harness: Copilot, Codex, goose, or Claude Code's
own CLAUDE.md. By default a Claude-sandboxed agent can rewrite them. Set
protect_prompt_roots: true in the project description to close that (follow-up #8 phase 2).
It is off by default because it also stops sandboxed agents from authoring those files, and that
includes an in-sandbox agentteams --update. Regenerate from outside the sandbox.
The roots:
- .github/copilot-instructions.md, .github/instructions/, .github/prompts/ and .github/agents/;
- AGENTS.md, AGENTS.override.md (Codex reads it ahead of AGENTS.md) and .goosehints;
- .codex/ and .goose/recipes/;
- CLAUDE.md, CLAUDE.local.md and .mcp.json;
- .agentteams/, only when present.
.github/workflows is never protected.
What each arm does:
- Claude block. permissions.deny gains an Edit(...) rule for every root. These rules bind
the built-in tools. sandbox.filesystem.denyWrite names only the roots that existed at
generation, because a missing deny path stops bwrap. A root created later needs a regeneration.
- Linux launcher. sandbox/confine-run.sh --protect-prompt-roots ro-binds every root present
under each writable root. An absent root is skipped. A symlinked root is refused.
--check lists the protected roots as prompt-roots (ro). The Linux goose runner example passes
the flag when the brief opts in. On macOS the launcher only warns, and the goose Seatbelt profile
is unchanged (deferred).
- Relaxation. When the live .claude/settings.json still carries the rules and the new render
drops them, generation prints SANDBOX RELAXATION: prompt-root protection removed. That happens
when the flag is turned off or removed. The notice warns; it does not block.
Generation recommends the flag when a Claude sandbox is on and the project also holds a non-Claude team.
P3b — inbound exclusion (operator-run, advisory only)¶
What P3a does not do: stop another team from reading your workspace. Nothing
agentteams emits into your config can constrain a different process. Selecting
exclusive therefore also prints and records a
privilege-profile-exclusive-inbound-hardening advisory pointing you at the OS steps
that do deliver inbound exclusion, which you run yourself:
- Restrict the workspace to your user:
chmod 700 <workspace>+chownit to you, so other-uid processes are kernel-denied. - (Stronger) dedicated macOS user: run the owning team's harness under a separate
user account (
dscl/System Settings), and own the workspace as that user — the kernel then excludes every other user, while your admin/human account retains access.
agentteams cannot verify you ran these, so P3b is emitted as an honest advisory, not a claimed guarantee — the same discipline as the unenforced-host warning.
Strict decision-signing enforcement (enforce_decision_signing)¶
An agent-position control, distinct from the workspace axes above. The security gate
clears a destructive/privileged action only against an authorizing decision row (a
PASS/CONDITIONAL PASS, or a HALT-RETRACTED). When decision signing is inactive, that
row is trusted on its author name alone — and the author is gated by the
security-approvers.txt roster, an operator-authored file that lives inside the workspace.
So in the signing-inactive configuration, an agent that can edit the roster could add itself
and author its own clearance.
enforce_decision_signing (project-description field, default true) closes that path:
when on, an unsigned authorizing row is refused (fail-closed) rather than honoured on its
author name. It applies to both authorization paths (PASS and HALT-RETRACTED).
- Default-on at update, notify-after, opt-out. The switch is emitted into the team as
references/agent-privilege.jsonand defaults on whenever the team is generated or updated; a notice prints at generation naming the opt-out. Turn it off with"enforce_decision_signing": falsein the brief, then re-run--update. - Absent means off. A workspace that has never been updated to this feature (no
agent-privilege.json) keeps the legacy behavior, so updating is what turns the switch on — no team breaks by surprise. A present-but-unreadable switch fails closed (it will not silently disable itself). - Migration edge: turning it on also stops any existing unsigned authorizations in
that workspace's decisions log from clearing until signing is activated (add a
signaturecolumn, or setAGENTTEAMS_DECISION_SIGNING_KEY).
Known residual (honest ceiling).
references/agent-privilege.jsonsits inside the agent write root, so an agent with Write could disable the control by setting itfalseor deleting the file (absent → off). This is the same "the referee plays in the same sandbox as the players" limitation documented forintegrity.pyand accepted for the capability-grant roster. The durable fix is harness-level enforcement plus integrity coverage of the switch value; it is tracked in the remediation log. Today the switch raises the bar (author-name-only → signature-required) and makes tampering a recorded, git-visible act, but it is not a boundary an in-sandbox agent cannot cross.
Limits (be honest about these)¶
- Inert until merged. agentteams never writes
settings.jsondirectly (clobbering operator config is a worse failure than an unwired boundary). The emitted block is wire-ready but does nothing until the operator merges it. The emitted example's comment states this. - Linux: framework-neutral, any framework;
claudeeverywhere;goosealso on macOS; Windows fails closed. OS enforcement is available: - On Linux, framework-neutrally for ANY framework (as of the 2026-W36 Linux-agnostic
sandbox work): a confined/exclusive team of any framework emits a provider-agnostic
bwraplauncher to repo-rootsandbox/confine-run.sh(read-only root + rootless netns +NoNewPrivs+ credential read-exclusion). It wraps any process, so Linux enforceability does not depend on the framework — this is deliberately not goose-gated and not.goose/-pathed. Emitted verbatim fromagentteams/frameworks/_linux_sandbox_emit.py; see the end-to-end setup below. The Linux launcher's enforcement is VERIFIED by a live-kernel bwrap escape/deny test (write outside--scratch, credential/sibling read, and raw egress all denied for a real process). - On the
claudeframework everywhere (Claude Code's own Seatbelt/bubblewrap sandbox). - On the
gooseframework on macOS (Apple Seatbelt viaGOOSE_SANDBOX/sandbox-exec; see the goose-privileges reference) — a confined/exclusive goose team on macOS emits asandbox.sbprofile + an inertconfig.yaml.agentteams.example.
The choice is platform-aware (host_features.is_sandbox_capable): is_sandbox_capable(<any>, "linux")
is now True. Selecting confined/exclusive on native Windows (or any other target
with no emittable boundary) still exits non-zero by default rather than ship a config
that silently does nothing. Pass --allow-unenforced-confinement to proceed anyway; the
request then degrades to a visible advisory and a privilege-profile-unenforced-host
manifest advisory — advisory only there, and never silently "on". (Whether Claude Code
enforces on native Windows is itself unverified; treat Windows as advisory. A block
generated there omits failIfUnavailable, which would otherwise stop Claude Code
starting.) For targets
with no emittable boundary (native Windows, or any non-Linux non-macOS host), confine
from outside the process: a container plus seccomp-bpf + Landlock and egress
filtering. macOS Seatbelt paths remain enforcement-UNVERIFIED off a mac host, and the
boundary as a whole does not close T6 / host-as-TCB; seccomp/Landlock is a further,
not-yet-added layer. This fail-closed default is on the generate path; the
--convert-from/--fleet/render paths still emit the sandbox block (via
_sandbox_feature_enabled/_goose_sandbox_feature_enabled reading privilege_profile
directly) but keep the advisory-not-raise default, so an unenforceable target there
degrades to a warning rather than a non-zero exit.
- P3 is read-exclusion (outbound), not enforced inbound exclusion. The emitted
exclusive config seals your team's reads; the "only my team touches my tree"
inbound property is operator filesystem hardening (P3b advisory), which agentteams
documents but cannot enforce. See the P3 section below.
- allowWrite mixes relative and absolute entries. P1's default root is ["."]
(project-relative); a P2 grant adds an absolute foreign path. The emitted allowWrite
therefore carries both forms — expected, and honored by the sandbox (verified against
Seatbelt).
End-to-end: the Linux OS-confinement launcher (framework-neutral)¶
Linux confinement "works like Seatbelt": agentteams emits a provider-agnostic launcher you run around your agent process. The whole flow, start to finish:
1. Generate a confined (or exclusive) team — for any framework. Set the profile in the brief (it is a project-description field, not a CLI flag):
# any framework: claude, goose, codex, copilot-vscode, copilot-cli, agents-md
agentteams --description brief.json --framework goose --project /path/to/proj
Confinement is requested when the brief/manifest sets privilege_profile: confined (the
default since 2026-W39) or exclusive, or carries any *:sandbox host-feature token. On
Linux this emits the launcher; only the explicit opt-out cooperative emits nothing.
2. Find the emitted launcher. It lands at the generated project's repo root, never under a framework dir:
3. Run your agent confined — the launcher wraps any command after --:
# read-only root, ./work the only writable path, network denied:
sandbox/confine-run.sh --scratch ./work --egress deny -- goose run --recipe .goose/recipes/orchestrator.yaml
# claude/codex/copilot/etc. work identically — it confines a process, not a harness:
sandbox/confine-run.sh --scratch ./work -- claude
Key flags: --scratch DIR (required; the only writable path), --egress deny|proxy|host,
--exclude PATH (extra read-denies on top of the credential defaults ~/.ssh ~/.aws ~/.gnupg
~/.kube ~/.config/gcloud ~/.azure), --writable PATH, --setenv VAR=VAL. It requires
bwrap (sudo apt-get install -y bubblewrap, or scripts/install-sandbox-deps.sh, which also
installs the socat that Claude Code's own sandbox needs) and refuses to launch without it.
Control plane (F-4, Linux branch). Inside every writable root (--scratch, --writable,
--coord-root) the launcher --ro-binds each agentteams control-plane path that exists there
(the enforce_decision_signing switch, the gate hook, the verify-key store and — PR-D — the
security-approvers.txt / authorized-managers.txt / management-authority.json rosters for
claude and goose, .goose/sandbox.sb, the project-root references/security-approvers.txt, and
each team's references/build-log.json), and gives each ancestor directory below the root a
read-write self-bind so it becomes a mount point: mv .claude .claude.old fails EBUSY, while
writes inside .claude/ still work. The order is rw roots, ancestor self-binds, read-only binds,
then the credential masks. Each path is realpath'd and a symlink anywhere on it is refused;
nothing is ever created. A framework's entries are required only when that framework holds
an agentteams team, marked by <agents dir>/references/build-log.json (a hand-written
.claude/agents without one is skipped); the marker itself is read-only bound so it cannot be
deleted to disable the check, and the rosters are required only when the team's switch is
present. A required entry that is missing is an exit-2 refusal ("run agentteams --update");
a team generated before PR-D lacks the roster stubs and is refused until its next --update.
The project-root roster is protect-if-present only. --protect PATH (repeatable) read-only binds an extra
path — --protect "$PWD/.claude" makes the whole config dir read-only, the Claude-block
equivalent — and a missing --protect path is an exit-2 refusal, never a mkdir. --check
prints the result on a control-plane (ro) line. Mechanism-verified (the launcher run end to
end under bwrap), product-unverified. macOS (2026-10-07): the same set (required-entry refusals,
--protect, --protect-prompt-roots) is written into the Seatbelt profile after the allows, as
(deny file-write* (subpath P)) per protected path and (deny file-write* (literal A)) per ancestor below a
writable root (a rename lock). This is verified live on macOS by mac-escape-tests.sh Gate 8 and
tests/test_launcher_macos_control_plane.py. A team missing a required entry is now refused on macOS too
(run agentteams --update).
4. Dry-run / inspect before trusting it — --check prints the effective sandbox and runs
nothing:
5. Verify the wiring programmatically (goose analog of the claude check):
agentteams.frameworks._goose_sandbox_emit.verify_goose_sandbox_wiring(project_root, manifest)
returns ENFORCEABLE on Linux once sandbox/confine-run.sh is present, and a
regenerate-warning if a confined team is missing it.
Provenance & drift. The launcher is emitted verbatim from
agentteams/frameworks/_linux_sandbox_emit.py (a shipped template asset), so its bytes are
identical across every consumer. A consuming project that keeps its own copy should pin the
emitted artifact's sha256 and re-pin only through a coordinated change to the template asset.
Honest status. Linux launcher enforcement is VERIFIED by a live-kernel deny test; macOS Seatbelt paths are UNVERIFIED off a mac; the boundary does not close T6 / host-as-TCB, and seccomp/Landlock is a further layer not yet added.
macOS augmentation (2026-W36)¶
The same sandbox/confine-run.sh carries a real macOS (build_macos) branch — emitted for
every framework on a macOS host, exactly as the Linux branch is on Linux. It relies on the
OS-independent core and adds macOS-specific augmentation only where the Linux sandbox expresses
something macOS cannot reach through that core; where even the augmentation cannot reach parity, it
fails honest (labels the residual, defers to a Linux host / an out-of-band layer) rather than
emit a confined claim it does not enforce. On macOS the emitter also ships sandbox/mac-escape-tests.sh
(the on-host deny test) and two inert Tier-B examples (dedicated-uid-provisioning.example.sh,
pf-per-tenant-anchor.example.conf) — reference only, never auto-run, never elevate.
Three distinct mechanisms — do not conflate them:
| Flag | Tier | Mechanism | Honest status |
|---|---|---|---|
--cpu-max SEC |
A (in-core, kernel-enforced) | RLIMIT_CPU via ulimit -t — a per-process cpu-second cap (SIGXCPU→SIGKILL) |
Enforced; DoS-bounding, not an aggregate/tree quota |
--nproc-max N |
B (interface only) | RLIMIT_NPROC via ulimit -u (per-uid) |
Isolates a tenant only under an operator-provisioned dedicated uid; on a shared uid it is a self-DoS knob. The launcher never drops uid (no sudo/launchctl) — provisioning is out-of-band |
--mem-max MiB |
C (fail-honest) | none | Memory is UNCAPPED on bare macOS; the launcher warns loudly and proceeds. A hard cap needs a VM / container / Linux host |
On Linux these three flags are no-ops (Linux reaches CPU/PID/memory caps via cgroups on its OOB path). No syscall filtering is emitted on either OS (operator decision 2026-W36: seccomp-grade policy is a Linux-host / Layer-B concern).
DNS-through-proxy contract (--egress proxy on macOS). SBPL's network address filter accepts
only host * or localhost — (remote ip "<real-IP>:port") fails at parse. So macOS pins
sole-proxy egress only to a loopback proxy: (deny network*) blocks UDP/53 (the guest cannot
self-resolve) and only the loopback proxy port is opened; the proxy must run on loopback and perform
all name resolution. A non-loopback --proxy FAILS CLOSED (exit 2) rather than silently widen to
*:port. Remote-address egress control lives out-of-band in PF (see the inert
pf-per-tenant-anchor.example.conf).
Setuid-exec denylist. The profile denies process-exec* on the known macOS system
setuid/setgid-root binaries. This is compensating hardening, NOT no-new-privs, and the list is
non-exhaustive — reconcile it against your box with find / -perm -4000 -o -perm -2000.
Literal-path matching may be bypassable by path aliasing; the deny test probes this.
Manual-wire + verification. Like Linux, the macOS launcher is not auto-applied — you must
wrap your agent invocation with it, and nothing is confined until you do. privilege_profile_advisory
surfaces this (a non-fatal privilege-profile-macos-launcher-manual-wire advisory for any framework
other than claude/goose, which have their own native macOS boundaries). No macOS boundary is
claimed to confine until sandbox/mac-escape-tests.sh passes on that box's actual build, UNNESTED and
with its positive controls — wiring-verified is not enforcement-verified.
Honest residuals (macOS), in full: memory is not hard-capped on bare macOS (Tier C); no syscall
policy; the setuid path is a denylist (compensating hardening, not no-new-privs); sole-proxy egress is
loopback-only (remote-address control is OOB in PF); per-tenant net isolation via a dedicated uid is
coarser than a Linux netns (host-global, root, PF-churn-prone); the whole tier rests on Apple's
deprecated sandbox-exec (the durable primary remains a Linux host / Layer B); and wiring-verified ≠
enforcement-verified until the on-host deny test passes unnested.
The honest Tier A/B/C descriptor is available programmatically as
agentteams.host_features.MAC_RESOURCE_CAPS.
Design foundations¶
The mechanisms here rest on a few established security principles — cited as design rationale, not as a claim that this implementation formally realizes any of them:
- Least privilege — a team writes only where it must (
workspace_write_roots, default["."]);exclusiveadditionally denies reading secrets it has no business touching. - Secure defaults, inert-until-merged — since 2026-W39 the default is
confined: a team gets a write-confinement boundary emitted by default, but as an inert settings/config example the operator merges — the system never silently claims protection it is not enforcing, and the default never rewrites a wired team's live hook (the fail-closed gate flip stays explicit-opt-in). Setcooperativeto emit no boundary at all. Where a boundary is requested but cannot be enforced (non-sandbox host, unknown profile), generation fails closed rather than ship an inert config that looks protective. - Complete mediation — the P2-4 target-path guard runs on the real generation-time
widening path, not only at issue, so an unsafe grant cannot reach
allowWriteby a path that skipped the check. - Confused-deputy avoidance — a grant widens a boundary at operator-run generation
time, never at agent runtime, so a running agent cannot be tricked into widening its own
reach; and it never overrides a
@securityHALT (C-2 parity). - Keyed integrity (HMAC) — grants are signed so a keyless (prompt-injected) agent cannot fabricate one. The honest ceiling of a stdlib-only HMAC scheme — no defense against a key-holding peer team — is stated under "Trust model" above; this is not a claim of cross-team unforgeability.
See also¶
host_features— theclaude:sandboxtoken and the gating mechanism.hooks-emit— the sibling constitutional-gate hook and the "ship an example, never clobber settings.json" convention this feature follows.