Skip to content

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 denyWrite path breaks bwrap for every command ("Can't mkdir …: Not a directory"). That is fail-closed per command, not a refusal to start, so failIfUnavailable does not change it. Generation emits every denyWrite path.
  • A missing denyRead path is tolerated, so the exclusive defaults (~/.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's bwrap-userns-restrict: every process bwrap launches runs under unpriv_bwrap, which denies capabilities, and Claude Code's seccomp helper runs there from an in-memory file. A profile on claude therefore does not help, and neither does enableWeakerNestedSandbox. Claude Code's documented fix is an unconfined profile bwrap /usr/bin/bwrap. On Ubuntu it collides by name with the one in bwrap-userns-restrict, and it lifts AppArmor confinement from everything bwrap launches on the host. scripts/test-sandbox-apparmor-userns.sh tries each candidate (the documented profile and the sysctl; a local/unpriv_bwrap allow 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 emitted sandbox block enforces it — a write to $HOME (outside allowWrite and outside the session temp dir) is DENIED, a denyWrite of references/agent-privilege.json blocks an agent from flipping the enforce_decision_signing switch, and a denyRead secret cannot be read, while legitimate in-root writes/reads still succeed. Encoded as the opt-in regression guard tests/test_os_sandbox_product_enforcement.py (run with RUN_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 /tmp target 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 claude framework'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-neutral sandbox/confine-run.sh bwrap 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 inside allowWrite, 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). The enforce_decision_signing switch (references/agent-privilege.json) lives inside the write root and is not under .claude/, so the auto-protection above does not cover it — a denyWrite entry does. denyWrite takes precedence over allowWrite (deny-over-allow; the Seatbelt write-deny mechanism is verified in tests/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 denyWrite binding 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) each denyWrite path 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-generated confined/exclusive team 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_emitted guards that generation keeps emitting every path the denyWrite list 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 frozen README.md sentinel into it, in the same step that emits the deny. agentteams never writes, rewrites or deletes a *.pub.pem. denyWrite binds Bash only (below), so the Write/Edit-tool route is closed by the permissions.deny Edit(...) rules. Whether Claude Code honours a directory denyWrite entry 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 each denyWrite entry becomes a --ro-bind placed after the read-write root bind, so denyWrite now also names the whole .claude directory: it becomes a read-only mount point, its rename fails EBUSY, and a planted .claude/settings.local.json is refused. A deny only removes capability, whatever the write roots, and permissions.deny is unchanged (no Edit(/.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/agents and .goose are denied when present). The goose Seatbelt profile denies file-write-unlink and file-write-create on 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 fails EBUSY; see the launcher section. Status: mechanism-verified (raw bubblewrap probes in tests/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 .claude and every denyWrite entry read-only bound in the real bwrap argv, and test_f4_config_dir_cannot_be_renamed_… saw a sandboxed mv .claude fail), 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 the build-log.json team 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

In the project description:

{
  "project_goal": "…",
  "privilege_profile": "confined",
  "workspace_write_roots": ["."]
}
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 --update would emit. These are refused in every emitter, through the pinned frameworks/_write_roots.py: - /, ~, $HOME and 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 PATH on the operator's command line, unless the live .claude/settings.json allowWrite already holds it. Every added root is printed as SANDBOX WIDENING with its source, and --check-wiring reports a widening as a widening, never as "re-merge". Signed grant roots skip acceptance but not the bans. See SECURITY.md (2026-09-30 advisory) for the residuals.

Typos fail closed. Only cooperative, confined, and exclusive are 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 to confined (since 2026-W39 — write-confinement emitted as an inert example; set cooperative to opt out). The disruptive constitutional-gate fail-closed flip still requires an explicit confined/exclusive, so the default never changes a wired team's live hook on --update.

Via the host-feature token directly

agentteams generate brief.json --framework claude \
  --target-host-features claude:sandbox

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 whose permitted_ops include write is honoured only when it is Ed25519-signed (--sign-grant) and verifies against the holder team dir's write-protected store references/authorized-verify-keys/<key_id>.pub.pem. An HMAC-signed write grant 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 inherits AGENTTEAMS_GRANT_SIGNING_KEY from the environment that launched it, so it could HMAC-sign its own grant, which the operator's next --update would 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 tag agentteams-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 example read).

Which team verifies. Grants are verified against the team dir that the --update writes: in a multi-framework project, the team whose --output the 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's references/authorized-verify-keys/, then re-issue each grant with --sign-grant (same spec plus key_id) and re-run the holder's --update. Each team's identity is its team_id (the privilege_profile sibling 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 declares issuer_root (an absolute path), it is signed into the grant and an absolute target_path that 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 widened issuer_root invalidates the grant. Omit it and the grant still works, bounded by the target_path shape checks below (backward compatible).

An approver roster is mandatory, and it is the team dir's. A grant is only honoured when its approver is on the holder team dir's references/security-approvers.txt (the same roster decisions and waivers use, protected by the sandbox's .claude write deny). A project-root references/security-approvers.txt is 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-in security/@security default. (Without this, a grant naming @security as its own approver could self-clear.) Create the roster with at least one named approver before issuing or honouring any grant.

target_path is 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 malformed expires_at is 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_KEY can 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 allowWrite persists. Prefer a short expires_at to bound the window rather than relying on after-the-fact removal.
  • max_uses is 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 sets max_uses=1 expecting a single use gets a grant that behaves as reusable-until-expiry; the CLI prints a NOTE to that effect at issue. Again: use expires_at for 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. denyRead denies 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> + chown it 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.json and 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": false in 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 signature column, or set AGENTTEAMS_DECISION_SIGNING_KEY).

Known residual (honest ceiling). references/agent-privilege.json sits inside the agent write root, so an agent with Write could disable the control by setting it false or deleting the file (absent → off). This is the same "the referee plays in the same sandbox as the players" limitation documented for integrity.py and 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.json directly (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; claude everywhere; goose also 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 bwrap launcher to repo-root sandbox/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 from agentteams/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 claude framework everywhere (Claude Code's own Seatbelt/bubblewrap sandbox).
  • On the goose framework on macOS (Apple Seatbelt via GOOSE_SANDBOX/sandbox-exec; see the goose-privileges reference) — a confined/exclusive goose team on macOS emits a sandbox.sb profile + an inert config.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):

// brief.json
{ "project_name": "MyProj", "privilege_profile": "confined" }
# 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:

<project>/sandbox/confine-run.sh     # executable, provider-agnostic

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:

sandbox/confine-run.sh --scratch ./work --egress deny --check -- goose run …

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 ["."]); exclusive additionally 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). Set cooperative to 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 allowWrite by 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 @security HALT (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 — the claude:sandbox token and the gating mechanism.
  • hooks-emit — the sibling constitutional-gate hook and the "ship an example, never clobber settings.json" convention this feature follows.