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 explicitconfined/exclusive, not the defaultedconfined). 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.shbwrap 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
sandboxexample intosettings.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.shlauncher (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
denyWriteof a non-existent path failsbwrapinitialization, which blocks all Bash — a fail-shut fragility, not a security hole, but an availability defect that remains open inreferences/agentteams-remediation-log.csv(item D-3). That native arm'sdenyReadbehaviour (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 withsocatmissing: the old block (enabled: true,allowUnsandboxedCommands: false) let Claude Code print "Sandbox disabled … Commands will run WITHOUT sandboxing" and run unconfined. The block now emitsfailIfUnavailable: trueon macOS/Linux targets (omitted on native Windows, where the block is advisory), so Claude Code refuses to start instead. Linux needsbwrapandsocat(scripts/install-sandbox-deps.sh); Ubuntu'skernel.apparmor_restrict_unprivileged_userns=1makes 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/bwrapprofile, which disables Ubuntu'sbwrap-userns-restricthost-wide; without it sandboxed commands fail closed. Operators who merged an earlier block: re-run--updateand re-merge thesandboxblock. - Native Windows has no emitted enforcement — it is design-only.
- On a host where confinement cannot be enforced, the interactive path fails
closed with
PrivilegeConfinementErrorunless 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). Thisdenyis 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.