agentStack

These pages describe v0.19.0, the current release. That is the build the installer gives you. agentstack --version says which build you have.

AgentStack — Enforcement matrix

Current as of agentstack 0.19.0.

Short codes below (W2, W4, D3, D4, G9, Phase 1Phase 4) name internal development milestones, not public releases or CVE-style identifiers. They record when a behaviour landed relative to other work in TODO.md; nothing in this document depends on knowing them, and every claim beside them stands on its own.

This is the authoritative, code-grounded answer to one question: for each execution mode, what does AgentStack actually enforce, and by what mechanism? When any other document and this one disagree, this one is right: it is checked against the source, not against intent.

Audience: anyone deciding what a mode actually guarantees.

Contents

AgentStack intercepts an agent CLI on four independent lanes (one observes, three enforce):

Four interception lanes: your agent CLI flows through the observe-only proxy to the Anthropic API, and through the enforcing gateway+mcp, guard, and egress interceptors to MCP servers, your filesystem, and the internet

Claim discipline#

AgentStack restricts destinations and records decisions; it cannot guarantee that sensitive content never leaves through an allowed destination. An enforced egress allowlist blocks connections to hosts you did not approve. It does not inspect payloads, and it permits traffic to every host you did approve, including the model API itself. A prompt-injected agent can still exfiltrate through any allowed channel. The honest claim is: untrusted project declarations are not auto-activated, and unapproved egress is blocked on the enforced paths, never "exfiltration is impossible."

Read every cell below with that ceiling in mind. "Enforced" means the disallowed action is prevented at runtime (by the kernel, the container boundary, or the proxy); it never means the allowed action is safe.

What "trusted" does and does not mean#

Trusting a project asserts exactly one thing: the current manifest, local overlay, and lockfile consent digest was approved for automatic loading on this machine. The lockfile separately pins resolved server definitions, skills, and instructions; drift in those inputs fails verification. Detached signatures attest to lockfile bytes but do not silently create local trust.

Trusted does not mean:

The resident agent#

The threat this section is really about is not a distant attacker: it is the agent already running here, in a hooked harness, on your account, holding the same shell and the same file tools you hold. Every flag the CLI accepts is a flag it can type, and inside the process a trust . --yes --consented <digest> from that agent is indistinguishable from the same command typed by a human who read the preview. The hook is the last place that difference still exists, so that is where the refusals live.

