Skip to content

Part VI — OS confinement

OS confinement is the layer that bounds an agent's runtime reach at the operating-system level, rather than at the level of judgment (the sentinel), decisions (the gates), or content (the scanner). It has three parts: a reference model for the defense-in-depth of the deployed system a project builds (S17); the emission of sandbox configuration and the privilege profiles that select it (S18); and the runtime PreToolUse hook that catches agent-initiated actions the CLI gates never see (S19).

This Part carries the most consequential honest ceilings in the guide. Two govern everything below and are worth stating up front:

  • Confinement is emitted by default but inert until wired. As of 2026-W39 the default privilege profile is confined, under which agentteams emits an OS write-confinement boundary — but only as a settings/config example the operator must merge, so it enforces nothing until wired (S1 fact 5). The PreToolUse hook still stays fail-open by default (its fail-open→fail-closed flip requires an explicit confined/exclusive, not the defaulted confined). Reading "OS confinement layer" as "runtime confinement is enforced out of the box" is the overclaim these sections exist to prevent.
  • agentteams emits configuration; it does not enforce it. Enforcement belongs to the harness (Claude Code's Seatbelt/bubblewrap). The empirically deny-tested OS-confinement path is Linux (the framework-neutral sandbox/confine-run.sh bwrap launcher — see the Sandboxing Guide); macOS Seatbelt is UNVERIFIED.

The infrastructure-layers model ✅ (reference doc) {#S17}

The infrastructure-layers model is a curated eight-layer (L0–L7) defense-in-depth model for the deployed system a project builds — not for the agent build process. It is cross-cut by a design → build → baseline → tune → operate → respond → review lifecycle, so each layer is considered at each lifecycle stage (agentteams/templates/universal/security-infrastructure-layers.reference.template.md:31-70).

Layer Concern
L0 Governance
L1 Identity
L2 Crypto / secrets
L3 Host & workload hardening — where OS confinement lives
L4 Network
L5 Application & supply chain
L6 Detection / logging
L7 Resilience / backup / IR

The boundary it draws

The reference states an explicit boundary: "infrastructure security ≠ agent security." This L0–L7 model governs the deployed system; the @security sentinel governs the build process. The two are adjacent and must not be collapsed — a point that recurs throughout the guide as the "two surfaces" distinction (S2). Each layer degrades independently: a failure at L4 does not imply a failure at L3.

Honest ceiling — guidance, not deployment

It is guidance, not deployment. A reference informs; it secures nothing by itself (agentteams/templates/universal/security-infrastructure-layers.reference.template.md:119-130). Posture throughout is stated as "engages as tested," never "verified" or "secure," and the concrete tools the model names are not deployed by agentteams — the operator deploys them, or does not. Treating the presence of this reference in a generated tree as evidence that the deployed system is hardened is a category error the ceiling exists to close.

Source. agentteams/templates/universal/security-infrastructure-layers.reference.template.md:31-70,119-130.


Sandbox emission and privilege profiles ✅ (Linux bwrap deny-tested) / ⚙ (macOS Seatbelt UNVERIFIED, Windows design-only) {#S18}

This is the section where the "emits vs enforces" distinction and the verification ceiling (Linux deny-tested; macOS Seatbelt unverified) are load-bearing. Read the honest-ceilings subsection as the primary content, not an appendix to it.

Three privilege profiles

A privilege profile selects how much OS boundary a generated team requests (agentteams/host_features.py:134-261). An unknown profile fails closed — it raises rather than silently downgrading to something weaker.

Profile OS boundary Emits
cooperative None — the opt-out No sandbox request
confined Requested — today's default A sandbox request token
exclusive Requested + read-exclusion A sandbox request token and denyRead

agentteams emits configuration, it does not enforce (fact 2 + fact 4)

agentteams emits configuration; the harness enforces it. (agentteams/frameworks/_sandbox_emit.py:25-208, agentteams/frameworks/_goose_sandbox_emit.py:1-222.)

  • For Claude, agentteams injects an inert sandbox example into settings.hooks.example.json. The operator must merge it into their own live settings; agentteams never writes a live .claude/settings.json. Actual enforcement is Claude Code's own OS sandbox — macOS Seatbelt or Linux bubblewrap.
  • For Goose, on macOS only, agentteams emits an Apple Seatbelt profile (the P1-1 work). Goose has no native Linux or Windows OS sandbox.

The consequence, stated as fact 4's binding ceilings:

  • Emitted blocks are inert until the operator wires them. An un-merged sandbox example enforces nothing. Enforcement is the harness's job, not agentteams'.
  • The empirically-verified path is Linux. A live-kernel bwrap deny test — the framework-neutral sandbox/confine-run.sh launcher (see the Sandboxing Guide) — denies write-outside-scratch, credential read, and raw egress. macOS Seatbelt is UNVERIFIED: no on-mac deny test has been run, so it engages as designed, not as tested.
  • Claude Code's native Linux bubblewrap arm has the open D-3 fragility. Distinct from the verified launcher above: on Claude Code's own Linux bubblewrap backend, a denyWrite of a non-existent path fails bwrap initialization, which blocks all Bash — a fail-shut fragility, not a security hole, but an availability defect that remains open in references/agentteams-remediation-log.csv (item D-3). That native arm's denyRead behaviour (incl. ~/) and its product arm are verified on Linux only under the AppArmor precondition in the next item.
  • Claude Code's native sandbox fails OPEN without failIfUnavailable (fixed 2026-09-30). Measured on Linux with socat missing: the old block (enabled: true, allowUnsandboxedCommands: false) let Claude Code print "Sandbox disabled … Commands will run WITHOUT sandboxing" and run unconfined. The block now emits failIfUnavailable: true on macOS/Linux targets (omitted on native Windows, where the block is advisory), so Claude Code refuses to start instead. Linux needs bwrap and socat (scripts/install-sandbox-deps.sh); Ubuntu's kernel.apparmor_restrict_unprivileged_userns=1 makes every sandboxed command fail closed at the seccomp step. That native arm is now verified end-to-end on Linux (2026-09-30: Ubuntu, Claude Code 2.1.251, bubblewrap 0.11.1, socat 1.8.1.1) only after installing Claude Code's documented /etc/apparmor.d/bwrap profile, which disables Ubuntu's bwrap-userns-restrict host-wide; without it sandboxed commands fail closed. Operators who merged an earlier block: re-run --update and re-merge the sandbox block.
  • Native Windows has no emitted enforcement — it is design-only.
  • On a host where confinement cannot be enforced, the interactive path fails closed with PrivilegeConfinementError unless the operator explicitly passes --allow-unenforced-confinement (agentteams/cli/artifacts.py:321-411).

denyWrite protects the control plane; denyRead hardens outbound

Independent of profile, the emitted sandbox applies denyWrite of exact control-plane paths — references/agent-privilege.json and .claude/hooks/constitutional-gate.py — on a deny-over-allow basis, so an agent running inside the sandbox cannot rewrite the enforcement plane that governs it (agentteams/frameworks/_sandbox_emit.py:25-208). The constitutional-gate.py hook of S19 is itself one of these protected paths.

Sibling team dirs (2026-09-30). The copilot (.github/agents) and codex (.codex/agents) team dirs carry the same trust roots but emit no sandbox of their own, so every other arm covers them:

Arm Coverage of .github/agents / .codex/agents Status
Claude denyWrite .github/agents, .codex (and .goose) when that team is present at generation; never .github product-verified, Claude Code 2.1.251, Linux
Claude permissions.deny Edit(...) on each trust-root file emitted, product-unverified
confine-run.sh required once the team marker exists; .github/workflows never bound mechanism-verified
goose Seatbelt trust roots; .github ancestor literals only with a copilot team UNVERIFIED

Claude Code self-binds each deny path's parent, so .github cannot be renamed while .github/workflows stays writable. Remove a sibling's denyWrite entry before deleting that team: a missing deny path stops bwrap. Copilot's *.agent.md files stay Edit-tool writable, so copilot teams are not fully covered.

The exclusive profile additionally emits denyRead of credential directories (SSH, AWS, and similar). This is outbound hardening — it targets files, not environment variables, and it stops the confined agent from reading your credential tree; it does not stop other processes from reading your tree.

The tie to the default posture (S1 fact 5)

Because agentteams only emits configuration the operator must merge, all of the above is dormant out of the box until it is wired in: under the default confined profile the sandbox + denyWrite/denyRead example is emitted but enforces nothing unmerged, and the S19 hook stays fail-open (only an explicit confined/exclusive flips it). Runtime OS confinement enforcement engages only once the operator wires the emitted example in. The governance layers (constitution, sentinel, gates, scanner) are always active; the OS-confinement enforcement layer is opt-in.

Source. agentteams/host_features.py:134-261; agentteams/frameworks/_sandbox_emit.py:25-208; agentteams/frameworks/_goose_sandbox_emit.py:1-222; references/agentteams-remediation-log.csv (D-3); agentteams/cli/artifacts.py:321-411.


The constitutional-gate hook ✅ (fail-closed under confined/exclusive; fail-open by default) {#S19}

The constitutional-gate hook is the runtime counterpart to the CLI's security_gate.py. Where the CLI gate guards a handful of CLI entry points, agent Bash/Write calls never pass through those entry points — the hook is what catches them.

Mechanism — a PreToolUse hook the harness runs

The hook is a PreToolUse hook the harness invokes before each tool call, enforcing C-4 (content is data) and C-5 (clearance precedes destruction) on agent-initiated actions (agentteams/templates/universal/hooks/constitutional-gate.py:1-209). Because the harness runs it — not the agent — it is "not merely another file the agents can edit on their way past it": an agent cannot skip a step the harness performs before handing control to the agent's tool call.

Split verdict

The hook returns one of two blocking verdicts, split by the kind of action:

  • deny — for deterministic high-severity write findings: credentials, PII, or injected instruction-override text (C-4). This deny is the runtime expression of a HALT.
  • ask — for procedural Bash Mandatory-Review triggers (C-5): privilege escalation, piping remote content into a shell, recursive force-removal, persistence mechanisms, package installs, history-rewriting force-push, and data-carrying outbound sends (S-9 criterion 6: uploads, raw sockets, remote copies, gists, new git remotes). These require operator confirmation rather than an outright deny.

Integrity check before scanning

Before it scans a write, the hook verifies the scanner against the integrity manifest (S22). If scan.py has been tampered with, the hook returns ask, not a silent allow — a compromised scanner degrades to operator prompting, not to a bypass.

Default posture — fail-open, flipped fail-closed only by an explicit profile

The hook is fail-open by default: if the hook itself cannot run to completion, the action proceeds. agentteams flips it fail-closed only for an explicit confined or exclusive in the brief — the defaulted confined profile does not trigger the flip, so the default posture stays fail-open; the operator can restore fail-open under those profiles with --allow-fallback-fail-open. This is the same emit-but-inert posture as S18: the strong runtime behaviour is tied to an explicitly-selected profile.

One crash is no longer fail-open: a missing agentteams import (1.0 must-fix). The harness runs the hook as plain python3, and the write path imports agentteams. When that interpreter lacks it, the hook re-runs itself under the interpreter named in agentteams-python beside it — the absolute interpreter that rendered the team, written to an untracked file (a .gitignore beside it ignores exactly that file, Rule S-8). The pin is used only when it is absolute, an executable python*, and outside the project tree; the re-run gets a scrubbed PYTHONPATH/PYTHONSTARTUP/PYTHONHOME and a loop guard. With no interpreter that can import agentteams, a content-bearing write gets ask (cooperative) or deny (fail-closed) naming the fix, never a silent allow. --check-wiring reports the pin and fails when neither it nor python3 can import agentteams.

Honest ceiling (E4) — cost, not impossibility (fact 4)

The hook does not escape a determined multi-file tamper. An attacker who can edit scan.py can also edit the integrity manifest and this hook file itself. The hook does not make that impossible — it raises the cost from one edit to three, each of them visible in git. Its value is that it converts a single silent change into a multi-step, recorded, git-diffable act, and gives the operator a chance to notice. The hook file is itself one of the denyWrite-protected control-plane paths of S18, which adds the sandbox as a further (Linux-deny-tested) obstacle to that third edit — but the residual ceiling stands: composed layers raise cost and make tampering evident, they do not eliminate it.

The decision log is evidence, not a boundary. With AGENTTEAMS_GATE_LOG set to an absolute path outside the project (judged from the hook's working directory) and outside any git work tree, the hook appends one JSON line per call: tool_use_id, tool name, decision (pass/ask/deny/crash-open/crash-deny), a fixed reason_id, scanner categories and line numbers, what it checked, the installed hook's sha256 and a prev_sha chain kept under flock. It never records written content, the free-text reason or exception text, because the scanner's message embeds the matched text. AGENTTEAMS_GATE_LOG_KEY adds a keyed input_hmac of the canonical tool_input. An unsafe path is refused with a stderr notice, and no logging failure changes the decision. Read it conservatively: a missing line is no evidence, never a pass, and checked=none does not show the call was inspected.

Source. agentteams/templates/universal/hooks/constitutional-gate.py.


Sources for Part VI. agentteams/templates/universal/security-infrastructure-layers.reference.template.md:31-70,119-130; agentteams/host_features.py:134-261; agentteams/frameworks/_sandbox_emit.py:25-208; agentteams/frameworks/_goose_sandbox_emit.py:1-222; references/agentteams-remediation-log.csv (D-3); agentteams/cli/artifacts.py:321-411; agentteams/templates/universal/hooks/constitutional-gate.py:1-209.