AgentStack — Architecture
Current as of agentstack 0.19.0.
For contributors and architects. If you just want to use AgentStack, start with the README and the getting-started walkthrough.
Contents
Vision#
AgentStack packages, runs, and governs AI agents as trusted, portable bundles: skills, tools, MCP servers, and ephemeral generated capabilities.
The manifest is the unit. Every other part renders, gates, constrains, or records it. Portability across agent CLIs is the product: import once, render everywhere, switch by task, recover safely. The trust gate, the policy ceiling, and the call record are what make that portability safe, so portability does not add its own risk.
Two words, two things. The manifest is the reviewed config file: what the README, the walkthrough, and the how-tos mean by that name. A bundle is the manifest, its optional local overlay, and the resolved lockfile taken together as one consent unit: what gets declared, pinned, trusted, and distributed, and what agentstack more share signs into a .astack for receive to review (see ENFORCEMENT.md).
Core principle: nothing executes automatically until its content is trusted; governed execution is constrained and recorded.
Where this starts#
The current implementation is an eleven-crate Rust workspace. It ships the manifest and lock resolver, 13 CLI adapters, a capability library of linked source folders, content-bound trust, machine-first policy, a single-dispatch MCP gateway, Docker sandbox and lockdown runtimes, egress enforcement, per-run recording, an experimental frozen-plan executor, and a self-contained workflow engine. This document describes the current boundaries. ../STRATEGY.md defines the gated direction, and ../TODO.md names the current work.
The flow#
manifest + library → resolve + lock → adapters → native CLI config
│
└→ trust → policy → gateway/runtime → recorder
↑
machine rulesStatic config rendering and governed execution are sibling paths, and both are behind the trust gate. apply is still an explicit, non-executing render operation, but apply --write and use --write refuse an untrusted or drifted project rather than writing native config or materializing skill files. The exemptions are stated rather than implied: the machine layer, and the inert direction (removal and prune plans).
Generated code follows the same path through a policy-agnostic execution domain: the CLI freezes an exact tool grant and limits into an immutable plan; the executor runs it inside the sandbox; and every capability call returns to the existing gateway. The executor never reads or interprets policy. The gateway remains the sole tool authority, the runtime owns isolation, the egress crate owns asynchronous relay transport, and the recorder owns evidence.
A bundle arrives (cloned, pulled, copied) and is inert by construction. agentstack trust displays its declared runtime surface, verifies its lock, and pins the current manifest/local/lock digest in the machine-local trust store. At run time, the policy engine intersects the bundle's requested policy with the machine's rules and compiles the effective ruleset. In sandbox mode the CLI's configured HTTP(S) traffic goes through the enforcing proxy; lockdown makes the proxy sidecar topologically the only route out. Lifecycle, limit, egress, brokered tool-call, and secret-reference events enter the per-run log.
Operating model — one question per boundary#
Three questions are independent of each other. A toolset answers which capabilities does this task select? Delivery answers how does each selected capability reach the agent?, and the system answers that one, per capability kind per harness, rather than offering it as a setting. The lifetime answers when does it go away? Selection is not delivery, and delivery is not isolation.
Each row in the table below answers exactly one question and does not answer the others. Mixing them up is the common category error (a lock is not trust, trust is not policy, policy is not a sandbox, audit is not enforcement):
| Primitive | The question it answers | What it does not do |
|---|---|---|
| Bundle | What intent is declared? agentstack.toml names servers, skills, toolsets, and requested policy. | Does not certify the referenced code as safe. |
| Integrity | Which capability bytes were reviewed? agentstack.lock pins resolved inputs; signatures can attest to the lock bytes. | Does not grant local consent to execute. |
| Selection | What does this task need? A toolset names the intended server and skill set. | Does not decide how the harness receives it. |
| Delivery | How does selection reach the harness? Routed into one of two lanes: served live through the gateway, or written into native config files. | Does not confine the agent process, and is not a per-project mode anyone picks. |
| Consent | May this repo auto-activate here? agentstack trust binds local approval to manifest + overlay + lock. | Does not mean "safe to run unsandboxed." |
| Authority | Which tools, hosts, secrets, and paths are allowed? Machine and project policy intersect, deny wins. | Does not create process isolation by itself. |
| Isolation | Where may the process run and connect? Sandbox and lockdown provide the runtime boundary. | Allowed destinations can still receive sensitive data. |
| Evidence | What happened? Call audit, run reports, and analysis record brokered activity. | Recording is not prevention. |
Delivery is routed, not chosen#
Delivery has no mode switch. The planner (crates/cli/src/delivery.rs) takes two facts, a capability's kind and the harness it is going to, and routes it into one of two lanes: the dynamic lane, served live through the gateway, or the rendered lane, written into that harness's native config files. That is the whole decision. The planner is a pure function over the manifest's [delivery] table and the adapter registry: it decides and states the routing, and never writes.
| Kind | Lane | Why |
|---|---|---|
| Skills · MCP servers, on an MCP-capable harness | dynamic | brokered, policy-checked, digest-verified per load, recorded |
| Instructions (house rules) | rendered | no live channel a harness is known to consume can carry an instruction per model or behind a lease (docs/design/instruction-variants.md) |
| Settings | rendered | only a native file carries them |
| Hooks · extensions | rendered | executable kinds: they run code, so they are always written and always carry the full consent ceremony |
| Any kind, on a harness with no live channel | rendered | there is nothing to serve it over |
A project is normally in both lanes at once. Rendering is therefore not a legacy path being removed. It stays the only correct answer for what no live channel can carry, and for harnesses that have none.
The single override is render locally ([delivery] render_locally, per project or per harness), which forces the rendered lane where the live channel would have worked: offline operation, deterministic native files, inspection with ordinary filesystem tools, a rule against a persistent background process, or compatibility testing against a harness's own behaviour. It moves capabilities only towards files, declares no capability, and changes neither trust nor authority. There is no "prefer gateway" counterpart and no per-project mode: the Mode axis was retired, set-mode refuses and explains, and set-mode-v1 sits under SUPERSEDED in the UI contract. The names static, clean-at-rest and zero-files survive only as readings of a project's current shape (reference.md — delivery).
Both lanes sit behind the same gates. An untrusted or drifted project delivers through neither: the gateway serves nothing, and apply --write / use --write refuse rather than render.
The boundaries an operator does still choose:
| Situation | Use | Why |
|---|---|---|
| Materialize the rendered lane | apply --write / use --write | Explicit, non-executing render; refused on an untrusted or drifted project. |
| Narrow one live connection to part of a manifest | Toolset lease | Smallest live surface, nothing rendered; policy and audit stay on the path. |
| Rendered files, clean between sessions | session start/end | A lifetime contract over the rendered lane, with an explicit restore step. |
| Files required where the live channel would have served | more delivery render-locally | The one delivery override, recorded in the manifest so every clone reads the same. |
| Unfamiliar repository | Trust gate first | Selection must never grant consent; unreviewed auto-project bundles stay inert. |
| High-risk code or strict egress | Policy + lockdown | Policy defines authority; lockdown removes direct routes and confines the process. |
| CI | install --locked + doctor --ci | Checks reproducibility, policy, drift, and content without interactive trust. |
agentstack more delivery states the routing per harness, and --json is the same reading for a UI (delivery-routing-v1). The reader-facing versions of this section are concepts.md — delivery and how capabilities reach your CLIs.
Product boundary and non-goals#
Keep AgentStack narrow: it should own the portable security contract, which declared or generated capability may run, with which tools and secrets, inside which boundary, and with what evidence afterward. It is the control plane beneath assistants rather than an assistant itself. Personal memory, values, durable assistant state, OAuth onboarding, and user-facing apps are the province of assistant products layered on top; AgentStack does not add them. Background jobs and schedules are a possible later layer, not part of the current control-plane mission. Content-addressed library packages are the intended forward path for persistence: a successful tools_execute run can be promoted into a reviewed library entry that re-enters the existing lock, review, trust, signing, and distribution lifecycle, turning an ephemeral generated capability into a governed, distributable one.
Design lineage: the capability-layer framing behind tools_execute — discovery over disclosure, generation-then-determinism, primitives over workflows — draws on Adam Jones's "I was wrong about MCPs"; the diagrams and recommendations here are original to this repository.
Layer 1 — The bundle (crates/core)#
A bundle is a directory. It is declarative and inert: pure data, nothing executes.
my-agent/
.agentstack/
agentstack.toml # preferred manifest
agentstack.local.toml # optional gitignored overlay
agentstack.lock # resolved, content-pinned inputs
instructions/ # instruction files
skills/ # skill directories (untrusted input)
extensions/ # native harness add-on code (executable; untrusted input)Capability kinds a manifest declares: servers (MCP), skills (inert text), instructions (compiled into CLAUDE.md/AGENTS.md), settings (native per-CLI config), hooks (declarative, compiled per-CLI), and extensions (native executable add-ons). Extensions are the highest-risk kind and the one agentstack governs only before delivery (see Layer 4).
Minimal agentstack.toml sketch:
version = 1
[servers.web-search]
type = "stdio"
command = "npx"
args = ["-y", "@example/search-mcp"]
env = { SEARCH_API_KEY = "${SEARCH_API_KEY}" }
[skills.summarize]
path = "./skills/summarize"
[instructions.team]
path = "./instructions/team.md"
[policy.tools]
web-search = ["*", "!*_delete"]agentstack.lock pins resolved server definitions, skill-directory content, instruction bytes, extension and workflow sources, and native settings to SHA-256 digests. Settings are pinned at the grain agentstack owns: one [[setting]] row per (target, key), checksummed over the canonical JSON of the value as declared with ${REF} unresolved. An undeclared key a user edited themselves can therefore never read as drift. That pin is a review signal, not a delivery gate: unlike an unpinned skill or instruction, a drifted settings key warns rather than refusing a render, because settings are inert config merged into a file the harness owns. Trust separately binds the manifest, local overlay, and lockfile into one consent digest. Detached ed25519 signing and verification of the lockfile are available as distribution tools.
Each pin has a matching deposit. The machine-local content store keeps a verbatim, content-addressed copy of the bytes a pin covers, which is what lets a re-review show which lines moved rather than only that the digest did. The deposit siblings are one per digest family, never one function with branches, so no kind's pin format can silently change: Store::pin (skill trees), pin_instruction (raw file bytes), pin_server_definition (one-file definitions), pin_settings_key (canonical settings JSON), pin_integrity_root (the strict integrity-root digest extensions and workflows use), and pin_blueprint (a workflow's approved blueprint, the instruction family with containment rules in front of it). Every deposit is best-effort and never blocks its pin; reads re-prove the address, and a lockfile predating a deposit degrades to an honest "the bytes you approved were not recorded" line rather than failing the project.
Key decisions:
- Skills and instructions are content-pinned like code because they can alter agent behavior. Inline skills cannot be trusted until they are lock-pinned; library server drift likewise blocks trust and governed execution.
- Secrets appear only as
${REF}placeholders, resolved through the chain process env → varlock → OS keychain (keyring) → project.env. varlock is the recommended vault: a project opts in with a.env.schema(whichinitoffers to write anddoctorhealth-checks), and that file carries names, never values. Resolution happens in memory at run time. Unresolvable secret → fail closed.
Layer 2 — Trust gate (crates/trust)#
Machine-local trust store: canonical project path → trusted consent digest + timestamp. Publisher signatures are verified separately from this local consent record.
The implemented states are untrusted and trusted. Before confirmation, agentstack trust summarizes the exact stdio commands, HTTP contacts, secret references, and skill pin status. Trust binds to the consent digest, so a manifest, local-overlay, or lockfile change re-gates automatically. Automatic project loading, experimental execution, and delivery all refuse untrusted content: the gate covers the five kinds apply and use deliver: servers, skills, instructions, hooks, and extensions.
Two rules qualify when the gate reads the store, and they are deliberately separate seams in crates/cli because they answer different questions:
render::prior_trust::PriorTrust— a command may not refuse itself.add --writeand the panel's edit verbs write the manifest and lockfile and then deliver in the same run, and those bytes are the consent digest, so a gate reading the store afterwards would refuse the delivery the human just asked for. The gate is therefore judged against the trust state as of command start, captured from a pre-write snapshot. It authorizes nothing new, since an untrusted or drifted project was untrusted or drifted before the command ran too, and it does not re-pin: the project is left readingChangedso the next command re-gates.PriorTrust::STRICTis theDefault, so a call site that captures nothing gets the unrelaxed gate. Hooks take no relaxation at all:render::hooksreads the store directly, because hooks always get the full consent ceremony.trust_carry::TrustCarry— a preference-shaped rewrite carries trust. A write that records only where declared capabilities land, such as[delivery] render_locallyor[meta] gitignore, moves the consent digest without moving the reviewed surface. Trust that was valid immediately before such a write is re-pinned across it. Four properties bound it: it never creates trust, never resolves a pending review, never blesses a race (the digest comes from the pre-write snapshot with the caller's own bytes spliced in, never a disk re-read), and it fails closed on any path that is not one of the two manifest layers.agentstack.lockis excluded structurally, because accepting content digests is a human's to give.
Invariant: changing any byte in the manifest/local/lock consent surface changes the trust digest. Changing lock-pinned skill, instruction, or library-server content fails lock verification until the project is deliberately re-locked and re-trusted.
Verification always hashes current bytes; there is no stat-fingerprint digest cache. Trust granting, lock verification, and governed execution, and skill content digesting specifically, read and hash the current bytes on every call. The mtime/size memoization that once accelerated skill-directory digests was removed: its only consumers were authoritative paths, where a same-stat content change (same size, restored mtime) could serve a stale digest and become a trust bypass. Reintroducing any stat-keyed digest cache on a verification path requires an explicit security review plus regression proof.
Principle: content identity and local consent are separate. The consent digest is content-shaped, but the trust decision is deliberately stored under the project's canonical path on one machine. Detached signatures provide the portable claim: a maintainer signs lockfile bytes, CI or a recipient verifies them, and the recipient still makes its own local trust decision. Hostnames and usernames never enter the content digest.
Honest limitation: the trust store and machine policy live under ~/.agentstack/, which is writable by the user, and in host mode the agent CLI runs as the user, so a compromised agent could modify them and self-trust a bundle. Only sandbox mode removes this. What ships today is the evidence half: every trust-store mutation appends an identity-only event (timestamp, action grant/regrant/repin/revoke, project key, digest, never content) to ~/.agentstack/audit/trust.jsonl. The append is best-effort and never gates the mutation, the file is 0600 and append-only by convention, and it is not tamper-evident: a compromised agent in host mode can still delete or rewrite it. It makes unnoticed self-trust harder, not impossible. See ENFORCEMENT.md for the exact per-mode enforcement status.
This layer must work standalone: valuable with no sandbox, no registry.
Layer 3 — Policy engine (crates/policy)#
Two inputs: the bundle's requested policy ([policy.*] in its manifest) and the machine policy — the [policy.*] tables of the machine-local ~/.agentstack/agentstack.toml manifest (TOML, loaded by manifest::machine_policy(); not a separate policy.yaml). The machine policy lives outside every repo's tree, so no repo content can alter it. But see the host-mode limitation in Layer 2: it is still a user-writable file.
Output: effective policy = intersection. Bundles can narrow, never widen. (The shipped machine-first [policy.tools] check is the v0 of this rule; It is now a general intersection engine with multiple dimensions.)
Four dimensions ship, each a top-level, name-keyed map, not nested under each MCP server entry in the manifest. Every one shares the same glob grammar: a plain pattern allows, a !-prefixed pattern denies, and the "*" key is rename-proof (it constrains every server regardless of what a manifest calls it, so a repo can't dodge a machine rule by renaming a server):
[policy.tools]— per-server tool allow/deny globs (policy.tool_allowed).[policy.egress]— per-server outbound host globs, optionally port-scoped ashost:port(api.example.com:443); a bare host means any port. The runtime proxy enforces the exact CONNECT port (policy.egress_allowed/CompiledRuleset::egress_decision).[policy.secrets]— per-server${REF}name globs (policy.secret_allowed).[policy.filesystem]— bundle-globalread/write/denypath globs (FsPolicy; no per-server split, because a sandbox mount is per-run, not per-server).read/writeare allow scopes;denyis a pure blocklist that no tool call may touch either way, and it is the one dimension whose layers UNION rather than intersect: a repo can add denies but never drop the machine's. It is matched against the workspace-relative path, the absolute path, and the bare file name, so".env*"catches a.envanywhere in the tree. Enforced by the host-mode hook guard (agentstack guard); sandbox mask-mount enforcement is a later session.
Tools, egress, and secrets are allow-by-default: an absent key constrains nothing. Filesystem writes are the deliberate exception on sandboxed paths: an absent effective write scope leaves the workspace read-only, as described below. Least privilege for the other dimensions is an explicit machine opt-in, e.g. [policy.tools] * = ["!*"] to deny everything unless a bundle's own allowlist narrows further. (No approval/confirm channel exists yet; a future "confirm before calling" tier is unbuilt work, not a shipped dimension.)
compile(machine, bundle, servers) folds both layers into a CompiledRuleset, the canonical, serializable artifact every enforcer consumes. It is lossless (each layer's allowlist is kept as an independent AND-bound, so tool_decision/egress_decision/secret_decision can still say which layer blocked a call) and rename-proof by construction ("*" folds into every named server plus an any bucket for unknown names). The in-process gateway consumes it for tool and secret decisions, while sandboxed runs serialize the same policy semantics into the enforcing egress proxy and runtime boundary. Keeping the artifact independent lets an enforcer change without rewriting the policy engine. The compiled ruleset is deliberately not part of the trust digest: one of its two inputs (machine policy) lives outside the pinned bundle by design, so folding it into the digest would create a second, machine-varying source of trust truth.
Enforcement honesty, per dimension (today). The authoritative mode-by-dimension matrix, with every caveat, lives in ENFORCEMENT.md. The policy-engine summary:
- Tools — enforced at the gateway (Layer 4's single enforcement point).
- Secrets — enforced fail-closed, at both adapter render and the gateway's per-server resolver.
- Egress — enforced on proxied traffic under sandbox/lockdown (exact CONNECT
host:port); coarse and declared-host-only at write time in host mode. - Filesystem — write scope enforced coarsely by the sandbox
:romount (deny-by-default,CompiledRuleset::workspace_write_decision); host mode enforces neither.
Invariant (property-tested): for all bundle policies B and machine policies M, effective(B, M) ⊆ M, across every dimension. This test is never deleted or weakened.
Layer 4 — Runtime (crates/adapters, crates/runtime, crates/egress)#
Adapters render a bundle into native config for each supported agent CLI (Claude Code, Cursor, Codex, …). Normal rendering is one-way and non-destructive; explicit init, adopt, and owned-server workflows can read native state back into the manifest. The 13 adapters are data-driven YAML descriptors, and writes stay blocked while any ${REF} is unresolved. Resolution completes before the renderer runs: render receives a concrete server and a resolver, never a library or store to consult, which is what lets a sandbox runtime materialize configs from core + adapters alone. One trust note, stated plainly: user drop-in adapter descriptors (~/.agentstack/adapters/) are part of the trusted computing base: they alter how configs render and are trusted because the user placed them, unlike bundle content, which is hostile. Inside a container that dir is simply absent, which is expected and correct.
Native extensions are the one capability agentstack delivers but does not govern at runtime: [extensions.*] code (pi .ts, OpenCode .js) executes inside the harness process at full user permission, outside every ceiling below. The design draws the honest line at delivery. The source is pinned in agentstack.lock with the strict integrity-root digest, so a byte change re-gates trust; apply renders fail-closed (an untrusted or drifted project writes nothing) by copying, never symlinking, the pinned bytes into the target harness's extension directory, so the harness loads exactly the reviewed bytes rather than whatever a later source edit leaves behind. A per-directory ownership ledger scopes pruning to what agentstack placed, and a hard deny-list keeps the renderer from ever authoring, overwriting, or pruning the host guard's reserved agentstack-guard* artifacts. A protected run (the default) re-verifies each delivered copy against its pin before launch. What this buys is provenance and content binding, not runtime enforcement; the trade-offs, the staging, and the enforcement limits are recorded in ENFORCEMENT.md.
The four runtime modes (host, gateway, sandbox, lockdown) enforce different dimensions to different depths; ENFORCEMENT.md is the authoritative per-cell matrix. This section describes the mechanisms behind it.
Host mode: adapters write configs onto the bare machine. Honest framing: advisory enforcement. A static apply is trust-gated at the write choke point, and render-time policy plus fail-closed secret checks govern what gets written, but once the bytes are on disk they are the harness's to execute. A CLI on the host can still bypass that config and could in principle tamper with the trust store itself (Layer 2). What each dimension actually enforces on this path is in ENFORCEMENT.md.
Single enforcement point (declared, not just observed): every MCP tool call agentstack itself brokers (the gateway serve loop, the agentstack mcp bridge, code mode) dispatches through one function, Gateway::try_call, which consults the policy engine before any upstream I/O; the upstream transport is private to it, so no other module can reach a server directly. Any new brokered path must route through it. Adding a second dispatch path is a security-review event, not a refactor. (The rendered lane hands the transport to the harness itself and is governed at write time, the advisory framing above.)
One enforcement-plan boundary for a sandbox run: run --sandbox assembles its security model in exactly one seam, ExecutionPlan::build: it checks trust, compiles the effective (machine ∩ bundle) policy, resolves the mounts + command, and picks the egress mode, returning one immutable plan. A command then executes that plan (which creates the fail-closed run log and the per-run proxy token once, then dispatches to the mode) or displays it (--plan: a Docker-free dry run that names the trust state, mode, and exact command). The mode executors no longer re-derive any of it, so a run can't skip a check by taking a different path, the same discipline as the single gateway dispatch, applied to run assembly.
Sandbox mode: agentstack run --sandbox launches the CLI in a container via the Docker API (bollard). Its configured HTTP(S) traffic is pointed at the egress proxy, which enforces the compiled ruleset and emits one event per decision (allow/block, host, server, tool). Two confinement strengths ship:
--sandbox(host-process proxy): the container gets an ordinary bridge network and itsHTTPS_PROXYpoints at a proxy on the host (host.docker.internal). This enforces the agent's configured egress and gates anything reachable only via the proxy, but a container that ignored the proxy env could still reach the open internet directly. The listener necessarily binds a broad address so the container can reach it, so the peer is authenticated: a per-run random token rides in the proxy URL's userinfo and the proxy 407s any CONNECT that doesn't present it. The token, not the bind address, is what stops a LAN neighbor from using the proxy as an open relay (and the same token authenticates the sandbox to the lockdown sidecar).--lockdown(no direct route): the container is attached ONLY to an internal Docker network with no host route, no internet, and no DNS beyond it, whose single reachable peer is the egress-proxy sidecar container (docker/egress-proxy.Dockerfile, theegresscrate's binary). The sidecar is dual-homed onto a second ordinary network so it (and only it) forwards allowed traffic out. Ignoring the proxy env then reaches nothing: the confinement is topological, not convention. The ruleset crosses the process boundary as a serializedCompiledRulesetthe sidecar fails closed on if its version is newer than the binary understands. Both modes are Docker-verified end to end through the real binary (sandbox_egress,sandbox_cli_e2e,sandbox_fs,sandbox_lockdown,sidecar_image).
D4 also makes the gateway the sole MCP authority under lockdown. The run resolves and pin-verifies one frozen server set, gives that exact set to the gateway, and compiles every normalized declared HTTP host into the ruleset's gateway_only_hosts. That fence wins over ordinary egress allows; direct connections to declared MCP hosts are blocked on every port while stdio servers stay host-side. Literal-IP and non-TLS tunnels are refused. Partial, drifted, or unclassifiable resolution fails the run, and an adapter whose gateway config or native shadows cannot be installed is refused rather than given a direct rendered-config fallback. The precise ceiling is the declared normalized endpoints: AgentStack does not discover every undeclared DNS alias the same upstream service may operate.
The egress proxy has five known-hard sub-problems, listed below:
- Per-server attribution: attributing egress to a specific MCP server requires one proxy identity per server (distinct ports, containers, or credentials), not one shared funnel.
- HTTPS filtering (enforced): the proxy decides on the CONNECT authority and, once TLS starts, requires the ClientHello's SNI to match that host, so a client can't tunnel to an allowed front and then ask for a denied host behind it (domain fronting). No TLS interception/MITM. Hostnames are normalized (lowercase, trailing dot stripped) before matching so casing can't dodge a deny.
- Anti-SSRF (enforced): an allowed name can still resolve to the host's own network. The proxy resolves once and requires every resolved address to be global unicast, refusing loopback, private, link-local (incl. the
169.254.169.254metadata IP), unique-local, and reserved ranges, then dials the validated address (no second resolution, closing DNS rebinding). Literal-IP CONNECTs flow through the same check. Tests/demos that dial the host gateway opt out viaAGENTSTACK_ALLOW_LOCAL_TARGETS; production never sets it. - DNS is itself an exfiltration channel and needs to be routed and filtered, not left open: the container resolves nothing directly; the proxy resolves only allowed names.
- Peer authentication (enforced): the listener must bind a broad address so the container can reach it, so a per-run token, minted by the CLI and injected as the sandbox's
HTTPS_PROXYcredentials and into the sidecar's env, is what authenticates the peer, not the bind. A CONNECT without validProxy-Authorizationgets a 407 and is recorded, so the proxy can't be used as an open relay by anything else that can route to it.
Scope honesty — exfiltration through allowed channels: even a perfectly enforced allowlist permits traffic to allowed hosts, including the model API itself. A prompt-injected agent can leak data through any host the policy allows. AgentStack's claim is untrusted declarations are not auto-activated and unapproved egress is blocked on the enforced paths, not that exfiltration is impossible.
(The shipped agentstack proxy token-observation relay is unrelated to this crate and keeps its name; the enforcement crate is egress.)
Design references (not dependencies): Sandcastle, an external executor that runs multi-agent loops on their own git branches, has a provider model, branch strategy, and event hooks that are good prior art for orchestration shape.
MCP protocol compatibility#
As of 2026-08-11, crates/mcp is the single protocol boundary for AgentStack's local stdio server, sandbox HTTP bridge, and upstream HTTP/stdio clients. It uses RMCP 3.1.2 for models, validation, framing, lifecycle negotiation, and Streamable HTTP rather than maintaining a second hand-written wire stack.
The 2026-07-28 path is stateless: it uses server/discover, returns the modern tool result and cache fields, creates no HTTP protocol session, never asks the client for roots, and re-derives the trusted default toolset per request. Connection-hidden lease mutations are refused on this path. The dated 2025 path keeps initialize, legacy HTTP sessions, roots, and process-local leases for released clients and older upstream servers. Both eras enter the same trust, policy, gateway-dispatch, and audit code.
Layer 5 — Flight recorder (crates/recorder)#
Append-only, per-run JSONL records execution start/finish/limits, sandbox lifecycle, egress decisions, brokered tool calls (with argument digests), and secret-reference access (reference names, never values). agentstack report run <id> renders that evidence for humans or JSON consumers and can supplement older runs from the separate global call audit log.
Trust-store mutations have their own stream: crates/trust appends one identity-only event per store mutation (timestamp, action, project key, digest) to ~/.agentstack/audit/trust.jsonl, inside the store lock and only after the store write succeeds, so log order is store order and an event always describes a mutation that happened. It is deliberately unrotated: the consent metrics count over the full history. Per-run token/cost events are still not wired. All of this JSONL is append-only by convention, not cryptographically tamper-evident; a compromised host-mode agent can delete it.
Scope discipline: the recorder is a log plus a viewer. It is not an observability platform.
Layer 6 — Registry (evidence-gated future)#
Push/pull of signed bundles. The trust gate verifies signatures against publisher keys; content-pinning and review flow are inherited unchanged. Starts life as a curated Git repository of signed bundles: no infrastructure until demand proves it.
Crate dependency rules#
Exact internal edges (anything not listed is forbidden):
core → (nothing)
trust → core, recorder
policy → core
recorder → core
adapters → core
mcp → (nothing internal)
runtime → policy, recorder
egress → core, policy, recorder
executor → (nothing)
workflow → (nothing)
cli → everythingtrust → recorder is the one edge added for trust-mutation evidence (P0.2). It keeps trust a small review boundary: recorder depends on core alone and forbids unsafe code, so the edge adds no new external dependency and no new authority. The recording call sits inside the store lock, immediately after the successful save, which is what makes evidence unskippable by construction and makes log order equal store order. The alternative, a callback the caller supplies, was rejected: it is skippable by construction, and a mutation path that forgets to record would be silently unwitnessed.
runtime lost its core edge on 2026-08-12: it never named a core type. It is the container-lifecycle crate: it takes an already-decided policy and an already-opened recorder and runs a sandbox, so core's manifest and lockfile vocabulary is the composing cli crate's business, not its own.
mcp is the MCP protocol boundary, and holds no internal edges by design. It owns the wire: RMCP supplies models, validation, framing, lifecycle negotiation, and Streamable HTTP, and this crate wraps all of it behind a Backend trait that speaks plain JSON values. That is the whole point of the crate existing separately: protocol SDK types stop here and never reach trust, policy, or manifest code, so an RMCP version bump can never become a change to what AgentStack consents to or enforces. It is a compatibility boundary, never an enforcement one: every request it decodes is handed to the same trust, policy, gateway-dispatch, and audit paths a local call takes (see "MCP protocol compatibility" under Layer 4).
executor holds no internal edges: it is a self-contained, policy-agnostic domain built on serde/serde_json/sha2/thiserror, and the cli crate is what composes it with the runtime and recorder. That is why it "never reads or interprets policy": it structurally cannot.
The experimental execution boundary is specified in the tools_execute threat model and runtime/ownership ADR. Its user-visible claims are intentionally narrower than this architecture description and live in ENFORCEMENT.md.
adapters deliberately does not depend on policy: the fail-closed secret check happens before render, in the caller. Re-granting that edge is a deliberate architecture change, not a Cargo.toml edit.
core depends on nothing internal; nothing depends on cli. trust and policy are the security-critical crates: they depend on core only, stay as small as possible, carry #![forbid(unsafe_code)], keep the restricted dependency list (see the ground rules in CONTRIBUTING.md), and their property-tested invariants are human-reviewed line by line.
Source of truth: docs/ARCHITECTURE.md — this page is generated from it.