What the guard refuses, on the everyday path (agentstack guard check, wired as each CLI's pre-tool-use hook):

Alongside those, a scripted agentstack init --yes no longer records trust by itself: --yes acknowledges the write, and consent for what the imported servers run comes either from the wizard's own confirm or from --consented <plan_digest> after init --plan. The import completes and the project stays untrusted, which the close says in as many words.

What remains, stated plainly:

crates/cli/tests/red_team_agent_self_consent.rs is the witness for the five doors above, driven through the hook binary and the real grant path.

Conversely, untrusted project declarations are inert on automatic and experimental execution paths: the auto-project gateway does not spawn or contact their MCP servers or resolve their secrets, and tools_execute refuses to begin. Since the write gate reached all five capability kinds, an explicit static agentstack apply --write is blocked too: it renders no server config, no skills, no instruction fragments, no hooks and no extensions for a project that is untrusted or drifted. What this still does not do is sandbox arbitrary repository code or prevent a user from running it by hand. Those are separate authorization and execution paths, and a harness that reads bytes already on disk starts them outside agentstack entirely.

Policy is authority, not isolation#

Policy decides which tools, hosts, secrets, and paths are permitted; it does not decide where the process runs. Policy is not a sandbox: an allowed tool can still have side effects, and an allowed host can still receive sensitive data. Confinement is the job of --sandbox and --lockdown, per the matrix below; the two compose but are never substitutes.

The shipped presets (examples/policies/) map to intent, not to a single universal mode: use developer for daily work, compatible as a migration step from an unmanaged setup, locked-down when a run needs confinement, and ci as a runner-only floor. In every case the effective ruleset is the machine ∩ project intersection, so a preset can only narrow what the machine ceiling already allows.

The matrix#

Modes are columns; policy dimensions are rows. Legend:

Dimensionhostgatewaylease--sandbox--lockdown
Toolsunsupportedenforcedenforcedenforcedenforced
Egresscoarsecoarsecoarseenforced*enforced
Secretsenforcedenforcedenforcedenforcedenforced
Filesystem — writecooperative¶cooperative¶cooperative¶coarsecoarse
Filesystem — readcooperative¶cooperative¶cooperative¶coarsecoarse
Audit / recordingunsupportedenforcedenforcedenforced§enforced
Native extensionsunsupported‖unsupported‖n/a◈unsupported‖unsupported‖
Hooksunsupported⁂unsupported⁂n/a◈unsupported⁂unsupported⁂

the strongest tools cell, and fenced on top. A lease is the gateway column's dispatch path with one addition: the toolset. Every capability call still goes through Gateway::try_call, which applies the compiled machine ∩ project tool policy and re-checks the consent digest before the upstream is dialled, and on top of that, only the leased toolset's members are reachable. With no lease open, a project that declares toolsets serves control-plane tools only: the implicit union of everything declared is never served, because capability exposure requires an explicit selection. Opening a lease exposes exactly that toolset's members and nothing more; closing it returns the connection to control-plane tools. What the fence does not do is constrain what an allowed tool then does (see ◆).

every brokered MCP call recorded — not "every call recorded". Each call the lease dispatches lands in calls.jsonl (and a run's events.jsonl inside a run) with digest-only arguments. That is evidence of the request: AgentStack records what was asked of a server, and cannot observe what that server then did internally. Recording is not prevention (a recorded call already happened), and an allowed destination can still exfiltrate, as the claim-discipline section above states for every column. A lease also does not make a call reproducible: only the pinned bytes in the lock do that.

Two further limits belong here, not in a footnote. A lease is process-scoped: it belongs to the MCP process that opened it and disappears with that process, so it is not a durable grant and cannot be re-attached to. And the delivery claim is "0 project artifacts for gateway-delivered capabilities", never a bare "0 files": a project in this lane still holds .agentstack/agentstack.toml, agentstack.lock, and, whenever instructions are used, a managed region in an instruction file. Those are rendered-lane artifacts and they are real.

not applicable — these kinds never enter this lane. Native extensions and hooks are executable capability kinds: their code runs inside (or around) the harness process at full user permission. They are delivered by rendering files, never over a lease, so there is no lease cell to label. The full consent ceremony always applies to them, in a package or out of one, and no compressed-consent path may ever cover them. Their real story is the ‖ and # notes below.

* for proxied traffic only. Plain --sandbox points HTTPS_PROXY at the proxy but the container keeps an ordinary bridge network. A process that ignores the proxy env can still dial out directly. The run is labelled SANDBOX / PROXIED · DIRECT ROUTE OPEN for this reason; only --lockdown (no direct route, topological confinement) earns ENFORCED. See the egress section below.

plain sandbox, for MCP traffic routed through the gateway. A trusted run renders one host-gateway entry into the harness config, so calls hit Gateway::try_call. Plain --sandbox still has an open direct route: an agent that independently reaches an egress-allowed upstream can bypass that gateway. An untrusted bundle, a bundle with no proxied servers, or an incompatible adapter can also be unrouted; those cases are surfaced at runtime. Under --lockdown, D4 closes this qualification: the same frozen, pin-verified server set drives gateway dispatch and the gateway_only_hosts egress fence; direct connections to every declared HTTP MCP host are denied even when ordinary egress policy allows them. If the gateway entry and native-config shadows cannot be installed, lockdown refuses to start. Undeclared service aliases are outside this exact declared-endpoint claim.

plain sandbox, for gateway-routed runs. The host-side gateway resolves ${REF} secrets in its own memory and hands the container only the endpoint URL + a per-run bearer token: resolved secret values never enter the container. A prior agentstack apply that baked secrets into a project config is shadowed out. (A run that isn't gateway-routed falls back to the coarse rendered-config path.)

§ plain sandbox, for gateway-routed runs. A gateway-routed run's own events.jsonl gains a ToolCall per call (digest-only args) and a SecretAccess per resolved ref (name only), alongside the lifecycle + egress events it already held. Trust-store mutations and cost/tokens remain unrecorded. See the Audit / recording section.

runtime is unsupported in every mode — this is a pre-delivery capability, not a runtime one. A native extension's code runs inside the harness process at full user permission; no policy ceiling, gateway, egress fence, container, or guard hook observes or constrains it once the harness loads it, so there is no runtime cell to earn a stronger label. What agentstack governs happens entirely before delivery: the source is content-pinned in agentstack.lock, an untrusted or drifted project renders zero bytes, apply copies (never symlinks) the pinned bytes into the harness's extension directory, and a protected run re-verifies each delivered copy against its pin before launch. That pipeline is provenance and content binding (which bytes, from where, reviewed by whom), not runtime enforcement, and it is labelled as such. See the Native extensions section.

runtime is unsupported in every mode — a hook is a command the harness itself runs at full user permission. A [hooks.*] entry compiles into each hook-capable harness's native hooks config; when the harness fires the event, it executes the hook's command in its own process context: no policy ceiling, gateway, egress fence, container, or guard observes or constrains that execution, so there is no runtime cell to earn a stronger label. (The host guard's own pre-tool-use hook is this same mechanism pointed at agentstack's guard binary, which is why the filesystem rows above top out at cooperative.) What agentstack governs happens before delivery, and it is a narrower surface than the extensions pipeline: the hook's declaration (its event, matcher, command line, and targets) is part of the manifest bytes, so declaring or editing a hook re-gates trust review; rendering is fail-closed for untrusted and stale-trust projects, at project and global scope alike (crates/cli/tests/red_team_hooks_trust_gate.rs); and doctor reports declared-vs-installed drift. But when a hook's command names a local script, the script file's bytes are not content-pinned: no lock entry digests them, so editing the script after consent changes what runs without re-gating anything. That gap is documented here rather than papered over. Strategy classification: hooks are an executable capability kind alongside extensions: the full consent ceremony always applies, and no compressed-consent path may ever cover them. See the Hooks section.

Two of the five columns are execution modes for a rendered config: host is agentstack apply + agentstack run (adapters write native config, the harness runs on the bare machine and talks to upstream MCP servers directly). gateway is the in-process broker (agentstack mcp, connect, code mode); every MCP call routes through Gateway::try_call. lease is that same broker with a toolset selected for one MCP connection (agentstack_lease_open), the dynamic delivery lane and the strongest column here. --sandbox and --lockdown are agentstack run --sandbox [--lockdown]: the harness runs in a Docker container behind the egress proxy.

The host column covers the protected default too. A plain agentstack run <cli> now gates the launch fail-closed, but every one of those gates runs before the harness starts, so it changes no cell here: pre-launch gating is not runtime confinement, and the label HOST / PROTECTED says exactly that much and no more.

Which column a capability lands in is now routed, not chosen (delivery flip, 2026-08-03; design/automatic-delivery.md). The delivery planner sends skills and MCP servers on an MCP-capable CLI down the dynamic lane by default, and instructions, settings, hooks, extensions, and every capability bound for a CLI without MCP down the rendered lane. One override, Render locally ([delivery] render_locally, per project or per harness), forces the rendered lane where the lease would have worked; nothing moves a capability the other way, because no channel would carry it.

None of that changes what any column enforces: the cells below are unchanged by the flip, and a routing default is not an enforcement claim. What it changes is which column an ordinary project is in: the lease column is now the everyday one for skills and servers rather than an opt-in mode, so its limits (◇, ◆, process scope, and the transparent-mode listing cost) are limits most users now meet, not edge cases. Routing is also not activation: a capability routed to the dynamic lane is served only once the bridge is registered, the project is trusted at its current bytes, and a lease names a toolset containing it.

Per-cell notes#

What every delivery trust gate leaves outside#

The per-kind gates below (servers, skills, instructions, hooks, native extensions) share two exemptions, each because the content is not the project's:

Each section below names any further exemption of its own and otherwise refers back here.

Tools#

Egress#

Secrets#

Filesystem — write#

Which array has an enforcer. Three of the five filesystem arrays are enforced and one is not; the full reference table, with the allow-all recipes, is in reference.md.

ArrayOwnerEnforcerWhen absent
[policy.filesystem] readmachine + projectnone — compiled and displayed, consulted by nothingno effect
[policy.filesystem] writemachine + projectsandbox workspace mount only, all-or-nothingworkspace mounts read-only
[policy.filesystem] denymachine ∪ project (project may only add)host guard (cooperative) — the only enforcer todaynothing blocklisted
[guard] allow_rootsmachine onlyhost guard write confinementwrites confined to workspace + temp
[guard.project_roots]machine onlyhost guard, one named workspaceno extra roots

Declaring a read scope confines no read: host reads are limited by deny and by nothing else. A write scope answers one question — may the workspace root be mounted read-write — and a partial scope like src/** rounds down to read-only rather than mounting part of the tree. Writes inside a project workspace need no configuration; they are allowed by default. And allow_roots = ["/"] switches off the write-scope check alone: the destructive-command rules and the refusal of shell writes into ~/.agentstack both still stand.

- Shell writes reach it on every wired CLI — a redirect, rm/mv/cp/tee, sed -i — because they arrive as commands and route through the same write-target check.

- File-tool writes reach it on two signals. The tool name is the floor (WRITERS: Write, Edit, MultiEdit, NotebookEdit, write_file, replace, edit_file, fs_write, create_file, str_replace_editor, replace_string_in_file, multi_replace_string_in_file, apply_patch) — every name on it is a write, as before. The guard refuses a write whose target it cannot read: a write the guard cannot locate is a write it cannot confine.

- Codex's apply_patch names its targets nowhere but inside its patch text, so the guard reads the documented envelope (*** Begin Patch*** Add File: / *** Update File: / *** Delete File: / *** Move to:*** End Patch, per the Codex parser's own constants) and puts every path it finds through the identical write check a Write gets. One refused path refuses the whole patch.

- Beyond the list the payload decides, so a tool this build has never heard of is still confined when its call plainly intends a write: an edit structure (old_string/new_string, a patch, a list of edits), an explicit write mode or an append/overwrite/create flag, a body of content for the file it names, or an editor verb (create, str_replace, insert) in command. Key spellings are matched normalized, so the snake, camel and Pascal dialects all land.

- The residual is real and named: a write whose call carries none of those signals — a path and nothing else, or content passed by handle — still degrades to the read path and gets the deny-glob check only. A path under a field name the guard does not read is now judged only when the tool's name is on WRITERS (there it fails closed and is refused); under an unknown name it is still not judged at all.

- The envelope reader is narrow: it fires only when the whole argument is the envelope (first line *** Begin Patch, last line *** End Patch), so a patch smuggled around a shell command stays on the command path and keeps its destructive-command analysis — and apply_patch invoked through the shell (heredoc, or argv ["apply_patch", "<patch>"]) is still analysed as a command, not as a patch. That degradation stays the safe default, chosen so an unfamiliar tool cannot hang the harness.

- Cursor is confined for shell writes and for nothing else: its surface offers no pre-write file hook, so the installer wires only beforeShellExecution and beforeReadFile and no Cursor file write is ever presented for a decision.

- [guard.project_roots] scopes an extra root to one workspace ("sessions under ~/x may also write ~/y"). The grant lives in the machine manifest, so a project can never widen its own write scope, and the guard denies shell writes to that manifest's directory precisely so this table can't be edited into allowlisting itself.

- Every denial is recorded to the audit log (host-guard entries in calls.jsonl), and the two kinds stay tellable apart by their subject. A rule denial names the call it judged — bash: …, read: …, write: …, other — and carries the anchored workspace. The three fail-closed system refusals — an unreadable machine config, an unavailable machine policy, and an unreadable or oversized hook payload — are recorded under a synthetic subject (system: machine-config-unreadable, system: machine-policy-unavailable, system: hook-payload-unreadable) with no project, because what refused was the guard's own broken state rather than an evaluated rule, and no workspace had been anchored yet. The prefixes are machine-authored and payload content can only land after them, so no tool call can forge a system subject. Recording never gates the block: an unwritable audit log loses the evidence, never the denial.

- The ceiling is the legend's: the harness must honor its own hook protocol — this catches accidents, not malice.

- Three CLIs are reported as not protected, and the code keeps the two reasons apart because they are not the same promise. NO_HOOK_SURFACE is a fact about the CLI — Claude Desktop has no PreToolUse-style hook and Junie has only a static action allowlist, so there is nothing to ride and nothing to wait for. NOT_WIRED is a fact about agentstack: Kiro is unprotected because no guard hook has been built for it, not because none could be. Kiro's descriptor records its MCP config only, so this repo knows no hook file to install into and no entry shape uninstall could find again; a hook format guessed from outside the descriptors would be one the guard cannot honestly claim. Both cells are unsupported today; only the second one can change.

- Config unreadable → the hook fails closed; unrecognized payload shapes fail open (a guard that wedges the harness gets uninstalled, not fixed). (crates/cli/src/guard.rs, crates/cli/src/commands/guard.rs)

Filesystem — read#

Audit / recording#

Recorded is not prevented. This whole section describes what is written down, which is a different claim from what is stopped. An event proves a check ran and what it decided; it never upgrades the cell that decided it. The matrix rows above are the enforcement claim, and a family whose row says cooperative or coarse keeps saying that no matter how completely its decisions are logged. The two are set side by side: a log records a decision; it does not strengthen the cell that made it.

The lease column's recording claim is stated in exactly one form: every brokered MCP call recorded, never "every call recorded". AgentStack records what was asked of a server; it cannot observe that server's internal side effects, and a call an agent makes by some route that never reaches Gateway::try_call is not brokered and is therefore not in this log. Absence from the log means "AgentStack did not broker it", which is not the same as "it did not happen".

Which is which, per denial family:

Denial familyEnforcement claimRecorded?Where
Gateway tool blockenforced (gateway/sandbox/lockdown)yescalls.jsonl + run events.jsonl (ToolCall, outcome: denied)
Egress refusal — sandbox proxyenforced under --lockdown, coarse/proxied under plain --sandboxyesrun events.jsonl (Egress, allowed: false)
Egress refusal — host pathcoarse — a write-time check on the declared host, not a wire-level fenceyes, both halves — the render-time one new in G9calls.jsonl (tool: egress) + run events.jsonl (Egress) when inside a run, for the gateway-build refusal and the one raised while rendering config (apply / use / doctor) alike. The render-time record names the server and the declared HOST, never the URL
Secret-scope refusalenforced — the ref reaches no backing storeyes, new in Phase 3calls.jsonl (tool: secret) + run events.jsonl (SecretDenied)
Filesystem guardcooperative — the harness chose to askyescalls.jsonl (server: host-guard, run: None)
Content-pin refusalenforced — the server is dropped before it is spawned or dialledyes, new in Phase 4calls.jsonl (tool: pin) + run events.jsonl (PinRejected)
Trust-at-dispatch refusalenforced — the call is refused before the upstream is dialledyes, new in W2calls.jsonl (tool: trust) + run events.jsonl (TrustRefused)
Toolset-fence refusalenforced — the fenced gateway holds no upstream for the name, so nothing was spawned, dialled, or forwardedyes, new in W4calls.jsonl (tool: fence) + run events.jsonl (FenceRefused)

Eight rows, seven families: Family::Egress refuses in two places that do not enforce alike, so its row is split rather than averaged. Counting rows is not counting families, and the paragraphs below number the families.

The toolset-fence row is the seventh family, added in W4 (leases). It fires when a call names a server this project declares while no open toolset selects it. Note the order: the fence had already emptied the gateway of that upstream, so the record is evidence rather than the act: mcp_server::fence_refusal turns what would otherwise be a bare "unknown tool" into a line that names the toolset to open. One bound on it: it records only for a server the manifest declares, because otherwise any caller could write unbounded rows into the audit log by inventing names. Inside a tracked run it writes the same two-destination evidence its siblings write: the calls.jsonl line and a RunEvent::FenceRefused mirror, which agentstack report run <id> renders in its own Fence refusals section rather than among the tool calls, because a refused call is not a call the run made. (crates/cli/src/seatbelt.rs Family::Fence, crates/cli/src/mcp_server.rs fence_refusal)

The trust-at-dispatch row is the sixth family, added in W2 (automatic delivery). It fires when the project's consent digest stops matching the one a live connection was authorized against: trust revoked, the manifest edited out of band, or agentstack.lock replaced wholesale by a git pull or a branch switch. Before W2 an already-spawned server stayed proxied until the next lease, load, or session call happened to re-check, so a withdrawn yes left a working path open; now every gateway dispatch recompares the digest, and any uncertainty (an unreadable manifest, an inconclusive recompute) refuses.

Two notes specific to it. First, what it empties is the upstream capability surface: the leased servers' tools go away and tools/list stops advertising them, while agentstack's own control-plane tools stay reachable on the same connection, deliberately: a user whose project just went untrusted has to be able to diagnose and fix it, and blinding them would turn a fail-closed refusal into a dead end. Second, the comparison is recomputed on every dispatch rather than cached: git pull, a manual edit, and a lock swap all happen outside agentstack, so a cache here could only ever be a guess that nothing moved.

The content-pin row is the fifth family, added in Phase 4. It differs from the other four in what refused: nothing the user authored denied anything here, the delivered bytes simply are not the bytes they reviewed, which is why it has its own family and its own next step (review what changed, or re-pin deliberately), rather than borrowing the tool block's. Under a sandboxed run it only ever fires for a project that is already trusted: Gateway::from_frozen carries the hard trust gate, so an unreviewed bundle is refused whole, earlier, and never reaches per-server verification. The host, lease, and eager gateways (Gateway::from_manifest, from_manifest_lease) carry no such gate: they resolve and pin-verify every selected server for an untrusted project too, and can emit this refusal for one. That is deliberate rather than an oversight: those constructors are reached by naming the project, and on the eager path --manifest-dir is itself the consent. What the refusal means is identical on every path (the delivered bytes are not the bytes that were reviewed); only the "already trusted" precondition is the sandboxed run's alone.

One note specific to it: its refusal text is composed from lockfile and manifest fragments, which are repository content and therefore hostile input (invariant 7). It is control-character-stripped and length-bounded before it is printed or recorded, so the reason in the log is deliberately lossy: a denial the reader can trust to be a denial is worth more than a complete one.

The secret-scope row and the gateway half of the host-path egress row were what Phase 3 gave events to: refusals that happened, printed once, and left nothing behind. Adding those events changed only what is written: both were already fail-closed refusals, both still are, and neither row's enforcement claim moved as a result. The same is true of the Phase 4 row: Gateway::build drops the same servers it dropped before, and refuse still returns (). The host-path egress row in particular stays coarse: recording a write-time decision does not make it a runtime fence, and reading this table as though it did is the exact error the paragraph above exists to prevent. G9 gave its render-time half the same two destinations, on the same seam and with the same discipline: still coarse, still fail-closed, and still only evidence that the check ran.

Servers#

Skills#

Instructions#

Settings#

Hooks#

Native extensions#

Workflows#

The protected run's frozen grant (the default run)#

Images (agentstack image)#

What ships: one toolset and its pinned members composed into a container image the user builds locally and runs themselves (design/packaging.md). Skill bodies are copied out of the content store by the digest agentstack.lock records (the same pinned-serving rule the MCP and rendered lanes follow) and laid down in the harness's own skills directory inside the image. Server definitions travel verbatim under /agentstack/servers/, ${REF} placeholders intact. Nothing is pushed, tagged remotely, signed, or registered, and nothing phones home. The build refuses fail-closed on an unpinned member, an unverifiable store deposit, a server the frozen resolution rejects, or a project that is not trusted at its current bytes.

The posture label, and its exact scope. The artifact carries the shipped Posture::Sandbox label (SANDBOX / PROXIED · DIRECT ROUTE OPEN), and that label describes what the image is prepared for, never what a run enforces. Posture is a property of the run. Every mechanism the --sandbox column above claims is supplied by whoever starts the container: the proxy, the allowlist, the run log, the gateway. Consequently:

What it is not. Packaging adds no enforcement of any kind. It changes where reviewed bytes are, not what a process holding them may do. It is also not a reproducibility claim beyond AgentStack's own layer: the members are content-addressed and identical across machines, but a Docker build is not bit-reproducible (layer metadata varies per build) and the FROM base is a floating tag unless the user passes --from a digest. And no secret is ever baked: the build constructs no resolver at all, the image carries only the ${REF} names it will require, and a start-up guard refuses to launch the harness until those names are present in the run's own environment.

Trust-store mutation logging#

What ships: every mutation of the machine trust store appends one identity-only line to ~/.agentstack/audit/trust.jsonl: timestamp, action (grant, regrant, repin, revoke, decide, undecide), the store's own project key, and the consent digest pinned, removed, or already stood on. Never the manifest bytes, never the reviewed surface. A standing re-gate answer (keep-pinned or blocked) is written onto the same project entry in the same store file, so it appends a line too — decide when one is recorded, undecide when one is withdrawn. The split is what the identity-only rule costs: which item was answered and what the answer was are consent content and stay out of the log, so the action name is the only place the direction of the change can live. A call that changes nothing — re-affirming an identical answer, or clearing one nobody gave — writes nothing and records nothing. The log now answers both "what was consented to, and when" and "what changed in trust.json". The append happens inside the store lock and only after the store write succeeded, so log order is store order and every event describes a mutation that actually happened. repin is recorded distinctly from regrant because no human consented to it. The file is created 0600 and is never rotated: consent metrics count over the full history.

What it is not: the append is best-effort — if it fails, the grant still succeeds and the event is simply lost, because recording must never gate consent. The log is append-only by convention, not tamper-evident, and it sits under the same user-writable ~/.agentstack/ as the trust store itself. A compromised host-mode agent that can self-trust can also delete or rewrite this log. It raises the cost of unnoticed self-trust; it does not prevent it. Only sandbox mode removes the underlying risk.

Intake detection (dropped files)#

What ships: files sitting in a project's own skills/ and instructions/ directories that no manifest entry declares are noticed at command time by status, doctor, use, lock, and adopt, and offered for adoption with a preview. Undeclared content is inert, and that is a property of the existing design rather than a new check: nothing enumerates those directories, so undeclared content is never resolved, pinned, materialized, or placed in an agent's context. Detection reads it and reports names, paths, and a one-line summary; adoption writes a manifest entry and nothing else — the lock still has to pin the bytes and the trust gate still has to pass before anything is delivered. Every byte read is treated as hostile input (invariant 7): bounded reads, bounded entry counts, symlinks refused rather than followed (the directory entry and the file whose bytes would be read), names validated before they can become manifest keys, and all displayed text passed through the shared terminal sanitizer. A dropped file whose name a manifest entry already uses is reported and not adopted: replacing a pinned declaration is a different act from bringing in something new, and it never happens behind a preview that presents it as an addition.

Each item is classified by provenance. Content already recorded as having arrived through receive or add from is settled first, before git is consulted at all: those bytes are a stranger's work whatever git would say, and adopting them lands them untracked, which is how they would otherwise read as the user's own. After that, inside a git work tree tracking alone decides — untracked is the user's own work, tracked came with the project — read through a single hardened git ls-files -z over the intake directories. Outside a work tree there is no tracking signal and no fallback: every item is classified as arrived, for the stated reason "no git history to attest who authored this". The classification is shown to the user and gates only compression of the first-time adoption path; it never gates adoption itself, so a project outside git simply takes the full staged review.

What it is not: provenance is a heuristic about origin, not an integrity claim. Untracked-in-git means git has not seen the file, which anything with write access to the working tree can arrange. Modification time is consulted nowhere — not for tracked files, because git rewrites it on every checkout, and not outside a work tree either, because touch is free to any process with filesystem access. That is why the absence of git yields no compressed path rather than a timestamp comparison: a signal an attacker can forge outright is worse than no signal. The signals that do remain still do not survive an attacker who already has local write access, and none of them is a substitute for reading what you are adopting. Detection is also not a monitor: it runs when you run a command, so content dropped and removed between commands is never seen.

Single-action activation (agentstack yes)#

What ships: for locally-authored dropped files with no name collision, one command performs declare → lock → trust → render behind one review and one confirmation. The collapse is presentation, not semantics. It calls the same functions the explicit sequence calls, and the grant goes through the one grant_gated path agentstack trust uses — same surface, same digest, same recorded trust.jsonl events in the same order. That parity is a witness test (crates/cli/tests/funnel_activation.rs), not a claim. The review shows everything the separate steps show — including the real activation dry run, which is the actual use code path with writing off, so the preview cannot drift from what follows it — plus, for each item, the provenance line saying why it qualified. Declining restores the manifest and lockfile to their previous bytes, so a refusal leaves the project as it was.

The command requires a terminal. It is a review a human reads and answers; headless callers keep the explicit path, where --consented binds the acknowledgement to previewed bytes (§7.2). Content that fails provenance or collides with an existing declaration is not filtered out downstream — it is never in the set the compressed path acts on, and the user is told which command reviews it properly.

What it is not: this is first-time adoption only. Re-consent to changed content is not compressed and stays on the explicit path until the review card can render a real diff of what changed — compressing a re-gate before the change is visible would be worse than the friction it removes. It does not widen what a yes grants: the same bytes are consented to, in the same gate, with the same effect. It is also not a review the tool performs on your behalf — nothing here inspects what a skill's text tells a model to do, for the reason stated under Skills above.

Review-card state on disk (snapshots, recognition, decisions)#

What ships: the review card keeps three pieces of state so a re-gate can show what changed rather than only that something changed, and so repeated review of identical content gets shorter.

What they are not:

Sharing, intake, and what a signature is worth#

Phase 4 added three surfaces that all handle content from outside this machine. Each is written down here because each is a place where a reader could reasonably assume more protection than exists.

Bundle signatures — share / receive#

A .astack bundle carries an ed25519 signature over its own contents (everything except the signature and the key that made it). What the signature proves, exactly: these bytes came from the holder of this key, unchanged since they signed.

What it does not prove, and must never be read as proving:

Interactively, an unsigned bundle and an invalid signature are both stated on the card and neither aborts: the full review stands in both cases. An invalid signature is the loudest of the three, because it means the bytes changed after signing.

Headlessly the rule inverts, and this is the one place a signature does decide something. receive --yes refuses both: a headless accept leans entirely on the signature, and neither an absent one nor a broken one holds. What it demands is a verified signature, not a recognized publisher: a good signature from a key nobody has ever trusted passes --yes, because whose key it is remains the reader's question and --yes was never a way to answer it. And with no --yes and no terminal there is no accept path at all: the receive declines. (crates/cli/src/commands/share.rs confirmed, crates/cli/src/publisher.rs Provenance::verifies)

Quarantine — where intake waits#

Fetched content is staged under .agentstack/quarantine/ before the card is shown, so what the card describes is what is on disk rather than what was in memory. Inertness there is structural rather than enforced: the path is not an intake directory, is named by no manifest entry, is on no search path, and is reachable by no server. There is no sandbox around it: it is a directory of files nothing is arranged to read. That is the whole mechanism, and it is honest precisely because there is nothing to bypass.

Declining removes the directory. The property is the Phase 1 one: fetched then declined leaves the project byte-identical with nothing to clean up later.

Path traversal is refused at one choke point (quarantine::check_relative), allow-list shaped, called both by the caller and inside staging so the guarantee belongs to the module rather than to call-site diligence.

Attribution — license and origin in the lock#

The lock schema carries license and origin, Lock::upsert preserves them across a re-lock, and agentstack share reads them from the sender's lock onto each bundle entry so a receiver's card can show provenance. The one wire not yet connected is inbound capture: the production paths that build a LockedSkill (use/lock/add) still write license: None, origin: None, so a locally added skill records no attribution until that wire lands. What follows describes the carry-forward and share behaviour, which are live; treat "recorded per pinned skill" as the intended end state, not today's default for every add path.

Carried forward by Lock::upsert so an ordinary re-lock cannot erase it. NOTICE/LICENSE text travels with the content rather than being summarized into a tag.

The honest limit: this records what a source declared, and verifies none of it. A registry claiming Apache-2.0 gets Apache-2.0 written down. AgentStack does not check that the claim is true, that the publisher had the right to make it, or that the NOTICE text is complete. It makes the obligation visible and durable, which is strictly more than a promise and strictly less than a legal review.

Experimental tools_execute#

This is a separate, machine-opt-in mode with a narrower runtime surface than a whole harness sandbox. It is available only in builds with the sandbox feature and has no host fallback.

PropertyStatusMechanism and honest limit
Project identityenforcedCurrent project digest must be in the trust store before files, Docker, relay, or upstream dispatch. Trust covers AgentStack manifest layers/lockfile, not every arbitrary repository file.
EnablementenforcedOnly [experimental] tools_execute = true in the machine manifest is consulted. The same table in a repo cannot enable it.
Tool authorityenforcedImmutable, exact namespaced grant; per-run authenticated relay checks membership and count; the existing gateway re-applies compiled machine ∩ project tool policy. Allowed tools can still have side effects.
SecretsenforcedNo resolved secret, gateway environment, or relay credential appears in guest env/result/events. Upstream processes still receive secrets that their declared server configuration authorizes.
Filesystem readenforcedGuest sees only a private read-only /app mount containing source, JSON input, bootstrap, generated bindings, and relay token. The policy ruleset is mounted only into the sidecar. The guest does not receive workspace, AgentStack home, Docker socket, or host home mounts. Container/kernel escape is outside this claim.
Filesystem writeenforcedRead-only root and /app; only a 16 MiB noexec,nosuid,nodev /tmp tmpfs and one pre-created result-file bind are writable, and both are capped by the kernel. See the write-cap note below the table for the two mechanisms and their limits.
Direct egressenforcedInternal Docker network has only the egress sidecar as peer. Its ordinary proxy requires an undisclosed separate token; the fixed raw relay reaches only the host execution relay. The host relay binds the narrowest interface the sidecar can still reach via host.docker.internal: the private, non-routable docker0 bridge gateway on a native Linux daemon, or the host loopback on Docker Desktop — never a LAN-facing interface. It stays reachable from Docker containers on the host (not from other LAN hosts). Where that narrow address is unknown or unbindable — a Linux host whose docker0 gateway could not be determined, a Linux host that cannot bind the gateway it chose (Docker-Desktop-on-Linux, whose gateway lives in the VM), or any platform that is not linux/macOS/Windows — the execution refuses to start instead of widening to 0.0.0.0, and the refusal names AGENTSTACK_RELAY_BIND. That variable is the only route to a wildcard bind, and AGENTSTACK_RELAY_BIND=0.0.0.0 is the operator accepting a LAN-reachable relay on purpose. Its random token, exact grant, bounded protocol, and execution-scoped lifetime are the control. No payload/content inspection occurs on allowed tool results.
Process isolationenforcedNon-root uid/gid 65532, capabilities dropped, no-new-privileges, 128 MiB memory, one CPU, 32 PIDs, 4 MiB max file size. Docker's configured/default seccomp policy, Docker itself, and the host kernel remain trusted computing base; AgentStack does not yet ship a custom executor seccomp policy.
LimitsenforcedMachine-owned timeout, output, and call defaults are configurable only below compiled hard ceilings; requests may only narrow them. Aggregate stdout/stderr and separate result/source/input bytes, granted-tool count, and relay call count are bounded. A tool call already dispatched upstream cannot be revoked atomically.
RecordingenforcedRun log creation is required. Events store digests and metadata, never source/input/result/secret values; tool calls carry execution IDs and render beneath the execution in agentstack report run. Recording is evidence, not tamper-proof remote attestation.
Runtime supply chainpartialNode image is pinned by repository digest. AgentStack does not yet publish an executor-specific SBOM, attestation, or independent scan, so the feature remains experimental.

The two write caps. The tmpfs size caps that mount. A bind's bytes land in the host inode and no mount option bounds them, so the result file is bounded at the writer instead: a 4 MiB RLIMIT_FSIZE (--ulimit fsize=, soft == hard) covering every file in the container. A write past it fails with SIGXFSZ, and with capabilities dropped and no-new-privileges set the guest cannot raise its own hard limit. The 1 MiB MAX_RESULT_BYTES is a separate host-side read refusal applied afterwards: an oversized result is rejected as invalid, never truncated. The write cap is four times the read refusal, so it can never clip a result the host would have accepted. The cap is per file, not aggregate; total host-disk exposure per execution is bounded because the bind is the only writable host path.

See also#

Source of truth: docs/ENFORCEMENT.md — this page is generated from it.