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 — 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 rules

Static 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):

PrimitiveThe question it answersWhat it does not do
BundleWhat intent is declared? agentstack.toml names servers, skills, toolsets, and requested policy.Does not certify the referenced code as safe.
IntegrityWhich capability bytes were reviewed? agentstack.lock pins resolved inputs; signatures can attest to the lock bytes.Does not grant local consent to execute.
SelectionWhat does this task need? A toolset names the intended server and skill set.Does not decide how the harness receives it.
DeliveryHow 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.
ConsentMay this repo auto-activate here? agentstack trust binds local approval to manifest + overlay + lock.Does not mean "safe to run unsandboxed."
AuthorityWhich tools, hosts, secrets, and paths are allowed? Machine and project policy intersect, deny wins.Does not create process isolation by itself.
IsolationWhere may the process run and connect? Sandbox and lockdown provide the runtime boundary.Allowed destinations can still receive sensitive data.
EvidenceWhat 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.

KindLaneWhy
Skills · MCP servers, on an MCP-capable harnessdynamicbrokered, policy-checked, digest-verified per load, recorded
Instructions (house rules)renderedno live channel a harness is known to consume can carry an instruction per model or behind a lease (docs/design/instruction-variants.md)
Settingsrenderedonly a native file carries them
Hooks · extensionsrenderedexecutable kinds: they run code, so they are always written and always carry the full consent ceremony
Any kind, on a harness with no live channelrenderedthere 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:

SituationUseWhy
Materialize the rendered laneapply --write / use --writeExplicit, non-executing render; refused on an untrusted or drifted project.
Narrow one live connection to part of a manifestToolset leaseSmallest live surface, nothing rendered; policy and audit stay on the path.
Rendered files, clean between sessionssession start/endA lifetime contract over the rendered lane, with an explicit restore step.
Files required where the live channel would have servedmore delivery render-locallyThe one delivery override, recorded in the manifest so every clone reads the same.
Unfamiliar repositoryTrust gate firstSelection must never grant consent; unreviewed auto-project bundles stay inert.
High-risk code or strict egressPolicy + lockdownPolicy defines authority; lockdown removes direct routes and confines the process.
CIinstall --locked + doctor --ciChecks 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:

toml
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:

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:

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):

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:

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:

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:

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      → everything

trust → 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.