Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Aegis is a research instrument for secure agent tool execution, built in Rust on wasmtime. It sits underneath agent frameworks — it is not an orchestrator, not a dashboard, not an LLM layer.

A reproducible runtime for testing what agent tool isolation actually guarantees.

The goal is not to assert that agent tools are safe. It is to make the isolation claims falsifiable: a pipeline you can run, a malicious guest you can point at it, benchmarks you can reproduce, and a threat model that names its own gaps.

Hypothesis

A default-deny, capability-grant-driven, per-call WASM sandbox with mandatory audit can contain an adversarial or prompt-injected tool call such that no single mistake — a forgotten host check, a malformed policy, a panicking host function — escalates into ambient host authority.

That is a design goal to be measured and attacked, not a guarantee. See the threat model for what is in scope, what is explicitly not, and where the honesty boundaries are.

What this book covers

The quickstart shows one policy refusal, from a clone of main, against Aegis’s own gateway standing in for any stdio server. The published 0.3.0 binary cannot wrap. Default wrap is still evidence, not a firewall: without --policy it blocks nothing, and a refusal under --policy is tool identity, not a sandbox. --confine (Linux) is a separate opt-in.

Current release

Published crates on crates.io are 0.3.0 (eight crates). Two things are newer than that tag and only exist on main:

  • botzr-aegis-wrap, a ninth in-tree crate
  • every aegis subcommand except run — keygen, verify, recheck, and wrap all landed after 0.3.0 was cut

Build from source for those. See Install.

Building this book

From the repository root:

cd docs && mdbook serve    # http://localhost:3000
cd docs && mdbook build

Both write to target/book/. That is build-dir in book.toml, not a default: the book’s src is docs/ itself, so an output directory inside docs/ would be copied back into the next build as a nested duplicate.

mdBook 0.5.4 is what CI pins, and the same pinned build publishes this book to https://botzrdev.github.io/aegis/ on every push to main.

Install

From crates.io (0.3.0)

cargo install botzr-aegis-cli

The published 0.3.0 binary has one subcommand: run (plus the bare-invocation ready banner). keygen, verify, recheck, and wrap all landed after the tag was cut and are only on main. If you want the evidence verbs, build from source.

0.3.0 also predates the current record format. It writes schema_version: 1: unsigned lines with no seq / prev_hash chain. The hash-chained, ed25519-signed schema v2 this book describes — and the aegis verify that walks it — are main only, and arrive together in the next cut.

Library consumers:

[dependencies]
botzr-aegis-runtime = "0.3.0"

Standalone sandbox (no orchestrator):

[dependencies]
botzr-aegis-sandbox = "0.3.0"
botzr-aegis-core = "0.3.0"

See INTEGRATION.md.

The 0.3.0 tarballs on crates.io are MIT as published. The repository and every release cut after 0.3.0 are dual Apache-2.0 OR MIT (ADR-0011).

From source

git clone https://github.com/botzrDev/aegis
cd aegis
cargo build -p botzr-aegis-cli

main is where keygen, verify, recheck, and wrap live. MSRV is 1.86.

This is the only way to reach aegis wrap --policy — the opt-in under which wrap refuses a denied tools/call instead of relaying it (ADR-0015). cargo install botzr-aegis-cli gets you the 0.3.0 run-only binary above and is not a substitute; the wrap walk is in the quickstart. Two in-tree crates back it — botzr-aegis-wrap and botzr-aegis-confine — and neither is on the registry at 0.3.0; both first appear on the next cut, which publishes ten crates.

The binary itself needs no WASM target — it depends on no fixture crate. Add one before running the test suite or the quickstart, which do build wasm32-wasip2 guests:

rustup target add wasm32-wasip2

What ships, per platform

Everything below is on main. Native OS confinement is Linux-only (ADR-0010), which is why that qualifier travels beside any containment claim rather than sitting in a footnote.

LayerLinuxmacOS
Policy evaluation — tool id, role and capability axesyesyes
Capability grants — default-deny, minted per callyesyes
Model A cell — a per-call wasmtime Store built from the grantyesyes
Signed, hash-chained record (schema_version: 2) on every exit pathyesyes
aegis verify — pinned or unpinnedyesyes
aegis recheck — executes nothingyesyes
aegis wrap --policy — refuses a tools/callyesyes
aegis wrap --confine — Landlock fs + a seccomp network deny-listyesno

Not shipped on either platform, and deliberately absent from that table: matching a call’s arguments (AILAB-626, canceled — a rule matches tool identity and nothing else), parking a call for human approval (AILAB-629), pinning a tool schema by hash (AILAB-627), and Landlock AccessNet (AILAB-810). macOS Seatbelt confinement is AILAB-630.

MCP gateway

cargo install botzr-aegis-mcp

This binary serves Aegis’s own catalog (echo / exfil) over stdio. It is not an interposer in front of someone else’s server. For that, see aegis wrap (from source, until the next crates.io cut) — the quickstart wraps this gateway as a stand-in for any stdio MCP server, with a separate record file for each end.

The published 0.3.0 gateway takes --policy and --audit only. It has no --signing-key, so the signed-record workflow in the MCP gateway chapter needs a source build — that chapter’s What 0.3.0 does instead spells out the difference.

Quickstart

Requires Rust (MSRV 1.86) with the wasm32-wasip2 target:

rustup target add wasm32-wasip2

The WASM fixtures used below are committed, so you do not need cargo-component to run the pipeline. You need it only to rebuild fixtures, which the adversarial demo does:

cargo install cargo-component

From a clone of botzrDev/aegis:

cargo test --workspace

One Model A call

A persistent record file is signed by a key you provision — once, per host.

cargo run -p botzr-aegis-cli -- keygen --out /tmp/aegis-signing.key
# stdout: public_key <hex> / key_id <hex>

cargo run -p botzr-aegis-cli -- \
  run \
  --component tests/fixtures/echo-tool/echo.wasm \
  --id echo \
  --input 'hello' \
  --audit /tmp/aegis-audit.jsonl \
  --signing-key /tmp/aegis-signing.key
# stdout: hello

Then pin the record. aegis verify distinguishes pinned from unpinned — a bare “Verified” without saying which is an overclaim (ADR-0004):

cargo run -p botzr-aegis-cli -- \
  verify --key <public_key printed by keygen> /tmp/aegis-audit.jsonl
# Verified (pinned to <key_id>)

Without --key / --trust-store the same file reports Verified (unpinned): internal consistency only, not provenance.

Adversarial demo

./scripts/build-fixtures.sh
cargo test -p aegis-adversarial-demo

A deliberately malicious wasip2 guest — write-under-readonly, .. traversal, symlink escape, HTTP exfil — all refused through Runtime::execute_tool_call.

Wrap a child, and refuse one call

aegis wrap interposes on an existing stdio MCP session. By default it relays everything and records every tools/call; --policy <YAML> is the opt-in under which a denied call is refused before it reaches the child (ADR-0015).

The child below is Aegis’s own MCP gateway standing in for any stdio MCP server — substitute yours after the --. Both halves need a clone of main: the published 0.3.0 binary has neither wrap nor keygen (Install).

cargo build -p botzr-aegis-cli -p botzr-aegis-mcp --release

cat > /tmp/deny-echo.yaml <<'EOF'
version: 1
default: allow
rules:
  - id: deny-echo
    action: deny
    tool: echo
    reason: "quickstart"
EOF

./target/release/aegis keygen --out /tmp/aegis-signing.key
# stdout: public_key <hex> / key_id <hex>

printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}}' \
  | ./target/release/aegis wrap \
      --audit /tmp/wrap-audit.aarl \
      --signing-key /tmp/aegis-signing.key \
      --policy /tmp/deny-echo.yaml \
      -- ./target/release/botzr-aegis-mcp \
           --audit /tmp/gateway-audit.aarl \
           --signing-key /tmp/aegis-signing.key
# client stdout is the refusal, not the echo body:
# {"error":{"code":-32042,"data":{"aegis":{"code":"POLICY_DENIED","layer":"wrap"}},
#  "message":"aegis wrap refused this tools/call"},"id":1,"jsonrpc":"2.0"}

./target/release/aegis verify --key <public_key printed by keygen> /tmp/wrap-audit.aarl
# Verified (pinned to <key_id>)

Run it again without --policy and the same pipe prints the echo result: default wrap blocks nothing, and that is the only difference between the two invocations on the client stream.

Two record files, on purpose. Wrap records the call it carried, the gateway the call it executed; one path for both would interleave two Chains and neither would verify. After the run above, /tmp/gateway-audit.aarl holds no call at all — the refused frame never reached the child — while /tmp/wrap-audit.aarl holds an intent and an outcome naming deny-echo as the matched rule, with the real Policy Set hash rather than the pass-through stand-in a relayed call carries. Records carry the .aarl extension (ADR-0014).

Drop --key here and the same walk reports Verified (unpinned) — the same distinction as in the Model A beat above, and for the same reason.

What this is not

This quickstart needs the repository (fixtures, scripts, and — for the wrap beat — a source build, because wrap and keygen are not in the published 0.3.0 binary). What it shows is a refusal by tool identity: --policy never looks at params.arguments, mints no capability, and puts nothing inside wasmtime, so a wrapped child is closer to Model B than Model A and weaker than either unless --confine (Linux) is also given. A chain that abuses a legitimate tool on the same server is not blocked here. aegis wrap lists the rest before you describe it as a sandbox, a firewall, or a guard.

The pipeline

Every tool call walks the same four stations, in this order (load-bearing — audit wraps the inner three):

POLICY → CAPABILITY → SANDBOX → AUDIT
StationRole
PolicyRole gate, approval gate, rate limits — sync eval over a parsed-once, immutable PolicySet the engine holds in an ArcSwap for hot reload
CapabilityDefault-deny manifest resolution → minted grant; a denial never reaches the sandbox
SandboxConfigure a per-call wasmtime Store from the grant, then run (cap-std preopens; epoch + memory limits)
AuditSchema-versioned record emitted on every exit — allow, deny, trap, resource cap, or panic — with no raw secret payloads

Do not reorder the stations. A policy or capability denial never instantiates a Store. Constants in botzr-aegis-core:

#![allow(unused)]
fn main() {
pub const PIPELINE_STAGES: &[&str] = &["policy", "capability", "sandbox", "audit"];
pub const HOST_PIPELINE_STAGES: &[&str] = &["policy", "capability", "audit"];
}

HOST_PIPELINE_STAGES is Model B: the effect runs in host Rust, so there is no sandbox station.

What each station does not do

  • Policy does not inspect call arguments. Matchers are tool, capability, and role only. See Policy YAML.
  • Capability never raises a grant. A policy ceiling can only narrow.
  • Sandbox gives Model B host functions zero isolation. Each host function must enforce the grant before the effect.
  • Audit is not a public transparency log. It is a hash-chained, ed25519-signed JSONL file. Truncation of an unanchored tail is undetectable from the chain alone (ADR-0002).

Trust models

Aegis supports two execution models with different blast radii. Conflating them is the primary way a sandbox becomes decorative. Details: threat model §3.

Model A — WASM tool

Tool logic compiles to wasm32-wasip2 and runs inside wasmtime. The guest can only reach the outside world through WASI surfaces wired from the grant. Isolation is strong because the guest cannot express an un-granted effect — there is no syscall surface except what the host linked.

aegis run is Model A. The DamageBot demo is Model A (plus a Model B host-effect case).

Model B — host function

The real side effect (HTTP, DB, exec) runs in host Rust, exposed to the guest as an imported function. The sandbox isolates the guest’s decision logic, but the effect executes with host privileges.

Model B is not sandbox isolation. It is a capability-checking, auditing proxy. Every host function must enforce the grant before acting; if it skips that check, the guest gets full host authority for that effect.

Prefer Model A wherever tool logic can live in WASM. Reserve Model B for effects that genuinely must touch the host, and keep that host-function set small and hand-audited.

Wrap is weaker than either

aegis wrap is not Model A and is weaker than Model B. The child is an ordinary OS process. Wrap does not mint a grant, does not evaluate policy, and does not restrict the child’s filesystem or network. What it buys is a signed record of which tools/calls ran.

Terminology

Words the project uses on purpose. Using a listed avoid term in docs or APIs is how two incompatible meanings collapse into one.

The pipeline

Call. One tool invocation travelling the full POLICY → CAPABILITY → SANDBOX → AUDIT pipeline. Avoid: request, invocation, execution.

Policy Set. The parsed, immutable rule collection a Call is evaluated against, held as Arc<PolicySet>. Avoid: policy file, ruleset, config.

Grant. The minted authority a Call executes under, derived by narrowing a parent grant; never ambient. Avoid: permission, capability (the axis), scope.

Decision Axes. The inputs a policy verdict is a function of, carried in the record’s decision_axes object (DecisionAxes in botzr-aegis-core). Exactly seven fields: capability, role, session, matched_rule, approval_ref, and the derived capability parameters fs and net. Never the raw argument tree. Every field is optional and omitted rather than nulled, so {} means “this emitter recorded no axes”. Every Decision Axis lives in the Chain, because recheck needs it.

tool_id is not inside decision_axes — it sits on the record line itself, next to the verdict, so it is not repeated.

fs and net are recorded today, but they describe the grant the call resolved under, not a caller-supplied argument. Both follow the same exactly-one-or-omit rule: fs carries the grant’s single preopen root and is omitted when a grant names more than one; net carries a single host and port and is omitted when the grant holds more than one HTTP entry or that entry names more than one port. An arbitrary one of N would be evidence that reads as fact and is not.

The shipped emitter writes the same string into path_raw and path_canonical — the pair exists because the two diverge once a caller path is resolved against that root. That is a property of this emitter, not of the format: SPEC.md §5.3 states them as independent fields, and its test vector carries divergent values deliberately. Matching on these axes is not shipped (ADR-0006). Avoid: policy inputs, request context, arguments.

Binding. A per-tool declaration of which argument position supplies which capability axis — read_file{path} and slurp{file_path} both bind to fs.read. Not shipped. ToolManifest declares static needs today, not bindings.

Model A / Model B. See Trust models. Docs must say plainly that Model B is not sandbox isolation.

The evidence

Agent Action Record (AAR). The signed, hash-chained record of one Call’s decision and outcome. The artifact third parties emit and verify. The file extension is .aarl (ADR-0014); .aar was ruled out because it is the Android Archive format. Nothing parses the name — the extension is a convention. Avoid: audit log, log line, event.

Chain. The ordered, signed sequence of AARs whose integrity aegis verify checks. Publishable by construction — it carries verdicts and Decision Axes, never raw payloads. Avoid: audit trail, ledger.

Envelope. The optional, local, content-addressed store of verbatim request bytes, keyed by request_digest. Never signed; authenticated transitively by the digest inside the signed Chain. Purely forensic — recheck does not need it. No Envelope code ships in this version. Avoid: payload store, blob store, sidecar.

Session. One writer lifetime over one Chain file — opened when the AuditWriter is constructed, closed on its Drop. A file may hold many Sessions. Avoid: run, connection, process.

Anchor. Any signed line that proves content exists beyond a given point — a close record, a later Session’s prev_session_tail back-reference, or a Checkpoint. Absence of an Anchor is what makes a tail undecidable.

Coverage. The highest seq covered by a valid signature. Every verify verdict is computed from Coverage plus Anchor presence, never from “is there a close record”.

Recheck. Re-evaluating a recorded session’s decisions against a different Policy Set and reporting the delta. Offline, deterministic, and executes nothing — which is why it is not called replay. Command: aegis recheck. Bare replay is reserved for a future re-execution engine that genuinely re-runs effects. Avoid: replay, simulate, re-run.

Indeterminate. A verdict meaning the question could not be decided from the evidence — an unverified tail, a torn write, an unknown line type, a missing Envelope, a digest mismatch. Never folded into “unchanged” or “verified”.

Relationships

  • A Call produces exactly one AAR, on every exit path including deny, trap, and panic
  • An AAR links to at most one Envelope entry, by request_digest
  • A Session contains many Calls; a Chain file contains many Sessions
  • A Chain verifies and rechecks without any Envelope
  • Model A and Model B both emit AARs; only Model A is contained by the sandbox
  • Every outcome line is signed, therefore an unverified tail can contain only intent lines plus at most one torn final line

CLI

Installed binary name: aegis. This page documents main.

aegis <version> — research runtime for secure agent tool execution

Usage:
  aegis [--policy <PATH>] [--audit <PATH> --signing-key <PATH>]
  aegis run --component <WASM> --id <TOOL_ID> [OPTIONS]
  aegis wrap --audit <PATH> --signing-key <PATH> [--policy <YAML>] [--confine] -- <CMD> [ARGS…]
  aegis verify [--key <HEX>]... [--trust-store <PATH>] <PATH>
  aegis recheck --policy <YAML> <PATH>
  aegis keygen --out <PATH> [--force]

Only run is in the published 0.3.0 binary. keygen, verify, recheck, and wrap landed after that tag and reach the registry with the next cut — until then, build from source.

Full flag tables: crates/botzr-aegis-cli/README.md.

aegis keygen

Writes a fresh ed25519 seed to --out as 64 lowercase hex characters. Prints public_key and key_id. Generation is never implicit — a key minted on the emit path would silently invalidate every pin held against the old one.

On Unix the file is created 0600, and aegis refuses to load a key readable by group or others. Elsewhere the mode is neither set nor checked: there is no portable equivalent, and claiming one would be a guarantee the code cannot keep.

aegis run

Registers a wasm32-wasip2 component and executes one call through the pipeline. --audit and --signing-key travel together, or neither is given.

Without --audit, nothing is kept. Bare aegis and aegis run with no --audit write to a Volatile, in-memory sink and print

Audit: (volatile sink — records are not retained)

instead of a path. The bytes die with the process: there is no file to open afterwards, nothing for aegis verify or aegis recheck to read, and the Session is signed by the dev key compiled into botzr-aegis-audit — so it is not evidence, and never was. Naming a path there would be the overclaim (ADR-0012).

To get a retained record, pass --audit <PATH> and --signing-key <PATH>. That is a Durable sink, and a Durable sink refuses the dev key — which is why the two flags travel together rather than one of them defaulting.

The published 0.3.0 binary differs here. At tag v0.3.0 there is no --signing-key at all: --audit <PATH> is accepted alone, and the records it writes are unsigned — no signing code shipped at that tag, so a 0.3.0 audit file carries no signature to attribute and aegis verify has nothing to check. It also defaults to a temp file sink and prints that /tmp/…/audit.jsonl path for a directory it deletes at exit (verified 2026-08-14 against the shipped binary). Signing, the in-memory default, and therefore the requirement that the two flags travel together, are main changes that reach the registry with the next cut. Flags on this page are main’s; do not read them against an installed 0.3.0.

aegis wrap

Interpose on a child stdio MCP server and record every tools/call.

Two independent opt-ins, neither on by default:

  • --policy <YAML> evaluates every well-formed tools/call before the frame may reach the child. A denied one never reaches it and the client is answered with JSON-RPC -32042 rather than left blocked on an id (ADR-0015). Without --policy wrap evaluates no rules and blocks nothing — every tools/call is relayed, and the client stream is byte-for-byte the child’s. A rule here matches tool identity; wrap never inspects params.arguments, and mints no capability under either mode, so even an allowed call records the deny_all pass-through grant.
  • --confine applies Landlock and seccomp to the child process, on Linux, and records what the kernel actually enforced. It is not a per-call decision and answers a different question from --policy.

See Wrapping an MCP server for the full non-guarantee list.

aegis verify

Reads one Chain file and reports a verdict. Exit codes are API (ADR-0002):

ExitMeaning
0Verified
1Tampered — or a usage error
2Could not read the record or the trust store
3Indeterminate

Two trust states (ADR-0004):

  • Verified (unpinned) — every signature checks out against the key the file itself published. Internal consistency only. An attacker who rewrites a whole Session signs it with their own key and the walk comes out clean.
  • Verified (pinned to <fp>) — same walk, plus every open key was one you supplied out of band. That is the provenance claim.

A pinned file that legally rotates across several anchored keys has no single fingerprint to name, so it prints bare Verified (pinned) — still the provenance claim, just over more than one key (SPEC.md §8.4).

A quickstart that prints bare Verified without saying which is an overclaim.

aegis recheck

Re-evaluates every recorded outcome against a new Policy Set and prints a would-block diff. Nothing is executed. The command is recheck, never replay (ADR-0008).

--policy is required. An implicit allow-all set would answer a question nobody asked.

ExitMeaning
0Every call unchanged
1A call is newly blocked, allowed, or parked — or a usage error
2Could not read the policy or the record
3Indeterminate — at least one call could not be answered for

3 outranks 1. Recheck does not verify signatures; asking what today’s rules would have done to a file verify would call Tampered is a legitimate forensic question.

Wrapping an MCP server

aegis wrap sits in the middle of an existing MCP session and writes a schema-v2 chained, signed audit record for every tools/call it carries — including one sent inside a JSON-RPC batch array, see batched calls.

client ──stdin──▶ aegis wrap ──stdin──▶ child MCP server
client ◀─stdout── aegis wrap ◀─stdout── child MCP server
                       │
                       └── audit JSONL (intent + outcome per tools/call)
aegis keygen --out /tmp/aegis-signing.key
aegis wrap \
  --audit /tmp/wrap-audit.jsonl \
  --signing-key /tmp/aegis-signing.key \
  -- npx -y some-mcp-server

# Opt-in OS confinement (Linux). Without --confine, wrap only records.
aegis wrap \
  --audit /tmp/wrap-audit.jsonl \
  --signing-key /tmp/aegis-signing.key \
  --confine --allow-exec-support \
  --allow-read /var/data --allow-net example.com:443 \
  -- npx -y some-mcp-server

# Opt-in policy enforcement. Without --policy, nothing is ever blocked.
aegis wrap \
  --audit /tmp/wrap-audit.jsonl \
  --signing-key /tmp/aegis-signing.key \
  --policy ./policy.yaml \
  -- npx -y some-mcp-server

--allow-exec-support is not decoration. Landlock is deny-by-default, so a profile of --allow-read /var/data alone means the dynamic loader cannot map libc and the child fails with Permission denied before its own main runs. The flag grants read on /usr /lib /lib64 /bin /sbin /etc /dev /proc — enough to start a dynamically linked program, and a named hole: a child holding it can still read /etc/passwd and walk /proc. It is deliberately not implied by --confine, because a widening that large should be something an operator typed. A statically linked server does not need it.

Then pin the record. aegis verify distinguishes pinned from unpinned (ADR-0004):

aegis verify --key <public_key printed by keygen> /tmp/wrap-audit.jsonl

--audit and --signing-key are both required. Wrap has no temp-sink mode: the only thing an interposer produces is its record.

The literal -- ends wrap’s own flags. Everything after it is the child argv, including the child’s --help.

wrap is on main. It is not in the 0.3.0 crates.io tarball, and neither is the keygen you need to mint its signing key.

What this is not

Wrap confines only when --confine is given, on Linux, and records what was enforced. It evaluates policy only when --policy is given. Without them the child is an ordinary OS process with the authority of the account that started it. Read this list before describing wrap as a sandbox, a firewall, or a guard:

  • No policy evaluation unless --policy. Default wrap runs no rules and makes no allow/deny decision: every tools/call is relayed and nothing is blocked at this layer. --policy <YAML> (AILAB-793) is the opt-in — see Enforcing a policy — and a denied tools/call is then refused with a JSON-RPC -32042 error (ADR-0015). No PolicyEngine lives in the wrap crate either way: it asks a CallGate and the CLI implements one.
  • No argument matching. Wrap does not look at params.arguments at all — a policy rule here matches on tool identity, nothing else.
  • No capability minting, ever. Not even under --policy: a gate decides whether a call may go and resolves no grant, so every record still carries the deny_all pass-through grant.
  • No filesystem or network restriction unless --confine. Default wrap is an ordinary OS process under the operator’s account. --confine applies Landlock and seccomp derived from --allow-read / --allow-write / --allow-net (Linux). --confine with no --allow-* is deny-everything. --best-effort is an explicit opt-in to partial enforcement; without it, a kernel that cannot honour the full profile refuses to exec.
  • The seccomp filter is a network deny-list, not a syscall sandbox. Its default action is allow. With no --allow-net it kills the socket/connect/bind family on SIGSYS; everything it does not name — ptrace, unshare, mount — is permitted. With --allow-net the filter is installed and denies nothing, which is why the enforcement record carries seccomp_network_denied alongside seccomp_applied. Read the pair, never seccomp_applied alone.
  • With no --allow-net, io_uring is unavailable to the child — all of it, not just its network operations. The ring dispatches socket and connect from memory shared with the kernel, so those operations never cross a syscall seccomp can inspect; until 2026-08-25 a confined process could reach the network that way while the record said seccomp_network_denied: true (AILAB-807). seccomp cannot read submission-queue entries, so no filter can permit io_uring file I/O while denying io_uring network I/O. The price, stated in the same breath: a child that uses io_uring for ordinary file I/O — some databases, some async runtimes — dies on SIGSYS under a network-denying profile. Give it --allow-net or do not confine it.
  • The network claim rests on enumeration, and enumeration has a tail. The filter denies the interfaces it names. Any kernel interface that carries a packet without crossing one of them is an open path that has to be discovered rather than prevented — io_uring was exactly that. Landlock covers the filesystem side at the LSM layer, where io_uring is caught like any other caller; moving the network side there too is AILAB-810 and is not shipped.
  • Not Model A isolation. Nothing runs inside wasmtime here. This is closer to Model B than to Model A, and weaker than either unless --confine is also given. See trust models and threat model §3.

What wrap does buy is evidence: a hash-chained, signed record of which tools were called, with digests of the exact request and response bytes, that survives the session and can be verified with aegis verify.

Batched calls

A tools/call sent inside a JSON-RPC batch array is recorded exactly as one sent in a frame of its own: an intent before the array reaches the child, an outcome when the child’s answer comes back. The array is relayed whole and unsplit in both directions — wrap does not rewrite a batch into per-call frames.

Under --policy it can refuse one, and only whole: a batch cannot be filtered without re-serializing it onto the child’s stdin, which wrap never does, so one refused tools/call drops the entire frame. See Enforcing a policy.

The calls in a batch share the frame’s digests: N intents carry the same request_digest and their N outcomes the same response_digest, because on each wire there was exactly one frame. A batched element never was a frame of its own, so digesting a re-serialized element would commit a signed record to bytes that crossed no wire.

macOS Seatbelt confinement is a later ticket (AILAB-630), not this one. --confine is Linux-only.

Enforcing a policy

Off unless --policy <YAML> is passed. That flag is the whole opt-in; there is no separate --enforce, and a wrap process without it is byte-for-byte identical on the client stream to one built before this existed.

With it, every well-formed tools/call is evaluated before the frame may reach the child. An allowed call is relayed verbatim and answered verbatim. A refused one — denied, rate-limited, or pending approval — never reaches the child, and the client is told so rather than left blocked on an id that would never be answered:

{"jsonrpc":"2.0","id":1,"error":{"code":-32042,
 "message":"aegis wrap refused this tools/call",
 "data":{"aegis":{"layer":"wrap","code":"POLICY_DENIED"}}}}

That frame is the only JSON-RPC wrap ever authors (ADR-0015). -32042, data.aegis.layer and the error-rather-than-result envelope do not vary; data.aegis.code does, across POLICY_DENIED, RATE_LIMITED, PENDING_APPROVAL and HOST_DENIED.

The record is the evidence, and it is what distinguishes a wrap refusal from a child’s own error: policy: denied, capability: denied with reason policy blocked before capability, execution: host_denied with reason not executed, and the real Policy Set content hash rather than the pass-through stand-in a relayed call carries.

A batch is refused whole. Every tools/call in a dropped array is answered and recorded — the refused ones name their own cause, and an allowed sibling gets HOST_DENIED with reason not executed: sibling call in this batch was refused while its record still says its policy verdict was allowed.

What --policy does not change. initialize, tools/list, ping, notifications and unknown methods all still reach the child, and -32601 is still never synthesized: “this call is refused” is a claim wrap can make, “no such method” is not. No capability is minted, so the record still carries the deny_all grant. --confine remains a separate flag answering a different question. A PendingApproval verdict is a refusal here, not a park — parking is AILAB-629 and is not built.

What recording costs

Interposing is not free, and the number misses its target: wrap_relay/tools_call_recorded measures 4.371 ms per recorded tools/call against an informational 0.5–2 ms budget — ~2.19× over. A relayed message that is not recorded costs 136.05 µs, so essentially the whole difference is the recording itself: an intent and an outcome line, each flushed and sync_all’d under the shipped G3 durability default. Two fsyncs cost ~4.2 ms on the reference filesystem on their own, which is why no arrangement of wrap code reaches 2 ms while durability stays where it is.

Expect a different figure on a different filesystem, and see Benchmarks for the hardware and the full attribution.

The library crate is botzr-aegis-wrap.

Policy YAML

Station 1 of the pipeline. YAML is parsed once into an immutable PolicySet held behind an ArcSwap. Evaluation is synchronous and targets <100 µs. The engine never parses at call time.

Matchers today are tool, capability, and role only. Policy does not inspect call arguments. Argument-level matching is not shipped.

Both trust models reach all three. The caller names the axes it asserts on the request it hands to execute_tool_call (Model A) or execute_host_call (Model B). A rule constrained on an axis the caller left unset does not match, so a role gate fires only where a role is actually claimed. session rides along on the request and is recorded on the Agent Action Record, but it is not a match axis — no rule can be written against it.

Neither aegis run nor the MCP gateway claims a role or a capability: both pass tool identity and nothing else, because neither has been told who is calling. Role-scoped rules are reachable from the library entry points, where the embedding application knows.

On-disk shape (version: 1)

Copied from the parser in crates/botzr-aegis-policy/src/parse.rs:

version: 1
default: allow          # allow (default) | deny
rules:
  - id: deny-exec
    action: deny        # allow | deny | rate_limit | pending_approval
    tool: exec-runner   # match axes (omitted or "*" = wildcard)
    capability: exec.command
    role: "*"
    priority: 10        # tie-break among equally-specific rules
    reason: "exec disabled in this environment"
  - id: rate-search
    action: rate_limit
    tool: search
    rate: { max: 100, per_seconds: 60 }
  - id: approve-dream
    action: pending_approval
    tool: dream
  - id: cap-writer
    action: allow
    tool: writer
    limits: { max_memory_bytes: 33554432, max_wall_ms: 5000 }

limits accepts a third key the example above omits, max_output_bytes, which caps a single call’s returned bytes.

Unknown fields are rejected. Duplicate id values are rejected. rate_limit requires a rate block with max > 0 and per_seconds > 0. rate is invalid on any other action.

Conflict resolution (G5)

No implicit file ordering:

  1. Deny-overrides — any matching deny wins outright.
  2. Among the rest, most-specific wins (more constrained match axes).
  3. Ties broken by explicit rule priority (higher wins).
  4. No match → the set’s default action.

pending_approval

Reject-with-resume-token: the call is not executed and no grant is minted. This is not a parked in-flight call. Parking a live MCP request across an approval is a D3 ticket, not this language.

Worked example

Three rules excerpted from the seven in examples/dreamd-poc/fixtures/dreamd-policy.yaml — the file also carries a role-gated write, a read-only recall allow, and two rate limits:

version: 1
default: deny
rules:
  - id: allow-episodic-append
    action: allow
    tool: append_node
    capability: fs:episodic
  - id: deny-net
    action: deny
    tool: "*"
    capability: net
    reason: "network denied for dreamd integration"
  - id: approve-dream
    action: pending_approval
    tool: dream

Parse-surface seeds (including invalid documents the fuzzer keeps) live in fuzz/corpus/policy_yaml/.

Recheck

aegis recheck --policy <YAML> <PATH> re-evaluates recorded outcomes against a different Policy Set. It uses the same G5 selection. It executes nothing. See CLI.

Library mode

botzr-aegis-runtime is the library-mode entry point. The CLI and the MCP gateway both go through RuntimeBuilder, so policy and audit wiring lives in one place.

use std::path::Path;
use botzr_aegis_runtime::{BuildError, RuntimeBuilder};

fn main() -> Result<(), BuildError> {
    let _rt = RuntimeBuilder::new()
        .policy_file(Path::new("policy.yaml"))?
        .audit_file(Path::new("/tmp/audit.jsonl"), Path::new("/tmp/aegis-signing.key"))?
        .build()?;
    Ok(())
}

Each builder step returns Result, so the chain needs a Result-returning scope — the ?s above do not work at the top level of a snippet. The binding is _rt because this snippet only shows construction; name it rt once you call something on it.

audit_file takes the signing key’s path and it is not optional. A persistent record file is one somebody will later pin a Verified (pinned) label to, so it is never signed with the dev seed compiled into botzr-aegis-audit. Leaving audit_file unset gives you the default in-memory Volatile sink, which is dev-key-signed, retains nothing past the process, and is not a production record.

Registration

register_tool associates a tool’s manifest with its executable atomically. Kind and executable must agree: ToolKind::Wasm with a WASM component, ToolKind::Host with a HostHandler. Duplicate ids are an error, not a silent replace.

Execution

  • Model A: execute_tool_call(ToolCallRequest { … }) — the caller does not supply a digest. The pipeline hashes the raw input bytes itself. The request carries the Decision Axes (capability, role, session), so a role-gated rule applies to a WASM tool and to a host tool alike.
  • Model B: execute_host_call(HostCallRequest { … }) — the handler receives a HostEffectContext built from the minted grant. The sandbox is not invoked.

execute_host_call_with is a research escape hatch: the runtime hands the closure a &CapabilityGrant and checks nothing before it runs. It is not a supported way to ship an effect.

Full API notes: crates/botzr-aegis-runtime/README.md.

Standalone sandbox

botzr-aegis-sandbox + botzr-aegis-core can be taken without the orchestrator. Wiring guide: INTEGRATION.md. Runnable proof: examples/sandbox-consumer/.

MCP gateway

botzr-aegis-mcp is a Phase 2 stdio server that exposes Aegis’s own tool catalog. External MCP clients call tools over stdio; each tools/call runs through the pipeline via Runtime::execute_tool_call.

This is a research scaffold — not a production MCP firewall.

It is not an interposer. There is no child process and no pass-through. For wrapping someone else’s server, see aegis wrap.

Catalog

Both tools use the same Model A WASM fixture. Arguments: { "text": "..." }.

ToolDefault policyPurpose
echoallowHappy-path tools/call through the full pipeline
exfildeny (station 1)Deny-smoke: refused before capability/sandbox; still audited

Run

This book documents main. The published 0.3.0 gateway differs — see below.

cargo run -p botzr-aegis-cli -- keygen --out /tmp/aegis-signing.key
cargo run -p botzr-aegis-mcp -- \
  --audit /tmp/aegis-mcp-audit.jsonl --signing-key /tmp/aegis-signing.key

--audit requires --signing-key. Omit both and the sink is Volatile and in-memory, signed by the compiled-in dev key: the gateway prints Audit: (volatile sink — records are not retained), the bytes die with the process, and there is nothing to verify afterwards. Pass both flags to get a retained record.

What 0.3.0 does instead

If you installed from crates.io, the commands above fail. At the v0.3.0 tag the gateway parses --policy and --audit only — there is no --signing-key — and aegis keygen does not exist, so there is no key to pass. Records at that version are schema_version: 1: unsigned, with no seq or prev_hash chain, so aegis verify has nothing to walk.

# crates.io 0.3.0
botzr-aegis-mcp --audit /tmp/aegis-mcp-audit.jsonl

Signing, chaining, and schema v2 arrive with the next registry cut. Until then, build from main for them.

Watchable reproduction: Demos (mcp-live-deny.cast).

Full protocol notes: crates/botzr-aegis-mcp/README.md.

Aegis threat model

Status: v0.1 draft (AEG-17 / OQ-15 T6) · Last updated: 2026-08-13 (exfiltration mitigations restated in schema v2 field names — request_digest / response_digest — with the published-0.3.0 caveat noted)
Related: SECURITY.md · Audit schema freeze · DamageBot demo · Benchmarks
Part B review: OQ-15 peer review (#19)

Aegis is a research instrument for testing what agent tool isolation actually guarantees. This document states what the runtime protects, what it does not, and how that compares to other approaches. It is written for security reviewers, integrators, and anyone evaluating claims about agent tool execution.

We do not claim foolproof security. Aegis is defense-in-depth with an explicit threat model and named non-goals. Any sentence that sounds absolute should be read as “absolute within the stated threat model.”


1. Scope

In scope (v1 runtime)

The botzr-aegis-* crates implement a four-station enforcement pipeline for agent tool calls:

POLICY → CAPABILITY → SANDBOX → AUDIT

Every call walks this order. Audit wraps all three inner stations and emits a record on every exit path — allow, deny, trap, resource cap, or panic.

StationRole
PolicyRole gate, approval gate, rate limits (sync; parsed-once Arc<PolicySet>)
CapabilityDefault-deny manifest resolution → minted grant (denial never reaches sandbox)
SandboxConfigure wasmtime Store from the grant, then run (per-call store; epoch + memory limits)
AuditSchema-versioned JSONL records (schema_version: 2), hash-chained and ed25519-signed; no raw secrets — the shipped payload digests are request_digest / response_digest (record format)

Out of scope (v1)

  • Layer 2 governance (LLM-side guardian, approval UX, policy authoring)
  • Multi-agent orchestration, dashboards, SaaS hosting
  • Public transparency logs, timestamping authorities, or inclusion proofs independent of possessing the record file. Schema v2 is a hash-chained, ed25519-signed JSONL file; truncation of an unanchored tail is still undetectable from the chain alone (see record format and ADR-0002)
  • Output DLP / content filtering on return values (see §6 Non-goals)
  • Credential injection as a first-class capability (design reserved; host env reality today)
  • In-process multi-tenancy (v1 = process-per-tenant deployment pattern)

2. Assets and adversaries

Assets we protect

AssetWhy it matters
Host filesystem outside granted preopensPrompt-injected agents must not read/write arbitrary paths
Host network outside granted hostsExfiltration and lateral movement via outbound HTTP
Capability grant integrityThe grant is the sole authority that configures the sandbox
Audit trail integrityForensics and accountability for every tool call
Host process stabilityRunaway or malicious guests must not exhaust host resources indefinitely
Caller secrets in tool inputRaw secrets must not appear in audit records

Adversaries assumed

AdversaryCapability
Malicious tool guest (Model A)Arbitrary WASM logic; attempts path escape, symlink tricks, resource exhaustion, WASI abuse
Malicious tool guest via host imports (Model B)Calls host functions with crafted URLs/paths; relies on host-side grant enforcement
Prompt-injected agent (upstream)Selects tools and arguments adversarially; may attempt bulk reads or staged exfil
Misconfigured operatorOver-broad grants, missing host-function checks, shared host credentials
Compromised host processAttacker with host-level access — outside Aegis’s isolation boundary

We do not assume a compromised wasmtime engine, a broken cap-std implementation, or a kernel-level attacker. Those are platform dependencies, not Aegis guarantees.


3. Trust boundaries: Model A vs Model B

Aegis supports two tool execution models with different blast radii. Conflating them is the primary way a sandbox becomes decorative.

Model A — WASM tool (logic inside wasmtime)

The tool compiles to wasm32-wasip2 and runs inside wasmtime. The guest can only reach the outside world through WASI surfaces wired from the grant:

  • Filesystem: cap-std preopens (no hand-rolled path.starts_with)
  • Network: no socket factories unless explicitly granted (default deny)
  • Memory / CPU: per-call MemoryLimiter + epoch interruption

Isolation is strong because the guest cannot express an un-granted effect — there is no syscall surface except what the host linked.

Evidence: DamageBot demo — write under read-only grant, .. traversal, symlink escape all refused at the WASI/cap-std boundary.

Model B — host function (effect in host Rust)

Real side effects (HTTP, DB, exec) run in host Rust, exposed to the guest as imported functions. The WASM sandbox isolates the guest’s decision logic, but the effect executes with host privileges.

Model B is not sandbox isolation. It is a capability-checking, auditing proxy. The sandbox gives zero protection for host-side effects. Every host function must independently enforce the grant before acting. If a host function skips that check, the guest can invoke the host with full host authority for that effect.

Evidence: DamageBot http_exfil cases — grant gate enforced in host.rs; calls without NetGrant or to disallowed hosts are refused and audited.

Context-owned effects vs the raw closure escape hatch

Model B enforcement is not uniform, and the difference is load-bearing:

PathEnforcementStatus
HostEffectContext methods (http_get, open_read, open_write_append, log_emit), reached via the handler registered with the tool and run by Runtime::execute_host_callStructural — the grant is checked before any effect, and FS access is a cap-std Dir opened from the grant, so an un-granted path is unreachable rather than merely uncheckedSupported
Raw closure passed to Runtime::execute_host_call_withConvention only — the runtime hands the closure a &CapabilityGrant and checks nothing before the effect runs; it applies the output cap afterwardsResearch escape hatch

The structural guarantee described in this document covers context-owned effects only. A raw closure that forgets its check is exactly the “missing grant check in a new host function” residual risk below — the runtime cannot catch it. The closure API is kept deliberately for research and experiment wiring; Aegis-owned effects must go through HostEffectContext.

Neither path is sandbox isolation. HostEffectContext narrows which effects a grant can reach; it does not contain the effect once it runs in host Rust.

Design consequence: keep the host-function set small and hand-audited. Prefer Model A wherever tool logic can live in WASM; reserve Model B for effects that genuinely must touch the host.


4. What the pipeline guarantees (within scope)

When correctly configured and integrated, Aegis v1 aims to ensure:

  1. Default deny. Unregistered tools, undeclared capabilities, and policy denials never reach sandbox instantiation.
  2. Grant-driven sandbox config. The Store is configured from the resolved grant, never from the raw request.
  3. Filesystem containment (Model A). cap-std preopens block .. traversal, symlink escape, and TOCTOU races that defeat naive path-prefix checks.
  4. Network containment (Model B). Host HTTP imports check NetGrant.http allow-list entries (host / ports / methods) before any outbound request.
  5. Resource bounds. Per-call memory cap, epoch wall-clock budget, and max_output_bytes limit unbounded guest output. The output cap is enforced orchestrator-side on the bytes a call returns — not a wasmtime store limit — and applies identically to Model A sandbox output and Model B host effects; oversize output fails closed (ResourceExceeded { kind: "output" }), never truncated-and-returned.
  6. Audit on every exit. Denials, traps, resource caps, and panics produce schema-versioned JSONL records without raw secret payloads. v0.1 persists via AuditWriter (per-line fsync) only — OpenTelemetry / OTLP export is deferred (not shipped). Wire contract: spec/SPEC.md.

Operational meaning of “foolproof” in this project: no single mistake — a forgotten host check, a malformed policy, a panicking host function — escalates into ambient authority. That is the design goal. A literal guarantee against all attack classes is not.


5. Approach comparison (named context, not a bake-off)

Different isolation approaches protect different things. Aegis uses WASM + capability grants; it does not replace network policy, kernel instrumentation, or human review.

ApproachWhat it protects wellWhat it does not protect
eBPF / LSM (kernel)Syscall-level enforcement on known processes; network egress at the hostTool semantics, prompt-injected argument choices, return-value exfil to the LLM, WASM guest logic before syscalls
Network proxy / MCP gatewayEgress filtering, request logging, rate limits at the wireIn-process filesystem access, host-function effects that bypass the proxy, semantic abuse of legitimately granted reads
WASM sandbox (Model A)Guest cannot express un-granted effects; strong containment for compiled toolsOnly applies when tool logic runs in WASM; does not help host-side effects; adoption tax for non-Rust/non-wasip2 tools
Capability proxy (Model B)Audited, grant-gated host effects; drop-in for existing toolsNot sandbox isolation — host function bugs = full host effect; equivalent blast radius to a well-audited proxy if checks are correct
Behavioral / LLM guardian (Layer 2)Semantic policy on tool intent and conversation contextNot a substitute for runtime enforcement; can be prompt-injected; correctly deferred post-v1

Aegis composes policy + capability + sandbox + audit for the tool-call path. It is not a replacement for kernel hardening, network segmentation, or secrets management outside the runtime.


6. Named non-goals

These are explicit gaps, not oversights. Naming them is part of the credibility story — a security reviewer finding an unnamed gap is the failure mode we avoid.

G9 — Exfiltration via return values

Capabilities gate what a tool can do, not what data flows back to the LLM. A tool legitimately granted fs.read can return file contents to a prompt-injected agent that leaks them in a later turn. v1 does not solve this.

Partial mitigations that exist today:

MitigationEffect
max_output_bytesCaps single-call return size, runtime-enforced on returned bytes (default 1 MiB)
request_digest in auditForensic trail of call input without storing raw args (Intent + Outcome); derived by the runtime from the call’s own bytes, so a caller cannot record a digest that does not match the payload
response_digest in auditForensic trail of call output without storing the returned bytes. Option on AuditRecord — present when the runtime produced a response, absent on denial and trap paths
Policy rate limitsFriction on bulk read patterns

A digest detects that returned bytes changed; it does not reveal or restrict what they contained. response_digest is evidence, not a control — it cannot stop exfiltration through a return value, only make a later comparison possible.

Published-release caveat: the 0.3.0 crates on crates.io emit schema_version: 1, which has input_digest and no output_digest. The field names above are schema v2, on main.

Full output DLP / content filtering is a candidate for a future enterprise tier (Layer 2 guardian), not v1.

Model B is not isolation

Do not market Model B host functions as “sandboxed.” They are grant-checked, audited proxies. Isolation guarantees for Model B are exactly as strong as the hand-audited host-function set.

HostEffectContext does not change that. It makes the check structural for the effects it owns (§3), so those effects cannot skip the grant — but the effect still executes with host privileges. Effects wired through the raw execute_host_call_with closure are not even structurally checked; they remain convention-checked by their author.

Host environment credentials (until credential injection ships)

Model B host functions that call real APIs may inherit the host process’s ambient credentials (env vars, IAM roles, kube service accounts). Aegis does not yet inject scoped credentials per grant (G6 — reserved for v1.5+). Operators must treat the host process as the credential boundary.

Prompt injection upstream of the runtime

Aegis enforces at the tool call boundary. It does not prevent an agent from choosing a legitimately granted tool with adversarial arguments, nor from relaying returned data in natural language. That is an agent-framework and governance problem.

Compromised host / operator

If the host OS, Aegis process, policy files, or manifests are attacker-controlled, runtime guarantees do not apply. Process-per-tenant deployment is the v1 honesty boundary for multi-tenancy.

Every tool / every LLM

v1 targets one real tool E2E and a deny suite, not universal tool coverage. The wasip2 adoption tax means most existing Python agent tools fall back to Model B unless rewritten as WASM components.

Absolute / “foolproof” claims

We will not claim Aegis makes agent tool execution foolproof. Defense-in-depth with honest non-goals is the product posture.


7. Residual risks

RiskSeverityNotes
Missing grant check in a new host functionCritical#1 sandbox bypass for Model B; mitigated by keeping the host-function set tiny and routing Aegis-owned effects through HostEffectContext. Raw execute_host_call_with closures remain exposed to this
Hand-rolled path checks instead of cap-stdHighBypassable via .., symlinks, TOCTOU — use cap-std preopens for Model A
Policy evaluated after capability mintedHighPipeline order violation; denied calls must never get a Store
Hung Model B I/O ignoring epochMediumWall-clock timeout + cancellation token required for host I/O
Audit sink failureMediumFail-closed vs fail-open is a deployment choice (see G3)
wasmtime / cap-std CVEPlatformTrack upstream advisories; in-scope for SECURITY.md
Return-value exfil (G9)Accepted non-goalPartial mitigations only

8. Evidence artifacts (not proof)

These in-repo artifacts support the claims above. They demonstrate specific containment cases; they do not exhaust all attack classes.

ArtifactWhat it shows
DamageBot demoSix adversarial cases through Runtime::execute_tool_call
Deny suitePolicy/capability/sandbox denial paths + audit emission
Record format specField-level line contract, chain rule, verdict model, and the non-guarantees stated plainly
Hot-path benchmarksPolicy ≪ 100 µs; combined pipeline ≪ 1 ms (cited hardware)
Findings reportMeasured guarantees vs named gaps; reproducible case studies + evidence bundle script

A passing demo is evidence, not certification. This threat model is the explicit statement of scope.


9. Reporting security issues

See SECURITY.md for private disclosure, supported versions, and coordinated disclosure policy.


Decision references

Built against Gap Resolutions G9 (return-value exfil non-goal) and G15 (security response process), OQ-15 T6 (written threat model), AEG-35 (audit-field honesty vs shipped audit.rs / schema freeze), AEG-36 (OQ-15 Part B structured review — issue #19), and the Hardened Implementation Design trust-model sections. Amendments should be logged in project planning docs and reflected here.

Aegis findings report

Status: v0.3 findings synthesis (AILAB-606) · Last updated: 2026-08-13 (audit references moved from schema v1 to the shipped schema v2 record format)
Related: Threat model · Audit schema freeze · SECURITY.md · Benchmarks · Fuzz campaign log

Aegis is a research instrument: the point is not to assert that agent tool execution is safe, but to make isolation claims falsifiable and then attack them. This document synthesizes what the instrument has measured so far — what the runtime is observed to guarantee, what it explicitly does not, and the exact commands that reproduce each observation. Everything here is evidence, not certification (see threat model §8).


1. Claim under test

The README states the hypothesis:

A default-deny, capability-grant-driven, per-call WASM sandbox with mandatory audit can contain an adversarial or prompt-injected tool call such that no single mistake — a forgotten host check, a malformed policy, a panicking host function — escalates into ambient host authority.

That is a design goal to be measured and attacked, not a guarantee. The threat model defines the scope, the two trust models, and the named non-goals against which every finding below should be read.


2. What we guarantee (measured)

Within the stated threat model, the following behaviors are enforced by the pipeline and exercised by in-repo tests:

  • Pipeline order. Every tool call walks POLICY → CAPABILITY → SANDBOX → AUDIT, with audit wrapping the inner three stations. A policy or capability denial never reaches sandbox instantiation.
  • Default-deny capability resolution. Unregistered tools, undeclared capabilities, and unresolvable needs are refused before any effect; a grant is minted only from a registered manifest.
  • Model A containment. WASM tool logic runs inside wasmtime with cap-std preopens wired from the grant — .. traversal, symlink escape, and writes under a read-only grant are refused at the WASI/cap-std boundary.
  • Model B is a grant check plus audit — not sandbox isolation. Host-function effects execute with host privileges; the runtime enforces the grant before context-owned effects and audits the outcome, nothing more.
  • Audit on every exit path. Denials, traps, resource caps, and panics all produce schema-versioned records (record format); there is no silent exit.
  • Resource ceilings per call. Epoch-based wall-clock deadline and a memory limiter are configured per call from the grant; tripping either produces a ResourceExceeded audit outcome.

3. What we do not guarantee

Explicit gaps, stated so a reviewer does not have to discover them:

  • Model B is not isolation. A host-function bug is a full host-privilege effect. See threat model §3 and §6.
  • A passing stress run is not a formal proof. The exactly-once audit result below holds for the schedules the scoped threads happened to produce, not for all possible interleavings.
  • A no-crash fuzz campaign is not exhaustive. It bounds the surface explored in the recorded time on the recorded hardware, nothing stronger.
  • Two planned fuzz surfaces were dropped, not deferred — because they do not exist. Early planning named three parse surfaces to fuzz. Host-argument decoding (the get_string OOB class) is not one Aegis has: the sandbox is component-model-native, so wasmtime lifts host-import arguments before they reach Aegis and there is no pointer/length decoder in-tree. Capability-manifest deserialization is not one either — ToolManifest is a Rust builder with no serde implementation and no on-disk format. Policy YAML is the only one of the three that exists, and it is the one that is fuzzed. Read that as a statement about how small the parse surface is, not as evidence of thorough fuzzing: §4.5’s limits still apply.
  • No Miri on upstream unsafe code. The workspace forbids unsafe, so there is no first-party unsafe for Miri to check; wasmtime and cap-std own their own unsafe surface. We track RUSTSEC advisories via cargo-deny instead — see the verification posture in SECURITY.md.

Supply-chain posture: cargo-deny runs in CI (full cargo deny check) plus a weekly advisory workflow; scope and the one recorded advisory ignore are in deny.toml. Details in SECURITY.md.

Layer 2 / governance findings are out of scope of this runtime findings document; governance/ is a separate service with its own evidence trail.


4. Case studies

Each case study is one finding, the files that carry it, a copy-pasteable command, and what you should observe. Commands run from the repository root on a clean checkout.

4.1 Grant narrowing cannot broaden authority

A sub-tool grant narrowed from a parent can never carry more authority than the parent on any axis — filesystem writes and output caps are checked by unit tests, and the subset invariant is exercised as a property test over randomized memory / wall-clock / output limits.

  • Files: crates/botzr-aegis-capability/src/narrow.rs (unit tests rejects_fs_write_escalation, rejects_output_cap_escalation), crates/botzr-aegis-capability/tests/capability.rs (proptest narrowed_grant_never_broader_than_parent)

  • Command:

    cargo test -p botzr-aegis-capability
    

    Targeted variants:

    cargo test -p botzr-aegis-capability rejects_fs_write_escalation
    cargo test -p botzr-aegis-capability narrowed_grant_never_broader_than_parent
    
  • Expected observation: escalation attempts fail with CapabilityError::Escalation; the proptest passes across its generated cases.

4.2 Adversarial guests are refused in both trust models

A deliberately malicious wasip2 guest (DamageBot) driven through Runtime::execute_tool_call is refused on every attack it attempts: write under a read-only grant, .. traversal, symlink escape (Model A / cap-std), and HTTP exfiltration without a net grant or to a disallowed host (Model B / host grant check).

  • Files: examples/damage-bot-demo/README.md (case table), tests/adversarial-demo/tests/damage_bot.rs (the six acceptance tests), guest source under tests/fixtures/damage-bot/

  • Command:

    ./scripts/build-fixtures.sh          # builds the adversarial guest wasm
    cargo test -p aegis-adversarial-demo
    
  • Expected observation: all six tests pass. Five attacks are refused outright; the sixth (guest_http_to_allowed_host_passes_grant_then_stubs) is a positive control — a request to an allowed host passes policy and grant resolution and is then denied by the v1 HTTP stub. All six outcomes produce audit records.

4.3 Every pipeline exit path is audited; denials are first-class

The deny suite drives the full pipeline through its refusal paths and asserts both the refusal and the audit record it produces; golden snapshots freeze the audit wire shape so a drift in the record format is itself a test failure.

  • Files: tests/deny-suite/ — representative tests: policy_deny_is_refused_and_audited, unregistered_tool_is_capability_denied, memory_cap_trips_through_pipeline, wall_clock_cap_trips_through_pipeline

  • Command:

    cargo test -p aegis-deny-suite
    
  • Expected observation: denial and resource-cap paths produce schema v2 outcome records; representative paths are frozen against golden snapshots (trap coverage lives in the adversarial demo and stress suite); no exit path is silent.

4.4 Audit emission is exactly-once under concurrency

Under mixed success, denial, trap, resource-cap, and panic-class calls fired on scoped threads at one shared Runtime, each call produces exactly one intent and exactly one outcome record — no orphan intents, no duplicate outcomes.

  • Files: tests/stress/tests/exactly_once.rs (single test audit_is_exactly_once_under_concurrency; plain threads, no async runtime)

  • Command:

    cargo test -p aegis-stress-suite
    
  • Expected observation: the test passes; the JSONL sink pairs every call-N intent with exactly one outcome. This is evidence over observed schedules, not a proof over all interleavings (§3).

4.5 Policy YAML parse surface survived its first fuzz campaign

The first cargo-fuzz campaign against PolicyEngine::from_yaml (plus one evaluate on successful parse) recorded no crash. From the campaign log: 2026-08-07, target policy_yaml, 10m 30s (30s smoke + 2×5m sessions), no crash across 5,893,498 campaign runs (final coverage 4244, corpus 2817), on the hardware cited in that log. Err(PolicyError) on malformed YAML is the parser doing its job, not a finding.

  • Files: fuzz/README.md (harness, seed corpus, campaign log)

  • Command (optional, bounded — requires nightly + cargo-fuzz; not part of the default evidence bundle):

    cargo +nightly fuzz run policy_yaml -- -max_total_time=60
    
  • Expected observation: the bounded smoke completes with no crash artifacts under fuzz/artifacts/policy_yaml/.


5. How to reproduce

5.1 Evidence bundle

One command runs the bounded evidence subset and writes a stamped bundle:

./scripts/evidence-bundle.sh

This creates evidence/YYYYMMDD-HHMMSS/ containing:

  • MANIFEST.txt — commit SHA, rustc -Vv head, uname -a, timestamp
  • deny-suite.log — cargo test -p aegis-deny-suite
  • adversarial-demo.log — cargo test -p aegis-adversarial-demo
  • stress.log — cargo test -p aegis-stress-suite
  • pointers.txt — paths to this report, the threat model, the fuzz campaign log, and the benchmark results

The script exits nonzero if any required test fails — or, when AEGIS_EVIDENCE_FUZZ=1, if the fuzz smoke fails. It deliberately does not run cargo bench or a long fuzz campaign. Setting AEGIS_EVIDENCE_FUZZ=1 adds a 30-second fuzz smoke (fuzz-smoke.log) when nightly and cargo-fuzz are available, and records a clean skip note otherwise. Bundle output under evidence/ is generated per run and not committed.

5.2 MCP live-deny demo

A watchable pass over the MCP stdio gateway — an allowed echo call, a refused exfil call, then, from one session of its own, the JSON-RPC error an MCP client receives for the refused call, the audit record that same call wrote, and a signature check over that record file:

./scripts/mcp-live-deny-demo.sh
  • Expected observation: the exfil call returns "code": "POLICY_DENIED" with "isError": true, and the audit file carries a schema_version: 2 outcome for exfil whose policy.status is denied, capability.status is denied (“policy blocked before capability”) and execution.status is host_denied (“not executed”). aegis verify --key <public_key> reports Verified (pinned to <key_id>) over that file.
  • The demo is presentation only. Its assertions are ./scripts/mcp-stdio-smoke.sh --deny, which the demo runs first and exits 0 only when it does. CI does not run that smoke; the equivalent assertions run there as botzr-aegis-mcp tests under cargo test --workspace.
  • A recording is committed at docs/demos/mcp-live-deny.cast — replay it with asciinema play docs/demos/mcp-live-deny.cast, or see Demos for what each beat shows.

6. Hardware / versions

No raw number in this report originates here; each is a citation:

  • Latency numbers come from benches/results/hot_path.md: policy_eval/allow_all 13.4 ns median, policy_eval/multi_rule 31.8 ns, hot_path/multi_rule 2.71 µs — against targets of <100 µs for policy eval alone and <1 ms for the combined policy + capability hot path. Recorded on the hardware cited in that file (WSL2 Linux, AMD Ryzen AI 5 340, rustc 1.96.0, Criterion 0.5.1, dated 2026-07-09).
  • Instantiation and audit-emission numbers come from benches/results/cell_and_audit.md: instantiation/warm 49.339 µs median against a <0.5 ms target, and instantiation/cold 39.490 ms against a <5 ms target — the cold target is missed, with engine construction only ~1–2% of it and component compile/link ~29.4 ms, so the target is retired pending a precompiled-artifact or compilation-cache mechanism. Warm is an upper bound: it runs through SandboxEngine::execute, so build_store and a trivial WIT run are inside the median — as was a per-call tokio runtime, which the engine built on every call when this was measured and no longer does (AILAB-809 moved it into SandboxEngine::new); the figure has not been re-measured since. audit_emission/begin_complete is 4.7185 ms and is write+fsync latency, not an Aegis-side overhead figure — a serialize_only control measures the serde half of the same cycle at 468.46 ns, ~0.01% of it. The compile-bound groups are noisy on this hardware (cold spans 27.8–39.5 ms across four runs); the results file publishes the cross-run table and the verdicts hold in every run. Recorded on the same machine but the repo-pinned rustc 1.86.0, dated 2026-08-10.
  • Fuzz campaign numbers come from the fuzz/README.md campaign log, which cites its own hardware and nightly rustc per row.

Numbers reproduced on different hardware will differ; the targets, not the medians, are the claim. Fresh runs should cite their own environment the same way (uname -a, CPU model, rustc -Vv).

The canonical source is spec/SPEC.md in the repository. Status: v0.1 (document version); the wire schema_version it specifies is 2 — a different number, see §12. The record file’s extension is .aarl (ADR-0014), so examples write session.aarl.

Where this document and the code disagree, the code is the defect report.

Agent Action Record — format specification, schema version 2

Status: v0.1 (2026-08-28) — this document’s own version; it tracks the code shipped by AILAB-619 · Wire schema_version: 2 — the number written inside every record, and a different counter (§12) Cite as: Agent Action Record format specification, document v0.1, schema_version 2 — spec/SPEC.md in botzrDev/aegis. Normative source of truth: the record types in crates/botzr-aegis-core/src/audit.rs, the canonicalizer in crates/botzr-aegis-core/src/jcs.rs, and the writer in crates/botzr-aegis-audit/src/writer.rs. Where this document and the code disagree, the code is the defect report.

This document specifies a file format, not an implementation. A third party should be able to write an emitter or a verifier from it. Everything stated as MUST is checked somewhere in this repository; everything this format does not guarantee is stated as plainly as everything it does. That asymmetry is deliberate — an evidence format that overclaims is worse than no format, because the overclaim is what gets quoted.

The decisions behind this format, with the options that were rejected and why, are in docs/adr/ — ADR-0001 through ADR-0006 are the ones this document implements.

Not specified by this version, deliberately:

  • Key lifecycle — where a private key lives, its permissions, how it is generated. Deliberately out of the format (ADR-0004), not merely deferred: what a Line carries is a key_id and a published public_key, and a verifier needs nothing else. The implementation is specified in crates/botzr-aegis-audit/README.md. Rotation is the one part that is format surface, and it is normative in §8.4.
  • Envelope reader/writer behaviour beyond the boundary in §9. No Envelope code ships in this version.
  • Argument matchers and the Bindings that produce them. That work was canceled, so this is a permanent omission rather than a deferral.

aegis recheck is specified, as of AILAB-622: aegis recheck --policy <YAML> <PATH> re-evaluates every recorded outcome in <PATH> against the Policy Set in <YAML> and prints a would-block diff, exiting 0 when every call is unchanged, 1 when a call is newly blocked, allowed or parked, 2 when the policy or the record could not be read, and 3 when any call is indeterminate — reading the Chain only, opening no Envelope (§9) and executing nothing.

License

This specification is dual licensed — SPDX Apache-2.0 OR MIT — under either the Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or the MIT license (LICENSE-MIT), at your option. Same terms as the reference implementation in this repository.

Apache-2.0 is offered because this document is meant to be implemented by other people: writing an emitter or a verifier from it, and shipping that, should not depend on a handshake about patents. The Apache-2.0 branch carries an explicit patent grant; take that branch if your organisation needs one. Neither branch requires attribution to Aegis in a record you emit — the format is deliberately vendor-neutral (ADR-0008), and nothing here conditions conformance on naming the reference implementation.

Applies to this document and to any release of the workspace cut after 0.3.0. The botzr-aegis-* crates published to crates.io at 0.3.0 carry MIT in their registry metadata and remain MIT as published; see ADR-0011.

The aegis verify command surface

Deliberately unnumbered: this is the command surface the format document pins in full, and it sits ahead of §1 so it cannot be mistaken for a property of the format. Sections 1–12 keep the numbers they have always had.

aegis verify is specified, as of AILAB-621. The verdict model stays in §8, because it is a property of the format. The command that prints it now ships, and its surface is fixed here because CI gates script it:

aegis verify [--key <HEX>]... [--trust-store <PATH>] <PATH>

<PATH> is the record file; any path is accepted. .aarl (§1.1) is the format’s extension, but it is a naming convention and this command deliberately validates nothing about it. --key is repeatable and takes a public key in the same 64-lowercase-hex wire form the open Line publishes — not a key_id fingerprint. --trust-store names a file of those keys, one per line, with blank lines and # comment lines skipped. The union of the two is the trust slice. Supplying neither flag is an unpinned walk (§8.4); supplying either one is a pin, and a --trust-store that yields no keys is a pin that nothing can satisfy — the first open Line is Tampered with an untrusted-key reason. A store that has been truncated or mis-mounted MUST NOT read as “unpinned”, or the CI gate it anchors keeps passing with its anchor gone.

Exit codes are API and there are exactly four:

ExitMeaning
0Verified
1Tampered, or a usage error — an unknown flag, a --key that is not 64 hex, a missing <PATH>
2The record file or the trust store could not be read
3Indeterminate, with the typed reason printed

stdout is deterministic — same bytes, same report, with no timestamp and no path in it. The first line is the verdict and, on success, its trust label; then one key_id line per observed key, one coverage line naming the Session and seq of the highest position a valid signature reached (§8), and one in_flight line per in-progress Call when the reason is an unanchored tail. Empty sections are omitted. A file that cannot be read produces empty stdout, error: … on stderr, and exit 2.


1. Model

A Chain file is a sequence of Lines. Each Line is one JSON object on one line (JSON Lines). Lines are append-only; nothing in this format ever rewrites a Line that has been written.

A Session is a contiguous run of Lines beginning with an open Line. One process that holds one writer is one Session. A file may hold many Sessions appended over time — a fresh Session per process start is the normal case.

A Call is one tool invocation. It contributes an intent Line before execution and exactly one outcome Line on every exit path, including denial, trap, resource exhaustion, panic and abandonment. Calls interleave: a Call’s intent and outcome are not adjacent, because a writer serialises concurrent Calls through one lock and other Calls’ Lines land in between.

An Envelope is an optional, separate, unsigned store of verbatim request bytes, content-addressed by request_digest (§9). It is not part of the Chain and no Envelope implementation ships in this version.

The Chain is the publishable artifact. It is what gets handed to an auditor. Read §10 before assuming that makes it non-sensitive.

1.1 The record file

Storage is JSON Lines: one JSON object per line, \n-separated, no trailing comma, no enclosing array. A trailing newline after the final Line is permitted; empty lines are ignored by a verifier.

The bytes written for each Line are its canonical form (§3). An emitter does not write one serialization and hash another. This removes the step where two implementations could disagree about what was hashed, and it means a reader can recompute a Line’s hash from the bytes on disk without re-canonicalizing — although a verifier SHOULD canonicalize anyway, so that a foreign emitter’s spacing cannot change a Line hash.

The file extension is .aarl (ADR-0014) — the AAR acronym followed by the l of JSON Lines. Examples in this document write session.aarl.

The extension is a convention for humans and their tooling, not a wire fact. Nothing in the format is carried by the filename, and nothing in this repository reads one: aegis verify and aegis recheck accept any path. A verifier MUST NOT reject a record file because of its name — the format states no requirement on it, and the file’s contents are the whole of the evidence.


2. Wire types

Wire formMeaning
digest64 lowercase hex characters (SHA-256). Uppercase is rejected, not normalized — one digest must have exactly one spelling, or two canonical forms of the same Line hash differently and a verifier disagrees with the emitter for no visible reason.
public key64 lowercase hex characters — a 32-byte ed25519 public key.
signature128 lowercase hex characters — a 64-byte ed25519 signature.
integerJSON number, non-negative, u64, strictly below 2^53 (§3.2).
stringJSON string.

Digest-valued fields are distinct types, not interchangeable strings: prev_hash, policy_set_hash, request_digest, response_digest, key_id. An implementation SHOULD make transposing them a type error rather than a convention — a Chain that hashes the policy set into prev_hash verifies clean while being wrong, and the shipped Rust types make that swap fail to compile (crates/botzr-aegis-core/src/digest.rs).


3. Canonical form — RFC 8785 JCS over a constrained value space

Every hash and every signature in this format is computed over the RFC 8785 (JCS) canonical JSON form of a Line. Storage stays JSON Lines; JCS defines the hash input (ADR-0003).

3.1 Canonicalization rules

  1. Object members are serialized in ascending order of their key’s UTF-16 code unit sequence, per RFC 8785 §3.2.3. This is not UTF-8 byte order. The two agree across the ASCII key set in this specification and disagree above the Basic Multilingual Plane, where UTF-16 surrogates (0xD800…) sort below U+E000. An implementation MUST implement the UTF-16 rule even if its own key set is ASCII, because a future or foreign emitter’s may not be.
  2. No insignificant whitespace: no spaces after : or ,, no indentation, no newlines inside a Line.
  3. Array order is preserved exactly. Arrays are never sorted.
  4. Strings escape only what JSON requires — ", \, and control characters — using \b, \t, \n, \f, \r where those short forms exist and lowercase \u00xx otherwise. Everything else, including all non-ASCII, is emitted literally as UTF-8. / is not escaped.

3.2 Value space — normative

These are requirements, not style. They are what make JCS safe to use here, so an emitter MUST NOT produce a Line that violates them and a canonicalizer SHOULD refuse one rather than guess:

  • No floating-point values anywhere in a Line. ES6 number formatting is the part of JCS that implementations disagree about, so the format removes the disagreement by removing floats. An integral float (1.0) is a float and is not an escape hatch.
  • Integers are non-negative and strictly below 2^53 (9007199254740991). This bound exists for a JavaScript verifier reading seq as a Number, not for Rust. A JS implementation is the realistic third-party verifier and losing precision above 2^53 would break verification in a way nobody would attribute to the format. A field whose natural domain exceeds this (for example a byte limit near u64::MAX) MUST be projected as a decimal string before it reaches the canonicalizer.
  • Absent fields are omitted, never null. A canonical form cannot leave absent-versus-null to the emitter. A literal null anywhere in a Line is invalid. The one field that is always present but may be empty is decision_axes, which serializes as {} (§5.3).

3.3 Why this is safe, and the boundary that keeps it safe

This constrained value space is only viable because payloads live in the Envelope. request_digest is SHA-256 over raw request bytes (§9), so arbitrary user data never reaches the canonicalizer: every key it sees is one of Aegis’s own, and JCS’s genuinely hard cases — float edge cases, unnormalized Unicode in attacker-chosen keys — all live in user data, of which there is none.

Do not relax that boundary without revisiting this decision. Putting request arguments inline would put attacker-chosen keys and arbitrary numbers into the canonical form, and every constraint in §3.2 would stop being reachable.


4. The chain rule

For each appended Line, in this order, inside the same lock that performs the write:

  1. Assign seq — the next position in this Session — and prev_hash — the hash of the Line written immediately before it in this Session.
  2. Compute signing_input = the canonical form of the Line with the signature member omitted and key_id present.
  3. If the Line is in the signed set (§5.1): signature = ed25519 over signing_input; write both signature and key_id into the Line.
  4. Compute line_hash = SHA-256 over the canonical form of the complete Line, signature included.
  5. Write the canonical form as one JSON Lines row; flush; fsync.
  6. Set the Session tail to line_hash. The next Line’s prev_hash is this value.

Step 1 must happen under the same lock as step 5. Two threads that read the chain head outside that lock receive the same prev_hash and fork the chain. This is the single highest-risk defect in an implementation of this format; the shipped writer holds seq, the tail and the file handle in one mutex-guarded struct precisely so they cannot drift apart, and a concurrency test appends from eight threads and asserts a single unforked chain.

line_hash covers the signature, deliberately. Stripping a signature therefore changes the hash and breaks the next Line’s prev_hash, which a verifier reports as Tampered. If the hash excluded the signature, signature-stripping would leave a clean chain.

seq is per appended Line, per Session — never per Call. Concurrent Calls interleave, so a per-Call counter cannot describe a file’s order.

A seq gap over an intact chain is possible and is not tampering: the writer takes seq before the append and advances the tail only after the write lands, so a failed write leaves a gap with the chain intact. See §8.


5. Lines

Every Line, of every type — including a type this specification does not define — MUST carry:

FieldTypeNotes
schema_versioninteger2 for this specification.
line_typestringSee §5.1.
seqintegerPosition within the Session, from 0.
prev_hashdigestHash of the predecessor Line, or genesis (§7).

A Line missing or mistyping line_type, seq or prev_hash is not a Chain Line; a verifier reports the file as Tampered, not as unrecognised. This is what makes the extension story in §5.2 bounded: a future Line type may add anything, but it may not opt out of its position in the chain.

5.1 Line types and the signed set

line_typeSignedEmitted by v0Role
openyesyesFirst Line of a Session. Carries the public key and prev_session_tail.
intentnoyesPre-execution Line for a Call.
outcomeyesyesThe Agent Action Record — one per Call, on every exit path.
decisionyesno — no callerA human approval verdict (ADR-0005).
closeyesyesLast Line of a Session.
checkpointyesno — reservedDefined so that adding it later is not a breaking change.

The signed set is open, outcome, decision, close, checkpoint.

intent is hashed into the chain but never signed. The intent Line is appended and fsynced ahead of execution, so anything added to it — including a signature computation — lands on the pre-execution critical path. It is authenticated transitively instead: the next signature commits to prev_hash, and prev_hash chains back through every unsigned Line before it. In the shipped implementation this is a type-system property, not a rule the writer remembers — the intent type implements the chain trait and not the signing trait, so there is no way to hand it to the signing path (crates/botzr-aegis-audit/src/line.rs).

checkpoint is reserved. No emitter in this repository produces one. A verifier MUST handle one: it is a signed Line, so it extends Coverage — and it still caps the verdict at Indeterminate, because this version does not define what a Checkpoint asserts.

decision is defined but not emitted by v0, which is a different statement from checkpoint’s. A checkpoint is unemitted because this version does not define what it asserts. A decision Line is fully defined: ADR-0005 fixes its semantics, the park-and-resume protocol below describes it, §8.1 makes a second decision for one approval_id Tampered, and a working emitter for it exists in the implementation. What does not exist is the approval protocol that would call that emitter — a pending_approval policy verdict is recorded as an outcome Line and returned to the caller as an error, and nothing in this version resumes a parked Call. The gap is a missing caller, not a missing definition, which is why the table says no — no caller here and no — reserved there. A verifier MUST still handle a decision Line: they are legal, and the published vectors in §11.2 and §11.4 both contain one.

There is no park Line type. A park is an outcome Line carrying policy.status = "pending_approval", which has shipped since schema version 1. The human verdict that answers it is a separate decision Line, and a resumed call is a new Call with its own intent and outcome, cross-referenced by approval_id (ADR-0005).

5.2 Unknown line types

An emitter newer than a verifier may write a line_type the verifier does not recognise. Such a Line:

  • still hashes. It is bytes; the chain stays valid across it, and Lines after it verify normally.
  • caps the verdict at Indeterminate, with a reason naming the unrecognised token — “unknown line type foo at session 0 seq 12, newer emitter”.

The cap is the answer for an unknown Line that carries no signature field. Whether a future Line type must be signed is unknowable to this build, so an unsigned one is only unreadable. A signature that is present and does not authenticate the Line is Tampered instead (§8.1, §8.4): forgery is decidable without understanding the Line, and it outranks the cap. Presence is read off the Line’s own fields — an unknown Line whose signature is present while key_id was stripped is still a present-and-invalid signature, not an unsigned Line.

A verifier MUST NOT report Verified over content it does not understand. If it did, a future emitter could smuggle anything past an old auditor. This is the format’s entire extensibility story and it is fixed at this version.

The token itself MUST be preserved through parsing. A verifier that collapses every unknown type to a single “other” can say that something was unreadable but not what, which is the half of the message an operator needs.

5.3 Field reference

Fields marked optional are omitted when absent — never null.

open

FieldTypeNotes
schema_version, line_type, seq, prev_hash—seq is 0; prev_hash is the genesis digest (64 zeros).
prev_session_taildigest, optionalThe previous Session’s final line_hash. Omitted for a fresh file. See §7.
public_keypublic keyThe ed25519 key every signed Line in this Session is signed under.
signature, key_id—The open Line signs itself under the key it publishes.

intent

FieldTypeNotes
schema_version, line_type, seq, prev_hash—
call_idstringPairs this Line with its outcome.
tool_idstring
request_digestdigestSHA-256 over the verbatim request bytes (§9).

No signature, no key_id. An emitter MUST NOT sign an intent Line.

outcome

FieldTypeNotes
schema_version, line_type, seq, prev_hash—
call_idstringMatches the Call’s intent Line.
tool_idstring
request_digestdigestIdentical to the intent Line’s.
policy_set_hashdigestWhich Policy Set governed this Call (§6).
policyobjectTagged by status: allowed · denied {reason} · rate_limited {reason} · pending_approval {approval_id}.
capabilityobjectTagged by status: granted {grant} · denied {reason, denied_capability?}.
executionobjectTagged by status: success · trap {message} · resource_exceeded {kind} · host_denied {reason}.
grant_idstring, optionalThe grant the Call ran under. Omitted when none was minted.
response_digestdigest, optionalSHA-256 over the verbatim response bytes. Omitted when the Call produced none, and when output was rejected by a cap — bytes that never left MUST NOT be recorded as a response.
wall_msinteger, optionalOmitted when the sandbox never ran.
peak_memory_bytesinteger, optionalOmitted when the sandbox never ran.
decision_axesobjectAlways present, possibly {}. See below.
signature, key_id—

capability.granted.grant is a CapabilityGrant: grant_id, tool_id, fs {read_paths, write_paths} (optional), net {http: [{host, ports, methods}]} (optional), max_memory_bytes, max_wall_ms, max_output_bytes.

decision_axes — the inputs the verdict actually turned on. It is nested rather than flattened because the outcome Line already has a top-level capability field holding the capability station outcome, and two different things named capability on one Line is a collision that survives review and breaks an ingest.

FieldTypeNotes
capabilitystring, optionalThe capability axis the Call requested, e.g. fs.read.
rolestring, optionalThe role asserted by the caller.
sessionstring, optionalThe policy session scope — the PolicyRequest scalar, not the audit Session.
matched_rulestring, optionalThe rule that decided it.
approval_refstring, optionalThe approval a resumed Call was allowed under (ADR-0005).
fsobject, optionalDerived filesystem parameter — the grant’s single root: {path_raw, path_canonical}.
netobject, optionalDerived network parameter — the grant’s single host and port: {host, port}.

The object is always emitted because {} and an absent member say different things: {} says this emitter recorded no axes, an absent member says nothing at all. Its members follow omit-never-null.

fs and net are the derived capability parameters of ADR-0006, and what they record is the scope the grant resolved to — for fs, a root the Call was permitted under; for net, the grant’s single host and port. Neither is a resource the Call touched: the emitter derives both from the grant alone, and no Call argument is in scope where they are built.

They are recorded only when the grant names exactly one such scope; a grant naming several roots has not resolved to a path, and the axis is omitted rather than guessed. Recording an arbitrary one of N roots would be evidence that reads as fact and is not.

No shipped consumer reads them. aegis recheck rebuilds its request from capability, role and session only, and the argument matchers of ADR-0006 that would have consumed these parameters were canceled and will not be built. They are evidence a reader interprets, not an input any verdict turns on.

Both path_raw and path_canonical are recorded because a difference between them is itself evidence. The pair exists so that an emitter resolving a caller-supplied path against a root can record both spellings without a breaking change to the shape.

The two are independent fields, and a verifier MUST NOT assume they are equal. The shipped emitter in this version always writes the same string into both, because the capability resolver canonicalizes at mint time and the grant therefore only ever holds the canonical form (crates/botzr-aegis-runtime/src/pipeline.rs, fs_axis). That is a property of this emitter, not a constraint of the format: the test vector in §11.2 deliberately carries divergent values so that an implementation built from this document handles divergent values today rather than hard-coding this emitter’s equality and breaking on one that does not share it.

decision

FieldTypeNotes
schema_version, line_type, seq, prev_hash—
approval_idstringThe park this verdict answers.
verdictobjectTagged by verdict: approved {scope} · denied {reason}.
signature, key_id—

scope is {tool_id, fs?, net?} — the authority the approval granted, not just the fact of approval. The scope rides inside the approved variant so that an approval without a recorded scope is unrepresentable: approval without recorded scope is a blank check in the evidence, and the resumed Call’s grant must be a subset of what is recorded here.

close

FieldTypeNotes
schema_version, line_type, seq, prev_hash—
signature, key_id—

checkpoint

Reserved. This version defines no fields beyond the four every Line carries.


6. policy_set_hash

policy_set_hash names the Policy Set that governed the Call. It is SHA-256 over the canonical bytes of the parsed set — the JCS form of a stable projection of the validated rules — and deliberately not over the YAML text.

Hashing the text would make a reindent, a retyped comment, or a reordered mapping key look like a different ruleset, and an identity that moves for non-reasons trains a reader to ignore it moving. Every field the evaluator actually reads is covered, so a semantic edit always moves the hash. Rule declaration order is covered, because rule order is observable behaviour: the selector keeps the incumbent on a full specificity-and-priority tie.

Every scalar in the projection is a string or a boolean. A legitimate policy document may carry a negative priority or a byte limit near u64::MAX, both of which are outside the §3.2 value space; projecting numbers as decimal strings means no policy file can push canonicalization outside the value space, so hashing a parsed set cannot fail.

There is a separate, older FNV-1a digest over the YAML text in this codebase. It serves the hot-reload audit trail and is self-documented as not a security digest. It MUST NOT be recorded as policy_set_hash.


7. Sessions

  • A Session begins with an open Line at seq 0.
  • seq restarts at 0 in every Session. A file with two Sessions has two Lines numbered seq 5.
  • An open Line’s prev_hash is the genesis digest: 64 zeros. A Session’s first Line has no predecessor within the Session.
  • The back-reference across a Session boundary lives in prev_session_tail on the open Line, and MUST equal the previous Session’s final line_hash. It is omitted only when the file was empty.
  • The tail is not duplicated into prev_hash. One fact gets one spelling, and a verifier already special-cases open because that is where the public key is.
  • A Session ends with a close Line, written when the writer is dropped. See §10 for what that does and does not cover.

Coverage is (session_index, seq), not a bare seq. ADR-0002 defines Coverage as “the highest seq covered by a valid signature”, which reads file-global; seq in fact restarts per Session. Those disagree the moment one file holds two Sessions, where a bare seq names two different Lines. This specification is normative: a position in a Chain file is the pair (session_index, seq), where session_index is the Session’s ordinal within the file counted from the first open Line. The ADR’s wording is under-specified, not wrong.


8. Verification

A verifier walks the file once, in order, holding the same state the writer held: current Session index, expected seq, the running tail hash, and the Session’s published public key.

Coverage is the highest position covered by a valid signature.

The verdict is one of three states, and only three:

VerdictMeaning
VerifiedEvery Line parsed, chained and — where required — verified, and the file ends with a verified close.
IndeterminateNothing contradicts the file, but something could not be decided. Carries a typed reason.
TamperedThe file contradicts itself.

A binary pass/fail is deliberately rejected. Failing on any unverified tail alarms on healthy systems — the common case is not a crash but a live file being appended to right now, and a gate that fires on every in-progress file is noise within a week. Passing with a warning is worse: exit 0 is what every CI gate actually reads, and an evidence tool whose machine-readable answer is “fine” for a truncated file reproduces the very critique it exists to answer.

8.1 Tampered

  • A Line’s prev_hash is not the hash of the Line before it — an edit, a removed Line, or a forked chain.
  • seq repeated or went backwards. Two Lines at one position is what a fork looks like from the file, and no correct writer can produce it.
  • A Line in the signed set carries no signature, or one that does not verify against the key its Session published. A stripped signature on an outcome Line lands here.
  • A Line’s key_id does not match the key its Session published (§8.4).
  • An open Line’s prev_session_tail does not match the previous Session’s final Line. This is how truncating a non-final Session is caught.
  • A Line outside the format: not JSON and not the final Line; not an object; missing or ill-typed line_type / seq / prev_hash; or outside the §3.2 value space, so it has no reproducible hash.
  • An open Line publishes a public key that is not in a trust slice the caller supplied (§8.4). Not Verified (unpinned): the caller stated which keys it accepts and the file answered with another one.
  • A second decision Line for an approval_id already decided in the file. One park, one verdict — without this rule a recorded denial can be followed by an approval for the same park with both Lines validly signed (ADR-0005).

8.2 Indeterminate

  • Unknown line type, with the emitter’s token (§5.2) — capped only when it carries no signature; a signature present and invalid is Tampered (§8.1).
  • Reserved checkpoint — signed, so it extends Coverage, and still capped. The cap does not excuse the signature: a checkpoint that is unsigned or whose signature does not verify is Tampered (§8.4), because it is in the signed set and a forgery must not hide behind the cap.
  • Torn final line — the file’s last Line does not parse. Distinct from “no close record”: it is a torn write, and only the last Line can be one, because a correct writer refuses to append onto a torn tail.
  • Unanchored tail — the final Session has no close and nothing anchors beyond it. The report names the Calls in flight: three intents for workspace reads is a shrug, one for a network POST is where an operator starts looking.
  • Missing line — seq jumped forward while prev_hash still matched. Only a writer can produce this (§4); an attacker cannot, because removing a Line breaks the next Line’s prev_hash and re-signing the remainder needs the key. So this is a durability incident, not a forgery, and calling it Tampered would alarm on a full disk.
  • Empty chain — no Lines. Nothing contradicts the file; there is also nothing in it to verify.

Anchors are what resolve a tail: a close Line, a later Session’s open, and — reserved — a checkpoint. The verdict MUST NOT be computed from “is there a close record” as a boolean; Coverage-plus-Anchor generalises across the crash case, the multi-Session case and the live-file case, and a close-record boolean does not.

The verdict is deterministic: same bytes, same verdict, always. This is a property, not a test case.

8.3 What the unverified tail can contain

Every outcome Line is signed, so the unverified tail after the last signature can hold only intent Lines plus at most one torn final Line. An outcome Line in the tail is a stripped signature — Tampered, not a crash.

8.4 Key model and rotation

A per-host ed25519 keypair signs Lines. The Session open Line carries the public key; every signed Line carries key_id, the SHA-256 fingerprint of that 32-byte public key.

Signature verification MUST be ed25519 strict verification, rejecting small-order and malleable signatures. A record format wants one signature to have one verdict everywhere; the permissive rule lets the same bytes verify in one implementation and fail in a batch verifier.

key_id is inside the signed input — a signature is computed over the canonical form with signature omitted and key_id present — so a signature cannot be replayed under a different key’s fingerprint. A verifier MUST read key_id off the Line rather than substituting the fingerprint of the key it holds; otherwise a Line naming a key it was not signed under would quietly verify.

Rotation rule (normative). A file MAY span multiple key_ids. A new key_id is legal only when introduced by a Session open Line that carries the matching public key. A key_id change within a Session is Tampered.

This is what makes rotation expressible without making key substitution free: rotating means starting a Session, which is a signed, chained, anchored event that back-references the previous Session’s tail.

Two success labels, not one. Verifying a Session’s signatures against the key that same Session published proves internal consistency and nothing about provenance: an attacker who rewrites a whole Session signs it with their own key, publishes that key in the open Line, and the walk comes out clean. A verifier therefore MUST print the fingerprint and MUST distinguish Verified (pinned to <fp>) — a key was supplied out of band and matched — from Verified (unpinned), stated as internal consistency only. A key_id that fails to match a supplied trust store is Tampered. The labels are specified here because they are a property of what the format can prove; the CLI that prints them is AILAB-621.

What the shipped walker enforces, and the one gap that remains — stated rather than papered over:

  1. The rotation rule is enforced across the whole signed set. A foreign key_id on an open, outcome, decision or close Line is Tampered with a key-mismatch reason, and a Session-opening rotation is accepted, both under test. checkpoint is in the signed set and is held to it: a checkpoint whose signature does not verify is Tampered, not capped — discarding that result would let a forged Checkpoint hide behind the Indeterminate cap. An unknown-type Line is judged on what it carries: a signature that is present and does not verify is Tampered, because forgery is decidable without understanding the Line, while an unsigned one only caps, because whether a future Line type must be signed is unknowable to this build. Neither is ever reported as Verified.
  2. Pinning ships. The walker takes an optional slice of public keys the caller anchored out of band, and aegis verify fills it from --key and --trust-store (see The aegis verify command surface, above), so both success labels are reachable. Every open Line’s key must be in a supplied slice, not merely one of them — rotation across Sessions stays legal, while rotating into a key the caller never anchored is Tampered with an untrusted-key reason. Pinning is identity checking, not a second signature path: signatures verify against the key each Session published either way, and the slice only decides whether that key is one the caller accepts. A supplied slice therefore cannot make a broken chain verify; it upgrades the label from Verified (unpinned) to Verified (pinned to <fp>), or to Verified (pinned) when the file legally rotates across several anchored keys.
  3. Remaining gap: a signature on an intent Line is ignored rather than rejected. It is still covered by line_hash, so it cannot be altered undetected, but an emitter that signs intent Lines is out of spec and the shipped walker does not say so.

9. The Envelope boundary

Per ADR-0006 the Envelope is purely forensic, not a recheck prerequisite. Argument matchers target derived capability parameters, and those parameters are decision axes carried in the Chain (§5.3), so aegis recheck works chain-only. The Envelope exists for human investigation after an incident, and as the seam a future execution journal grows into.

The boundary, normatively:

  • An Envelope entry holds the verbatim request bytes of a Call.
  • It is content-addressed by request_digest — the same digest the intent and outcome Lines carry.
  • It is stored outside the Chain, in a separate artifact that need not travel with the Chain and often should not.
  • It is never signed.
  • It is authenticated transitively: a reader hashes the bytes on load and compares against the request_digest on the signed Line. The Line’s signature covers that digest, so a matching hash means the signature covers the payload too.

“Verbatim” is load-bearing and is stated here explicitly. request_digest is SHA-256 over the raw request bytes exactly as they arrived. An Envelope writer that pretty-prints the JSON, re-encodes it, sorts its keys, or normalizes its whitespace produces bytes that no longer hash to the recorded digest — and every entry it wrote silently stops authenticating. The trap is invisible until someone runs a formatter, at which point the break is retroactive and looks like tampering. An implementation MUST hash and store the bytes it received, before any processing, and MUST NOT expose an API that accepts a caller-supplied digest.

The same rule governs response_digest: hash what was produced, never a re-encoding of it.

No Envelope writer or reader ships in this version. This section specifies the boundary so that building one later cannot break the Chain format.


10. Threat model and non-guarantees

State these to anyone who is handed a Chain file. Each one is a limit of the evidence, not a bug to be worked around.

Drop does not run on SIGKILL. The close Line is written when the writer is dropped, which covers clean exit and panic unwinding — both under test. It does not cover SIGKILL, a power loss, or a host that vanishes. That gap is exactly what produces Indeterminate, and it is documented rather than engineered around: a Session with no close and nothing anchoring beyond it is honestly undecidable, and the format says so instead of guessing.

Truncating a non-final Session is detectable from the Chain alone. Drop Lines off the end of Session N and its tail changes, so Session N+1’s signed open no longer agrees with it — Tampered, from the file alone, with no external witness. Only the final Session’s tail is undecidable, and only when nothing anchors beyond it.

ADR-0002’s opening sentence says “truncation is therefore not detectable from the Chain alone”. That sentence contradicts that ADR’s own first Consequence, which correctly states the per-Session property above, and it understates what shipped. This specification states the shipped property; the ADR header is a known editorial defect.

Content beyond the last signature is unverifiable by construction. No format that signs some Lines can say anything about bytes appended after the last one it signed. That is what Coverage measures and what Anchors resolve.

A self-embedded key proves consistency, not provenance. See §8.4. Without an out-of-band key, Verified means “some Aegis build wrote this and the file names which key”, never “this key is yours”.

Derived paths appear in the Chain, and the Chain is the publishable artifact. A derived fs path naming a client directory under someone’s home directory is smaller than a full argument tree, but it is not non-sensitive. Sensitivity moved when the Envelope split off; it did not vanish. Anyone publishing a Chain must read its decision_axes first. tool_id, call_id, matched_rule, policy reasons and trap messages are free text written by the host and are subject to the same reading.

Unmatched intent is a consistency cross-check, not a tamper signal. An intent with no outcome inside a closed Session is a structural error worth reporting, but interior deletion is already caught by prev_hash. What the invariant adds is catching a buggy emitter, and catching a rewriting attacker who holds the signing key but drops an outcome sloppily. Do not claim more.

Two linkage kinds with different strengths:

  • intent ↔ outcome is a hard invariant, Session-scoped, guaranteed by the writer’s drop guard plus the borrow that makes the writer outlive every Call it issued. A violation inside a closed Session is a structural error.
  • approval_id ↔ decision is a soft cross-reference that may span Sessions and files. A human approving after a restart is normal. A pending_approval outcome with no decision is legal and informational; a decision for an absent approval_id is legal and informational. Two decision Lines for one approval_id is a structural violation, the same class as a chain break, because a correct emitter cannot produce it.

A seq gap is a durability incident, not a forgery. See §8.2.


11. Test vectors

Every vector below publishes the canonicalized intermediate form alongside the final hash. This is required, not a courtesy: an implementer who canonicalizes wrong and only sees “hash mismatch” has nowhere to look, and the canonical string is where the divergence is visible.

All vectors are live in the tree and are checked by CI.

11.1 Canonicalization

Source: crates/botzr-aegis-core/src/jcs.rs, test published_test_vector_canonical_form_and_hash.

Input (member order deliberately scrambled, nested object included):

{
  "tool_id": "echo",
  "seq": 7,
  "decision_axes": {
    "role": "ops",
    "capability": "fs.read",
    "fs": { "path_raw": "~/notes.md", "path_canonical": "/home/a/notes.md" }
  },
  "line_type": "outcome",
  "prev_hash": "0000000000000000000000000000000000000000000000000000000000000000"
}

Canonical form (one line, no whitespace, wrapped here only for reading):

{"decision_axes":{"capability":"fs.read","fs":{"path_canonical":"/home/a/notes.md",
"path_raw":"~/notes.md"},"role":"ops"},"line_type":"outcome","prev_hash":
"0000000000000000000000000000000000000000000000000000000000000000","seq":7,
"tool_id":"echo"}

SHA-256 of those canonical bytes:

9017de5e13e0a12b261e1960d0d0bc9220c6b7ef501b7dd189141e6327377664

Nothing else is mixed in — the Line hash is exactly SHA-256 over the canonical bytes.

11.2 A Session, end to end

Source: the committed goldens in crates/botzr-aegis-audit/tests/golden/, emitted through the real writer into one Session and signed with a fixed-seed development key. Each file in that directory is the canonical form of one Line, exactly as written to disk.

The Session is twelve Lines: open, intent, eight outcome Lines covering policy deny, rate limit, pending approval, capability deny, trap, resource exceeded, host panic and abandonment, then decision, then close.

Line 0 — session_open.json (canonical form, verbatim):

{"key_id":"77a2c2f5952039243c043b69e7e812a2deb69e3271adb3013b8f24d3b8ea40f6","line_type":"open","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","public_key":"3de537a06e04b2ffe1fb0558ea16d3c0f042ed99f7e392698aa5120f568d4e2c","schema_version":2,"seq":0,"signature":"f27b7f566bce4e0e8126a30dd951411a5ea82333b7b41740ee047f0382788b3239d382aa9506cf3ef7f34137cf9cd7f57388018c67a46b2c3285af8fcc2a7901"}

Derived values a third-party implementation must reproduce:

QuantityValue
key_id = SHA-256 of the 32 public-key bytes77a2c2f5952039243c043b69e7e812a2deb69e3271adb3013b8f24d3b8ea40f6
line_hash = SHA-256 of the canonical bytes aboveace4118a6a4ec1bf47503431aaa769ebb74de9c61174bf64470561a99abf4066
Line 1’s prev_hashthe same ace4118a…

Signing input for line 0 — the canonical form with signature omitted and key_id present. This is the exact byte string the ed25519 signature covers:

{"key_id":"77a2c2f5952039243c043b69e7e812a2deb69e3271adb3013b8f24d3b8ea40f6","line_type":"open","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","public_key":"3de537a06e04b2ffe1fb0558ea16d3c0f042ed99f7e392698aa5120f568d4e2c","schema_version":2,"seq":0}

Note that key_id is inside the signed bytes while signature is not, and that line_hash is taken over the form that does include signature — the two differ by exactly one member, and getting that backwards is the most likely implementation error in this specification.

Line 1 — intent.json, showing an unsigned Line inside the chain:

{"call_id":"call-golden-0","line_type":"intent","prev_hash":"ace4118a6a4ec1bf47503431aaa769ebb74de9c61174bf64470561a99abf4066","request_digest":"fb0288872031fc4818c03a7253bd3a78de192d05e6bccd09ceabeda65b4d7c6f","schema_version":2,"seq":1,"tool_id":"smoke"}

line_hash of line 1 is 5d923c33bede5bef42ee41556fed00fa0660ab30feb57355b3316a2263ffa7f9, which is line 2’s prev_hash — the unsigned Line is a full chain link, and the next signature covers it transitively.

Line 2 — policy_deny.json, the populated decision_axes shape:

{"call_id":"call-golden-1","capability":{"reason":"policy blocked before capability","status":"denied"},"decision_axes":{"capability":"fs.read","fs":{"path_canonical":"/fixtures/notes.md","path_raw":"./notes.md"},"matched_rule":"block-smoke","role":"ops","session":"sess-golden"},"execution":{"reason":"not executed","status":"host_denied"},"key_id":"77a2c2f5952039243c043b69e7e812a2deb69e3271adb3013b8f24d3b8ea40f6","line_type":"outcome","policy":{"reason":"blocked in test","status":"denied"},"policy_set_hash":"89a056813bdf93f95c1881a78793b1a86f5b6bab829c1ba9d20bb4add2aae921","prev_hash":"5d923c33bede5bef42ee41556fed00fa0660ab30feb57355b3316a2263ffa7f9","request_digest":"2baf1f40105d9501fe319a8ec463fdf4325a2a5df445adf3f572f626253678c9","schema_version":2,"seq":2,"signature":"470a273226ed3d32516e40fe136c03b684998ed17fa65ac0ed387c8074edf5aaf75b85f263ce2bdfa93fc4c083e0685e1a1a2d3ca42459f943c4dc3be698830c","tool_id":"smoke"}

This is the vector that matters for ADR-0001’s claim that a recorded deny can explain itself: role, capability, session and matched_rule are all on the Line, so the verdict can be reconstructed from the record alone without the request payload.

Note that its fs axis carries path_raw ./notes.md and path_canonical /fixtures/notes.md — two different strings, on purpose. The shipped emitter writes the same string into both today (§5.3); the vector diverges so that an implementation built from this document is exercised on the general case rather than on today’s coincidence. Do not read it as a sample of runtime output.

The remaining Lines — including session_close.json, and decision.json with an approved scope — are in the same directory and chain in the order listed above.

The signing key in these vectors is public and worthless. It is a fixed seed compiled into the test crate so that goldens are reproducible and its key_id is identical on every machine. A Line it signs proves that some Aegis build wrote it, never which — a verifier can only report Verified (unpinned) over these files. Never use it for anything.

11.3 Reproducing the vectors

Signatures are deterministic (ed25519), the seed is fixed, and the records are constants, so the whole Session is byte-reproducible:

cargo test -p botzr-aegis-audit --test golden

every_committed_golden_line_verifies_and_chains checks the committed files, not a freshly written Session, so a golden edited by hand fails there rather than passing as “expected output”.

11.4 Tamper vectors

Source: the committed files in crates/botzr-aegis-audit/tests/tampered/, one per Tampered class in §8.1, each read back and checked by crates/botzr-aegis-audit/tests/tampered.rs.

These files have a different shape from §11.2’s. There, one file is the canonical form of one Line. Here, one file is one whole Chain — seven of the eight classes are only meaningful in the context of the Lines around them, and the session-boundary class needs two Sessions in one file. The extension is .aarl (ADR-0014) for the same reason: these are whole record files, where a single Line is not one.

Every vector is signed by the same fixed-seed development key §11.2 publishes, so key_id 77a2c2f5… and public_key 3de537a0… are the values to expect throughout. The warning under §11.2 applies unchanged: that key is public and worthless.

File§8.1 classVerdict and reasonHow it is damaged
prev_hash_mismatch.aarlA Line’s prev_hash is not the hash of the Line before itTampered — chain broken at (session 0, seq 3)The close Line’s prev_hash is overwritten with 64 fs
seq_out_of_order.aarlseq repeated or went backwardsTampered — seq out of order in session 0: expected 4, found 1The unsigned intent Line is replayed at the end, keeping seq 1, with its hash link repaired
bad_signature.aarlA Line in the signed set carries no signatureTampered — bad signature at (session 0, seq 2): line carries no signatureThe close is dropped and the outcome Line it exposes loses its signature member
key_id_mismatch.aarlA Line’s key_id does not match the key its Session publishedTampered — bad signature at (session 0, seq 3): signed under dbc29825…, not 77a2c2f5…The close Line’s key_id is swapped for another key’s fingerprint; its signature is left untouched
session_boundary_broken.aarlAn open Line’s prev_session_tail does not match the previous Session’s final LineTampered — session boundary broken at session 1Two Sessions in one file, with the first Session’s close removed
malformed_line.aarlA Line outside the formatTampered — malformed line 3The bytes not json at all are inserted before the final Line
untrusted_key.aarlAn open Line publishes a key that is not in a trust slice the caller suppliedVerified with no trust slice; Tampered — untrusted key at (session 0, seq 0) against oneNot damaged at all. See below
duplicate_decision.aarlA second decision Line for an approval_id already decided in the fileTampered — duplicate decision at (session 0, seq 2) for apr-vector-8Two decision Lines are emitted for apr-vector-8, both validly signed

untrusted_key.aarl is a valid Chain, and that is the point. Its bytes contradict nothing; every signature verifies and every link holds. The finding exists only relative to a trust slice the caller supplied from outside the file, so the vector is the file plus that input. Verified with no trust slice it is Verified (unpinned); verified against any slice not containing 3de537a0… it is Tampered. The suite uses the key from a seed of thirty-two 0x09 bytes, whose fingerprint is dbc29825…. An implementation that reports Verified (unpinned) in the second case has ignored the key it was gated on, which is why this class is published rather than left implicit.

Two derived values for prev_hash_mismatch.aarl, since a chain break is the one class where an implementer needs both halves to confirm they are looking at the same contradiction:

QuantityValue
What the close Line claims as prev_hashffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
What the Line before it actually hashes to9a2c3c9f6c9f58effd1636bbb31c30fad2fcbcbd25ea5d2679c835740370cb9c

bad_signature.aarl in full, as the smallest vector — three Lines, and the damage is an absence rather than a substitution, so it reads clearly:

{"key_id":"77a2c2f5952039243c043b69e7e812a2deb69e3271adb3013b8f24d3b8ea40f6","line_type":"open","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","public_key":"3de537a06e04b2ffe1fb0558ea16d3c0f042ed99f7e392698aa5120f568d4e2c","schema_version":2,"seq":0,"signature":"f27b7f566bce4e0e8126a30dd951411a5ea82333b7b41740ee047f0382788b3239d382aa9506cf3ef7f34137cf9cd7f57388018c67a46b2c3285af8fcc2a7901"}
{"call_id":"call-vector-3","line_type":"intent","prev_hash":"ace4118a6a4ec1bf47503431aaa769ebb74de9c61174bf64470561a99abf4066","request_digest":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","schema_version":2,"seq":1,"tool_id":"echo"}
{"call_id":"call-vector-3","capability":{"reason":"not evaluated","status":"denied"},"decision_axes":{},"execution":{"status":"success"},"key_id":"77a2c2f5952039243c043b69e7e812a2deb69e3271adb3013b8f24d3b8ea40f6","line_type":"outcome","policy":{"status":"allowed"},"policy_set_hash":"d19fc5aa391e8220c634b40bef26894b83ae1493f8a8628e8ab667a6fd7e0e67","prev_hash":"de66ac5fac6432fca53cebba4fb926d54dd31aac03a6c4f721198ee601d1fdb2","request_digest":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","schema_version":2,"seq":2,"tool_id":"echo"}

The third Line is an outcome with no signature member, sitting in the unverified tail. §8.3 is what makes that decidable: the tail may hold intent Lines and at most one torn final Line, never an outcome. Note that it kept its key_id: a verifier that decides which Lines are signed by looking for a key_id will not notice that this one’s signature is gone.

Reproducing them. Same properties as §11.3 — fixed seed, deterministic signatures, constant records — so every vector is byte-reproducible:

cargo test -p botzr-aegis-audit --test tampered

every_committed_tamper_vector_is_byte_reproducible compares each committed file against a freshly built one, so a vector edited by hand fails there rather than passing as its own expected output.

11.5 Indeterminate vectors

Source: the committed files in crates/botzr-aegis-audit/tests/indeterminate/, one per Indeterminate class in §8.2, each read back and checked by crates/botzr-aegis-audit/tests/indeterminate.rs.

Same file shape as §11.4 — one file is one whole Chain, with the .aarl extension (ADR-0014) — and signed by the same fixed-seed development key §11.2 publishes, so key_id 77a2c2f5… and public_key 3de537a0… are the values to expect here too. empty_chain.aarl is the exception: it has no Lines to sign.

These are published because §8’s central claim is that a binary pass/fail verdict is wrong, and Indeterminate is the verdict a naive implementation collapses into one of the other two. A verifier with only Verified and Tampered passes every vector in §11.2 and §11.4 while being exactly the two-state verifier §8 argues against. It fails here, on all six.

File§8.2 classVerdict and reasonHow it is damaged
unknown_line_type.aarlUnknown line typeIndeterminate — unknown line type attestation at (session 0, seq 4)A closed Session with one extra final Line whose line_type is a token this version does not define, carrying a correct seq and prev_hash and no signature member
reserved_checkpoint.aarlReserved checkpointIndeterminate — reserved checkpoint at (session 0, seq 4)Not damaged. A closed Session with one extra final checkpoint Line whose signature verifies. See below
torn_final_line.aarlTorn final LineIndeterminate — final Line 5 does not parseA closed Session with a Line cut off mid-write appended after it, and no trailing newline — a torn write ends mid-Line
unanchored_tail.aarlUnanchored tailIndeterminate — session 0 has no close record; 1 Call in flight, call-unanchored-tailA Session’s outcome and close are both dropped, leaving open and intent
missing_line.aarlMissing LineIndeterminate — session 0 skips seq 2..3 with the chain intactThe outcome append was refused by the Sink, so seq 2 was consumed and never written; the Session then closed at seq 3
empty_chain.aarlEmpty chainIndeterminate — no Lines to verifyNot damaged. A zero-byte file

Two of these files are not damaged, and that is the point of publishing them. empty_chain.aarl is absent rather than corrupt: nothing in it contradicts anything, which is precisely why an implementation is most likely to collapse it into Verified. reserved_checkpoint.aarl is a well-formed, correctly signed Line this version cannot interpret — checkpoint is reserved and unemitted (§5.1), and a verifier does not pass content it cannot read.

The Tampered siblings of the first two classes are rules, not files. An unknown line type whose signature is present and does not authenticate the Line is Tampered (§5.2, §8.1), and so is a checkpoint whose signature does not verify (§8.4): forgery is decidable without understanding a Line, and it outranks the cap. Neither sibling has a published vector — those two cases are stated as rules and must be read there. This is why unknown_line_type.aarl carries no signature member at all, and why reserved_checkpoint.aarl’s signature verifies. A vector on the wrong side of either rule would document the opposite class.

One Line in this set was not produced by an emitter, and the specification should say which. reserved_checkpoint.aarl’s final Line is assembled directly and signed with the same primitives the writer signs with, because there is no checkpoint emitter to call and adding one to produce a fixture would be inventing format surface to satisfy a test. Its signing input follows §11.2’s rule unchanged — the canonical form with signature omitted and key_id present — which is what makes it checkable rather than self-consistent. unknown_line_type.aarl’s final Line is likewise assembled directly, and carries no signature at all, so nothing about signing is being restated there. Every other Line in every vector above came out of the real writer.

Reproducing them. Same properties as §11.3 — fixed seed, deterministic signatures, constant records — so every vector is byte-reproducible:

cargo test -p botzr-aegis-audit --test indeterminate

every_committed_indeterminate_vector_is_byte_reproducible compares each committed file against a freshly built one, so a vector edited by hand fails there rather than passing as its own expected output.


12. Versioning and compatibility

schema_version is 2. Version 1 is not compatible: phase became line_type, input_digest became request_digest, the chain and signature fields are new, two line types became six, and hashing is now specified.

Within version 2, an emitter MAY add fields and MAY add line types. A verifier:

  • MUST ignore members it does not recognise, and MUST still hash the whole Line.
  • MUST NOT report Verified over a line_type it does not recognise (§5.2).
  • MUST treat a Line missing line_type, seq or prev_hash as a format violation, not as an unknown extension (§5).

A change to the canonicalization rules, to the signing input, or to what line_hash covers is a version bump, not an addition — each one silently changes every hash in every file.

This document has a version of its own, and it is not the wire version. schema_version is the only version on the wire: it is the number inside a record, and it is the one a consumer keys off. No implementation should ever need to know which revision of this document it was written from in order to parse a file.

The document version — v0.1, on the status line at the top — moves whenever this document changes materially, and that includes clarifications which break no compatibility and therefore do not move schema_version. The two consequently run at different rates, and they are linked in one direction only: a schema_version bump always implies a document version bump, because a document that describes the wire format cannot describe a new one without changing. The converse does not hold, so a reader who infers a format change from a document version has inferred wrongly.

Cite both when it matters. “Conforms to schema_version 2” states what an implementation reads; “written against document v0.1” states which description of it the author had.

12.1 Downstream consumers

The Layer 2 governance ingest in governance/ targets this version (AILAB-624) and rejects version 1. It consumes intent and outcome lines; open, close, decision, checkpoint and any line type a newer emitter adds are skipped and counted per §5.2, never treated as corruption. It parses signatures without checking them — it is a consumer, not a verifier. See governance/DECISIONS.md D25.

Test packages that exercise this format: aegis-deny-suite, aegis-stress-suite, aegis-api-surface, aegis-adversarial-demo, aegis-stage2-demo.

Runtime crates that define and produce it: botzr-aegis-core (types, digests, canonicalization), botzr-aegis-audit (writer, signing, verdict), botzr-aegis-policy (policy_set_hash), botzr-aegis-runtime (decision axes).

Benchmarks

Published numbers on cited hardware. No number ships without a bench behind it, and no bench with a missed target is left out of this page. How to run the suite: benches/README.md.

Targets and status

Every target the project has set, and where it stands. Do not conflate these into a single threshold — they measure different things.

ScopeGroupTargetStatus
Policy eval alonepolicy_eval/allow_all, policy_eval/multi_rule< 100 µspass
Combined policy + capabilityhot_path/multi_rule< 1 mspass
Warm cell instantiationinstantiation/warm< 0.5 mspass
Cold instantiationinstantiation/cold< 5 msmiss — 39.490 ms, ~7.9× over
ed25519 line signingaudit_signing/sign_outcome_line< 50 µspass
Wrap relay per recorded tools/callwrap_relay/tools_call_recorded0.5–2 ms (informational)miss — 4.371 ms/call, ~2.19× over
Audit emissionaudit_emission/begin_completeno target set (fsync-bound)n/a
Rate-limit path, capability alone, attribution splits—informational onlyn/a

Both misses are stated rather than retired quietly. Cold instantiation is dominated by a Cranelift compile of the component (~29.4 ms), not by engine setup (~1–2% of the median), so the 5 ms target does not survive contact with an AOT compile of this size; Execution Report §7 was amended rather than the measurement reshaped. Wrap’s per-call cost is two sync_all calls under the shipped G3 durability default, which alone cost ~4.2 ms on this filesystem — reaching 2 ms needs a batched-or-deferred-fsync decision, not tuning. Neither number was improved by narrowing a bench or adding private-API-shaped surface (PRD §10).

The three results files follow, verbatim.


Hot-path Criterion results (AEG-16 / OQ-15 T5)

Hardware / OS

uname -a
Linux botzrDev 6.6.87.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Thu Jun  5 18:30:46 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux

lscpu (selected):
Architecture:                         x86_64
Model name:                           AMD Ryzen AI 5 340 w/ Radeon 840M
CPU(s):                               4
Thread(s) per core:                   2
Core(s) per socket:                   2
Hypervisor vendor:                    Microsoft
Virtualization type:                  full

rustc -Vv:
rustc 1.96.0 (ac68faa20 2026-05-25)
host: x86_64-unknown-linux-gnu

Environment notes: WSL2 / Linux; Criterion 0.5.1; Gnuplot not installed (plotters backend). Date: 2026-07-09.

Command

cargo bench -p botzr-aegis-policy -p botzr-aegis-capability -p botzr-aegis-runtime

Summary vs targets

GroupMedian (approx.)TargetStatus
policy_eval/allow_all13.4 ns< 100 µspass
policy_eval/multi_rule31.8 ns< 100 µspass
policy_eval/rate_limit183 nsinformational (mutex)n/a
capability_resolve/registered_tool2.43 µsno hard gaten/a — bench id and measured path both changed 2026-08-24; see AILAB-707 below
hot_path/allow_all2.67 µs(floor)n/a — superseded 2026-09-07; see AILAB-846 below
hot_path/multi_rule (stations 1–2 only — not the cost of a call)2.71 µs< 1 mspass — superseded 2026-09-07; see AILAB-846 below

Criterion reports [lower median upper] as time/op; medians above are the middle sample.

What the last two rows are not. hot_path mirrors stations 1–2 and calls neither sandbox, audit nor wasmtime inside b.iter — that non-goal is deliberate and still stands. So 2.71 µs is a component figure, not what an audited call costs, and it must not be quoted as one: the same call against the shipped Volatile sink is roughly an order of magnitude more, and against a Durable sink a further two to three orders on top of that — a span, not a figure, because the Durable arm did not reproduce across two sessions. Those are the audited_call group in cell_and_audit.md, which is where the end-to-end number lives.

Criterion text tables

capability_resolve/registered_tool
                        time:   [2.3959 µs 2.4329 µs 2.4751 µs]

policy_eval/allow_all   time:   [13.240 ns 13.408 ns 13.582 ns]
policy_eval/multi_rule  time:   [31.025 ns 31.775 ns 32.898 ns]
policy_eval/rate_limit  time:   [181.31 ns 182.74 ns 184.38 ns]

hot_path/allow_all      time:   [2.6091 µs 2.6727 µs 2.7417 µs]
hot_path/multi_rule     time:   [2.6453 µs 2.7136 µs 2.7872 µs]

Notes

  • Combined hot path is dominated by capability grant minting (~2.4 µs alone); policy eval is tens of nanoseconds.
  • Rate-limit path is ~6× multi-rule policy eval but still far below 100 µs; kept informational because it takes a Mutex.
  • HTML reports live under target/criterion/ and are not committed.

AILAB-707 update — 2026-08-24

AILAB-707 routed both benchmarks off CapabilityResolver::register, which is #[deprecated] as a cross-crate visibility fence. A published benchmark that suppressed a deprecation to reach a forbidden path was the claim-integrity defect; the suppressions are gone. The table above is not retroactively edited — it records what was measured on 2026-07-09, under the ids that existed then.

What changed in each measured path

  • capability_resolve/registered_tool → capability_resolve/mint_from_manifest. Renamed because the tool is genuinely no longer registered. botzr-aegis-capability cannot reach Runtime::register_tool (runtime depends on capability, so the reverse edge is a dependency cycle), so the bench moved to resolve_manifest, the supported one-off mint route shipped for aegis wrap --confine. Two pieces of work left the measured path: the HashMap<ToolId, ToolManifest> registry lookup, and the ResourceCeiling::combine fold. Ceiling semantics are unchanged — the old call passed ResourceCeiling::default() and the standing ceiling is also default, so combine was a no-op. The number should therefore have moved slightly down.
  • hot_path/allow_all, hot_path/multi_rule — measured work unchanged. The bench lives inside botzr-aegis-runtime, so it now registers through Runtime::register_tool in setup and measures against rt.capabilities(). The closure still runs evaluate → decision.limits → resolve_with_ceiling, the registry is still a one-entry map, and registration is setup, never measured. The fixture manifest’s ToolKind became Host so the handler matches; nothing on the production mint path branches on kind (every ToolKind in capability’s mint.rs / narrow.rs is inside #[cfg(test)], and policy never reads it). These two numbers should therefore not move at all.

Measurements — and why they are not a re-baseline

Hardware / OS as above, except the toolchain: rustc 1.86.0 (05f9846f8 2025-03-31), LLVM 19.1.7, on Linux 6.6.87.2-microsoft-standard-WSL2, AMD Ryzen AI 5 340 (4 CPU / 2 cores), Criterion 0.5.1, plotters backend. The 2026-07-09 table above was taken on rustc 1.96.0, so it is not a valid before-value for this change.

Medians, µs. Pre-change was measured on clean 746ae42 before any edit; runs 1–3 are three consecutive runs of the same post-change binary.

Benchpre-changepost r1post r2post r3
capability_resolve/mint_from_manifest2.57312.64722.58332.6314
hot_path/allow_all2.62383.69662.77642.7480
hot_path/multi_rule2.72862.87553.40962.9367

Between those identical-code runs Criterion reported, in order: +42% “regressed”, −20% “improved”, +12% “regressed”, −10% “improved”, +7% “regressed”, −5.6% “improved” — all at p < 0.05. A benchmark whose measured work provably did not change (hot_path/*) swung by 42 points between runs of the same binary.

Conclusion: no change is attributable to the edit, because this box cannot resolve an effect of the expected size. The run-to-run noise floor here is roughly ±20%, and the predicted effects are one hash lookup and three Option<u64> mins. Note also that capability_resolve moved up ~2% while the work it does strictly decreased — the sign is wrong, which is itself evidence that noise dominates.

The published medians above are therefore left as they are rather than overwritten with these runs: replacing a measurement with a noisier one, taken on a different toolchain, would publish noise as evidence. A clean re-baseline of the whole suite — quiet machine, pinned toolchain, all groups — is owed and is not this ticket’s scope.


AILAB-846 update — canonicalizing at registration, 2026-09-07

What changed, and which of these groups can see it. Capability resolution used to std::fs::canonicalize every path a manifest declared, on every call. It now happens once, when the tool is registered, and resolution reuses the result. Exactly one of the two groups below runs that code:

  • hot_path/* registers through Runtime::register_tool in setup and calls resolve_with_ceiling inside b.iter — the changed path.
  • capability_resolve/mint_from_manifest calls resolve_manifest, which mints from a manifest the caller already holds and has no registration to have prepared. That path still canonicalizes per call, deliberately: it is what aegis wrap --confine uses. It is the control here, not the result.

Hardware / OS as in the block at the top of this file, with the toolchain stated rather than inherited: rustc 1.86.0 (05f9846f8 2025-03-31) — the version rust-toolchain.toml pins — on Linux 6.6.87.2-microsoft-standard-WSL2, AMD Ryzen AI 5 340 (4 CPU / 2 cores), WSL2, Criterion 0.5.1, plotters backend, bench profile. The 2026-07-09 table above was taken on rustc 1.96.0 and is not a valid before-value; the before column here was measured today, from a worktree at 9b1d484, in the same session and on the same toolchain as the after column.

Criterion [lower median upper]:

Benchbefore (9b1d484)aftermedian delta
hot_path/allow_all[2.2905 2.3150 2.3428] µs[221.18 224.61 228.43] ns−90.3% (10.3×)
hot_path/multi_rule[2.6515 2.7314 2.8171] µs[259.60 263.44 267.90] ns−90.4% (10.4×)
capability_resolve/mint_from_manifest (control — unchanged path)[2.2668 2.2997 2.3351] µs[2.4060 2.4457 2.4908] µs+6.3%

Why these are readable when AILAB-707’s were not. That section, above, measured a predicted effect of one hash lookup and three Option<u64> mins against a ±20% run-to-run noise floor, and correctly concluded nothing was attributable. Here the effect is 10×: fifty times the noise floor, and the two arms move in opposite directions in the way the change predicts — the group that stopped touching the filesystem fell by an order of magnitude, and the group that still touches it did not move outside noise. The control’s +6.3% is the wrong sign for a speed-up and is exactly what a control should do. The before-value is also corroborated rather than a single sample: AILAB-707’s pre-change hot_path/multi_rule on this same toolchain was 2.7286 µs, against 2.7314 µs measured today.

What the saving is, and what it is not. It is 2.47 µs off stations 1–2. Stations 1–2 are not a call — that is the label the row above carries and it still applies. Against the end-to-end figures in cell_and_audit.md, 2.47 µs is:

  • 6.6 – 7.5% of an audited call against the shipped Volatile sink (32.9 – 37.4 µs)
  • 0.014 – 0.086% of an audited call against a Durable sink (2.86 – 17.5 ms)

So a call did not get ten times faster and no sentence here should be read that way. The 10× is a component figure for a component that is a small and, on the durable arm, negligible share of what a call costs. This work was queued for the grant-id spelling gap and the process-global counter; the speed-up is a side effect of doing it, not the case for it.

Provisional, like everything else in this directory. These were taken on a box that swings ±20% on identical binaries and was doing other work at the time. The shape — an order of magnitude on the changed path, noise on the control — is what survives; the digits are not a baseline. AILAB-796 is the ticket that re-measures on a quiet machine.


Cell instantiation + audit emission Criterion results (AEG-005 / AILAB-683)

Covers the two AEG-005 rows hot_path.md deliberately does not: wasmtime instantiation (warm and cold) and audit record emission. Policy and capability numbers stay in hot_path.md.

Hardware / OS

uname -a
Linux botzrDev 6.6.87.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Thu Jun  5 18:30:46 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux

lscpu (selected):
Architecture:                         x86_64
Model name:                           AMD Ryzen AI 5 340 w/ Radeon 840M
CPU(s):                               4
Thread(s) per core:                   2
Core(s) per socket:                   2
Hypervisor vendor:                    Microsoft
Virtualization type:                  full

rustc -Vv:
rustc 1.86.0 (05f9846f8 2025-03-31)
host: x86_64-unknown-linux-gnu

Environment notes: WSL2 / Linux; Criterion 0.5.1; Gnuplot not installed (plotters backend); wasmtime 36.0.13 and wasmtime-wasi 36.0.12 (Cargo.lock); bench profile (optimized). Date: 2026-08-10.

Toolchain differs from hot_path.md. That file cites rustc 1.96.0; this run used the repo-pinned 1.86 (rust-toolchain.toml). Numbers across the two files are not strictly comparable.

Command

cargo bench -p botzr-aegis-sandbox -p botzr-aegis-audit

Summary vs targets

GroupMedianTargetStatus
instantiation/warm49.339 µs< 0.5 mspass (~10× under)
instantiation/cold39.490 ms< 5 msfail (~7.9× over)
instantiation/cold_engine_only456.08 µsinformationaln/a
instantiation/cold_compile_only29.351 msinformationaln/a
audit_emission/begin_complete4.7185 msnone, deliberately — see belown/a
audit_emission/serialize_only468.46 nsinformationaln/a

Criterion reports [lower median upper] as time/op; medians above are the middle sample. All six come from a single process run.

Criterion text tables

instantiation/warm      time:   [48.175 µs 49.339 µs 50.624 µs]
instantiation/cold      time:   [36.838 ms 39.490 ms 42.364 ms]
instantiation/cold_engine_only
                        time:   [438.01 µs 456.08 µs 478.53 µs]
instantiation/cold_compile_only
                        time:   [28.335 ms 29.351 ms 30.455 ms]

audit_emission/begin_complete
                        time:   [4.6635 ms 4.7185 ms 4.7730 ms]
audit_emission/serialize_only
                        time:   [462.98 ns 468.46 ns 474.43 ns]

Stability across runs

This box is noisy for the compile-bound groups, so the verdicts below rest on four consecutive runs rather than the single published one. Blank cells are groups that did not exist yet in that run.

Runwarmcoldcold_engine_onlycold_compile_onlybegin_complete
154.519 µs27.799 ms——4.6620 ms
257.894 µs37.237 ms412.58 µs29.806 ms—
354.493 µs29.700 ms441.35 µs29.601 ms5.0875 ms
4 (published)49.339 µs39.490 ms456.08 µs29.351 ms4.7185 ms

cold swings 27.8–39.5 ms (~±20% around its mean) while its two components are comparatively stable — engine construction 412–456 µs, compile/link 29.4–29.8 ms. In run 4 the components sum to ~29.8 ms against a 39.5 ms cold median, i.e. the splits do not reconcile with cold run-for-run; only the per-component medians are stable enough to attribute with. Both verdicts hold in every run regardless: warm clears 0.5 ms by 8.6–10.1×, cold misses 5 ms by 5.6–7.9×.

What each group actually measures

instantiation/warm — includes a WIT run and a tokio runtime, not instantiation alone. PreparedTool.tool_pre is private and there is deliberately no public instantiate-only API (adding one to make a bench possible is forbidden by PRD §10), so the warm iteration goes through SandboxEngine::execute. That is, per iteration:

  1. block_on runs the guest future on the engine’s tokio runtime (crates/botzr-aegis-sandbox/src/engine.rs). These numbers were measured before AILAB-809, when a fresh current-thread runtime was built per call and was therefore inside the median; the engine now builds one runtime in SandboxEngine::new and reuses it, so this step is amortized and the figure below is an upper bound on today’s cost (re-measuring is AILAB-796’s);
  2. build_store — WASI ctx from the grant, memory limiter, epoch deadline;
  3. tool_pre.instantiate_async — the actual warm instantiation;
  4. the WIT run export.

The fixture is tests/fixtures/echo-tool/echo.wasm, whose run is an identity copy of a 4-byte input, so guest-side work is negligible — but the canonical-ABI lower/lift of list<u8> is inside the median. 49.339 µs is an upper bound on warm re-instantiation, not an isolated instantiate cost, and the four components above were not separated because doing so would require new public API.

The warm cache being measured is PreparedTool { tool_pre } (crates/botzr-aegis-sandbox/src/engine.rs:160-161), populated by linker.instantiate_pre at engine.rs:81 and re-instantiated at engine.rs:286-289. The InstancePre in PreparedFixture is a different, #[cfg(feature = "test-utils")] object and is not what these numbers cover.

instantiation/cold — fresh Engine + compile/link, no execute. SandboxEngine::new() + prepare(ECHO) per iteration. It deliberately does not call execute, so cold-compile cost is never conflated with warm instantiation-from-cache.

Why cold misses 5 ms: it is prepare, not engine setup. Engine construction — config, linker, WASI link, epoch ticker spawn, plus the ticker join that Drop pays — is 456.08 µs, roughly 1–2% of every cold median observed. prepare on an already-built engine is 29.351 ms: that is Component::new (Cranelift compilation of the 62 KB wit-bindgen component) plus linker.instantiate_pre link resolution plus ToolPre::new, dominated by the Cranelift compile but not exclusively it. There is no epoch-ticker thread storm. The 5 ms target does not survive contact with an AOT compile of a component of this size on 4 vCPUs; reaching it needs a different mechanism, not tuning. Execution Report §7 was amended accordingly rather than the measurement being reshaped to fit.

audit_emission/begin_complete — the full two-line durable cycle. CallSession::begin (emits the intent line) → set policy allowed / capability granted / execution success / metrics → complete (emits the outcome line). Two JSONL lines means two sync_all calls (crates/botzr-aegis-audit/src/writer.rs:74); the fsync-per-line is the shipped G3 durability default and was not stripped for the bench. One writer is reused across iterations so TempDir creation is not timed.

The cycle is fsync-bound, and that is measured rather than assumed. serialize_only runs the same two records through the already-public to_json_line with no file write and no fsync: 468.46 ns, or ~0.01% of the 4.7185 ms cycle. Serialization is not a meaningful cost here; essentially the entire median is write + fsync latency on WSL2’s filesystem. Expect it to move by an order of magnitude on a different filesystem, and do not cite it as an Aegis-side overhead figure.

There is no target on that row, and the blank is a decision rather than an omission (AILAB-851). serialize_only settles what the median is made of: 468.46 ns of Aegis-side work inside a 4.7185 ms cycle, so ~99.99% of it is write + fsync latency on WSL2’s filesystem. A number in that column would therefore be a durability target for someone else’s storage stack — it would pass on an NVMe host and fail on a network mount without a line of this repository changing, which is a claim about the hardware wearing a claim about Aegis. The sentence above already forbids citing this figure as Aegis-side overhead; a target would quietly license exactly that. What the row is for is attribution, and it does that job with serialize_only beside it. If a durability target is ever wanted it belongs on a named storage profile, not on this row.

Isolated ed25519 signing (AILAB-620)

Added because the previous section makes the emission cycle unusable as a crypto number: at 4.7185 ms it is a measurement of fsync, and quoting a 50 µs signing target against it would be a claim about the filesystem. This group measures one thing — SigningKey::sign over bytes already in hand.

Hardware / OS / toolchain: identical to the run above (AMD Ryzen AI 5 340, 4 vCPUs, WSL2 Linux 6.6.87.2, rustc 1.86.0, Criterion 0.5.1, bench profile, plotters backend). Date: 2026-08-11.

cargo bench -p botzr-aegis-audit --bench sign
GroupMedianTargetStatus
audit_signing/sign_outcome_line13.765 µs< 50 µs (AILAB-620)pass (~3.6× under)
audit_signing/sign_outcome_line
                        time:   [13.641 µs 13.765 µs 13.907 µs]
Found 4 outliers among 100 measurements (4.00%)
  4 (4.00%) high mild

Per iteration: one ed25519 signature over the canonical signing input of a representative outcome line — the JCS form with signature absent and key_id present (ADR-0003), built once outside the loop. No AuditWriter, no file, no fsync, no canonicalization inside the measured region, so the median is the signature and nothing else. The key comes from insecure_dev_key: signing cost is a property of ed25519, not of which 32 bytes the seed holds, and a fixed seed keeps the bench deterministic. Nothing here reads a key file.

What this does and does not license. 13.765 µs is the cost of signing a line. It is not the cost of emitting one — that is begin_complete above, and it is ~340× larger because of two sync_all calls. Adding signing to the shipped emit path is therefore invisible against fsync; that is a statement about how expensive durability is, not about how cheap ed25519 is. Cite the two numbers together or neither.

An audited call, end to end (AILAB-851)

Added because nothing here or in hot_path.md measured a call. hot_path/multi_rule is stations 1–2 at 2.71 µs; audit_emission/begin_complete is the two-line durable cycle on its own. Neither is what one Runtime::execute_host_call costs, and the two do not add up to it either. This group drives the whole pipeline — POLICY → CAPABILITY → SANDBOX → AUDIT — with a host handler as the executable, so wasmtime compilation stays in instantiation/cold where it belongs.

It lives in this file rather than a fourth one because benches/README.md declares exactly three scopes and all three are included verbatim into the book’s Benchmarks chapter. This file already owns audit record emission; an audited call is that scope one level up.

Two arms, one variable. Both runtimes are built identically and differ only in the sink: durable_sink is a FileChainSink with a provisioned key — the configuration that retains evidence, and the one a Durable sink requires a real key for (ADR-0012) — and volatile_sink is the shipped default, an in-memory Chain that emits, signs and chains every record and then keeps none of it past the process.

EVERY NUMBER BELOW IS PROVISIONAL

This box swings ±20% on identical binaries — the instantiation/cold table above is the evidence — and these were taken while it was doing other work. For this group there is now direct evidence rather than an inherited caution: re-running it in a second independent session on the same box did not reproduce the first session’s durable_sink figures at all. The two sessions’ ranges do not overlap, a 6.1× spread across six runs, and the table below records every one of them. AILAB-796 is the ticket that re-measures on a quiet machine, and until it lands nothing here should be quoted as a median. What is worth reading is the shape: two to three orders of magnitude between the two sinks.

Hardware / OS / toolchain: identical to the first run in this file (AMD Ryzen AI 5 340, 4 vCPUs, WSL2 Linux 6.6.87.2, rustc 1.86.0, Criterion 0.5.1, bench profile, plotters backend). Date: 2026-09-01.

cargo bench -p botzr-aegis-runtime --bench audited_call
GroupRange across six runs (provisional)TargetStatus
audited_call/durable_sink2.86 – 17.5 ms — spans 6.1× across six runs in two sessions; not reproduciblenone — see begin_complete aboven/a
audited_call/volatile_sink32.9 – 37.4 µs — reproduces within 1.14×none setn/a
audited_call/durable_sink
                        time:   [3.0238 ms 3.2586 ms 3.5437 ms]
Found 2 outliers among 20 measurements (10.00%)
audited_call/volatile_sink
                        time:   [34.153 µs 34.432 µs 34.799 µs]
Found 2 outliers among 20 measurements (10.00%)

Stability across six runs in two independent sessions

Two independent sessions on the same box, three runs each. Nothing about the binary, the hardware or the bench parameters differs between them.

RunSessiondurable_sinkvolatile_sinkratio
12026-09-013.2586 ms34.432 µs94.6×
22026-09-012.8585 ms34.791 µs82.2×
32026-09-013.7153 ms32.889 µs113.0×
42026-09-0717.505 ms34.889 µs501.7×
52026-09-079.832 ms37.370 µs263.1×
62026-09-0713.895 ms33.566 µs414.0×

The volatile arm is the only reproducible number here, and the ratio is the least stable thing in the group. volatile_sink holds inside 1.14× across all six runs and both sessions. durable_sink spans 6.12×, and the two sessions do not merely differ — their ranges do not overlap at all, the second session’s fastest run being 2.6× the first session’s slowest. Because the durable term dominates the quotient, the ratio inherits every bit of that variance and spans 82×–502×. So the honest claim is two to three orders of magnitude, not a figure, and no median for the durable arm belongs in this file at all. Anything tighter needs AILAB-796.

What the pair shows

The configuration that is fast is the configuration that retains nothing. Both arms emit two signed, chained records per call; the only difference is whether those records survive the process. That difference spans 82×–502× across the six runs above — and it is almost entirely fsync, for the reason serialize_only established above: serialization is 468 ns against a millisecond-scale cycle. Evidence that outlives the process costs two sync_all calls, and no amount of Aegis-side tuning touches that number.

It also puts hot_path/multi_rule in proportion. Stations 1–2 are 2.71 µs; the same call reaching AUDIT against the unretained default is 34.4 µs, about 13× more; against a retaining sink it is 2.86–17.5 ms, another 82×–502× beyond that. The published 2.71 µs covers well under a percent of a durable call and must not be quoted as its cost.

The cross-check that would not reconcile, and its resolution

An end-to-end audited call appeared to measure less than audit_emission/begin_complete (3.2586 ms against 4.7185 ms) while doing strictly more work: the pipeline emits the same two lines through CallSession::begin and complete (crates/botzr-aegis-runtime/src/pipeline.rs), and adds policy, capability, the request digest and handler dispatch on top. That is not possible if both figures measure the same fsync work under the same conditions, so at least one of them had to be non-comparable.

The six-run table above resolves it, and the split falls exactly on the session boundary. All three runs from 2026-09-01 land below begin_complete’s 4.7185 ms (3.2586, 2.8585, 3.7153 ms); all three from 2026-09-07 land above it (17.505, 9.832, 13.895 ms). Neither figure was wrong when it was taken — the first session simply caught a quiet fsync window. The two numbers are not separable at this measurement precision: the fsync term dominates both, and it varies by more than the gap between them. The earlier explanation that emission.rs writes a larger fixture record than this group’s grant remains true, but it is not needed to account for the discrepancy and cannot be measured apart from the noise.

That this was answerable one session later is entirely because the failure was published instead of omitted — a number that does not reconcile is evidence about the measurement, and hiding it would have left the next reader to rediscover it. AILAB-796 should still re-measure both in one process run.

Why the sample budget is capped

MemoryChainSink holds an Arc<Mutex<Vec<u8>>> and never truncates, so the volatile arm retains every byte it emits for the life of the bench process. At Criterion’s default budget it would append hundreds of megabytes and start measuring the allocator. The group therefore sets a 500 ms warm-up, a 2 s measurement window and 20 samples; peak RSS for the run above was 124 MB. Both arms get the same budget, so the comparison is unaffected — but the sample count is lower than the rest of this file, which is a second reason to treat these as provisional.

Notes

  • The sandbox crate’s benches are compiled with the test-utils feature on, because a pre-existing self dev-dependency (crates/botzr-aegis-sandbox/Cargo.toml:43) enables it for all dev targets so tests/sandbox.rs can reach the fixture API. The bench uses no test-utils item: the feature gates only additive prepare_fixture / execute_fixture / PreparedFixture, none of which is on the measured prepare / execute path.
  • No public API was added to either crate to make these benches possible. serialize_only uses to_json_line, which was already exported.
  • HTML reports live under target/criterion/ and are not committed.

aegis wrap relay overhead Criterion results (AILAB-625)

One number, measured end to end: what it costs to put aegis wrap between an MCP client and a stdio child server. Per iteration a whole run_wrap_with_streams session runs — signing key loaded, AuditWriter opened, child spawned, 50 scripted JSON-RPC lines relayed and answered, child reaped, close line written.

What it deliberately does not cover. This is not a confinement cost: this run sets confinement: None, so wrap records without confining and the capability/sandbox stations never run here. It is not a policy or wasmtime number either — those live in hot_path.md and cell_and_audit.md. It is not a portable figure: a recorded call is two JSONL lines and therefore two sync_all calls, so the median is dominated by this filesystem. And it is not a measure of any real MCP server’s work — the child is the in-repo mirror fixture, which answers from a match.

Hardware / OS

uname -a
Linux botzrDev 6.6.87.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Thu Jun  5 18:30:46 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux

lscpu (selected):
Architecture:                         x86_64
Model name:                           AMD Ryzen AI 5 340 w/ Radeon 840M
CPU(s):                               4
Thread(s) per core:                   2
Core(s) per socket:                   2
Hypervisor vendor:                    Microsoft
Virtualization type:                  full

rustc -Vv:
rustc 1.86.0 (05f9846f8 2025-03-31)
host: x86_64-unknown-linux-gnu
LLVM version: 19.1.7

Environment notes: WSL2 / Linux; Criterion 0.5.1 (Cargo.lock); Gnuplot not installed (plotters backend); bench profile (optimized); repo-pinned toolchain 1.86 (rust-toolchain.toml). Date: 2026-08-12.

Same box and same toolchain as cell_and_audit.md, so the audit crate’s audit_emission/begin_complete (4.7185 ms, 2026-08-10) is directly comparable and is used below as the reference for the fsync cost sitting inside every recorded call.

Command

cargo bench -p botzr-aegis-wrap

Summary vs targets

Criterion reports [lower median upper] per iteration (50 calls); Throughput::Elements(50) turns the thrpt row into the per-call figure. Per-call medians below are the published iteration median ÷ 50, which agrees with the reciprocal of the thrpt median to four significant figures.

GroupMedian / session (50 calls)Median / callTargetStatus
wrap_relay/tools_call_recorded218.54 ms4.371 ms0.5–2 ms (informational)miss (~2.19× over the 2 ms ceiling)
wrap_relay/ping_relayed_only6.8027 ms136.05 µsinformational (attribution baseline)n/a

Derived attribution — recording, per recorded call: 4.235 ms. (218.54 ms − 6.8027 ms) ÷ 50. Everything the two groups share — key load, AuditWriter::open, child spawn, 50 relayed lines and 50 responses, reap, close — cancels in the subtraction, leaving the per-call intent + outcome pair. That 4.235 ms lands within 11% of the audit crate’s own two-line begin_complete cycle (4.7185 ms), which is the expected result if wrap’s recording is exactly two durable lines and nothing else.

The miss is the honest outcome, not a regression. The 0.5–2 ms target (spec §1.9 / §3.4) is informational and predates the measurement. Two sync_all calls on this filesystem cost ~4.2 ms on their own, so no arrangement of wrap code reaches 2 ms per recorded call while durability stays at the shipped G3 default. Reaching it needs a different mechanism (batched or deferred fsync), which is a durability decision, not a tuning exercise. Nothing was narrowed, stripped, or made private-API-shaped to improve the number (PRD §10).

Criterion text tables

Raw output of the published run, verbatim:

Gnuplot not found, using plotters backend
Benchmarking wrap_relay/tools_call_recorded
Benchmarking wrap_relay/tools_call_recorded: Warming up for 3.0000 s

Warning: Unable to complete 100 samples in 5.0s. You may wish to increase target time to 19.8s, or reduce sample count to 20.
Benchmarking wrap_relay/tools_call_recorded: Collecting 100 samples in estimated 19.757 s (100 iterations)
Benchmarking wrap_relay/tools_call_recorded: Analyzing
wrap_relay/tools_call_recorded
                        time:   [210.32 ms 218.54 ms 227.62 ms]
                        thrpt:  [219.66  elem/s 228.80  elem/s 237.74  elem/s]
Found 11 outliers among 100 measurements (11.00%)
  6 (6.00%) high mild
  5 (5.00%) high severe
Benchmarking wrap_relay/ping_relayed_only
Benchmarking wrap_relay/ping_relayed_only: Warming up for 3.0000 s
Benchmarking wrap_relay/ping_relayed_only: Collecting 100 samples in estimated 5.0924 s (400 iterations)
Benchmarking wrap_relay/ping_relayed_only: Analyzing
wrap_relay/ping_relayed_only
                        time:   [6.3020 ms 6.8027 ms 7.3371 ms]
                        thrpt:  [6.8147 Kelem/s 7.3500 Kelem/s 7.9339 Kelem/s]
Found 18 outliers among 100 measurements (18.00%)
  11 (11.00%) high mild
  7 (7.00%) high severe

The Criterion warning is left in: the recorded group needs ~20 s for 100 samples, and the run was not shortened by cutting the sample count.

Stability across runs

Two consecutive runs of the same command on the same box. Run 1 is published above; run 2 is the immediate repeat, so Criterion also printed its change: comparison against run 1.

Runtools_call_recorded / callping_relayed_only / callderived recording / calloutliers (rec / ping)
1 (published)4.371 ms136.05 µs4.235 ms11% / 18%
24.176 ms136.70 µs4.040 ms3% / 6%

Criterion’s own verdict on run 2 was No change in performance detected for both groups (p = 0.06 and p = 0.92). The published run is the noisier of the two and its median is the slower of the two; the verdict is a miss in both.

What each group actually measures

wrap_relay/tools_call_recorded — the shipped recording path, whole session. Every one of the 50 client lines is a tools/call with a distinct id, so every line takes the full recording route in crates/botzr-aegis-wrap/src/record.rs:59-119: serde_json::from_str on the raw line, RequestDigest::of_request_bytes over the verbatim bytes (record.rs:78), PolicySetHash::of_canonical_bytes over the pass-through constant (record.rs:79), CallSession::begin (record.rs:110) — one intent line, fsynced before the request is relayed (crates/botzr-aegis-wrap/src/relay.rs:199-203) — and then, on the child’s matching response, complete_relayed (record.rs:129-161) with the deny_all grant, the response digest, the metrics and complete() — the second line, and the second fsync (crates/botzr-aegis-audit/src/writer.rs:218). Also inside the iteration and not amortised away: load_signing_key and AuditWriter::open (relay.rs:90-91), the child spawn (relay.rs:93-102), the bounded reap (relay.rs:319-333), the stderr-tee drain (relay.rs:127), and the close line the AuditWriter writes on drop.

wrap_relay/ping_relayed_only — the same session with the recording removed. ping is not tools/call, so observe_client_line returns None at record.rs:66 before any digest, session or audit line exists; the line is relayed with zero interception and the mirror child answers {"result":{"mirrored":"ping"}}. The group therefore carries the identical fixed cost — key, writer open/close, spawn, reap, drain — plus 50 round trips of pure byte relay.

Its per-call figure is an upper bound on byte relay, not a measurement of it. 136.05 µs/call is 6.8027 ms of session cost divided by 50, and most of that session cost is the fixed part (one process spawn, one key load, two fsynced Session lines). This bench does not separate spawn from per-line relay, and no third group was added to try: the only figure it is used for is the subtraction above, where the fixed part cancels.

What is outside the measured region: the TempDir, the signing key generation (generate_signing_key, once, hoisted), and both scripted request bodies — all built before the first iteration. Inside the region but negligible against milliseconds: a Vec<String> argv clone, a PathBuf join, and a few KB of script.to_vec() memcpy per session.

Why the audit file is fresh per iteration. Each session writes to recorded-<n>.jsonl / relayed-<n>.jsonl in one hoisted TempDir. Reusing a single path would make AuditWriter::open recover the previous Session’s tail from an ever-growing file, so the per-iteration cost would climb with the sample number — a non-stationary measurement. File creation is consequently inside the timed region, which is the honest trade and is far below the fsync cost that dominates it.

Notes

  • No public API was added to any crate to make this bench possible (PRD §10). A group isolating the recording work alone — parse + digest + CallSession begin/complete, with no child and no byte relay — was considered and skipped: record::observe_client_line and complete_relayed are pub(crate) and a bench is an external crate, so the only way to reach them would have been to widen botzr-aegis-wrap’s public surface for a measurement. The attribution is obtained by differencing two shipped-path groups instead, and benches/results/cell_and_audit.md’s audit_emission/begin_complete already isolates the durable cycle from the other side.
  • Child-process spawn is amortised across the 50 calls in every group. A per-call figure derived from a one-call-per-process session would be much larger and would not describe how wrap is used — a wrap process lives for a whole client session.
  • The outlier rates (up to 18% in run 1) are consistent with the 20 ms poll granularity of reap (relay.rs:47, relay.rs:319-333) and of drain (relay.rs:303-312) occasionally landing inside an iteration when the child has not yet been reaped by the time the pump breaks. That mechanism is a hypothesis from reading the code, not something this bench measured; per-sample data would be needed to confirm it.
  • Wrap does not meter the child, so nothing in these numbers is the child’s own resource use. CallMetrics::peak_memory_bytes is recorded as 0 = “not measured” (record.rs:152-155).
  • HTML reports live under target/criterion/ and are not committed.

Demo recordings

Terminal casts of Aegis behaving as documented. Each one is a recording of the script named beside it, run unedited — the cast is a convenience for readers who want to watch before running anything, not a substitute for running it.

mcp-live-deny.cast — MCP live deny

Recorded from scripts/mcp-live-deny-demo.sh. Four beats:

  1. scripts/mcp-stdio-smoke.sh --deny — the assertion gate. It spawns the MCP stdio gateway over the real pipeline, allows an echo call, refuses an exfil call, and checks the resulting records. The demo exits 0 only when this exits 0.

Beats 2–4 are then one gateway session the demo drives itself, under a freshly minted signing key and its own audit sink — so the refusal on screen and the record on screen are the same call, not two lookalike runs:

  1. The JSON-RPC response the MCP client receives for the refused call — "code": "POLICY_DENIED", "isError": true. A typed refusal the caller can branch on, not a hang and not a silent empty result.
  2. The schema_version: 2 audit outcome that same call wrote: policy.status denied, capability.status denied (“policy blocked before capability”), execution.status host_denied (“not executed”) — each station stated separately, the whole line signed.
  3. aegis verify --key <public_key> pinning that record file to the key the session published.

CI does not run this script. The equivalent assertions run there as botzr-aegis-mcp unit and stdio tests under cargo test --workspace.

Replay:

asciinema play docs/demos/mcp-live-deny.cast

Run it live instead — no asciinema required:

scripts/mcp-live-deny-demo.sh

Re-record (maintainers; asciinema is maintainer tooling, not a workspace dependency):

cargo build -p botzr-aegis-mcp -p botzr-aegis-cli   # pre-warm, so the cast stays short
asciinema rec -c 'scripts/mcp-live-deny-demo.sh' docs/demos/mcp-live-deny.cast

DEMO_PAUSE=0 drops the script’s reading pauses, which is what you want in CI and not what you want in a recording.

Scope

This is a research artifact, not a certification. The cast shows the default catalog’s exfil tool refused at the policy station under the shipped default policy — one path, on one machine. What that does and does not establish is set out in the threat model and the findings report.

Crate map

Ten crates in the workspace. Eight are on crates.io at 0.3.0. botzr-aegis-wrap and botzr-aegis-confine are in-tree and first appear on the registry with the next cut.

CrateResponsibility0.3.0
botzr-aegis-corePure types and traits; zero I/Opublished
botzr-aegis-policyYAML → Arc<PolicySet>; sync evalpublished
botzr-aegis-capabilityDefault-deny resolver and grant mintingpublished
botzr-aegis-sandboxwasmtime host; cap-std preopens; resource limitspublished
botzr-aegis-auditSchema-versioned records, always emittedpublished
botzr-aegis-runtimeOrchestrator — Runtime::execute_tool_callpublished
botzr-aegis-mcpMCP stdio gateway for Aegis’s own catalogpublished
botzr-aegis-cliBinary aegispublished
botzr-aegis-wrapStdio MCP interposer — always records; confines only with --confine (Linux), evaluates policy only with --policy. Default wrap relays every call and blocks nothingin-tree only
botzr-aegis-confineLinux Landlock + seccomp from a grant (AILAB-628)in-tree only

governance/ is a separate Python (Layer 2) service — not a workspace member, never writes into the Rust runtime.

botzr-aegis-sidecar is yanked. The Phase 2 gateway is botzr-aegis-mcp. Do not confuse that gateway with aegis wrap.

Crate READMEs on crates.io are the API notes. This book is the stranger-facing hub.

Architecture decision records

Decisions that constrain the record format, the CLI verbs, and what the docs are allowed to claim. An ADR records what was decided when; later renames are noted in place rather than silently rewritten.

An accepted ADR is a decision, not a ship receipt. Several below are written in the present tense and describe behaviour that does not exist yet; “lands in AILAB-*” means the work is ticketed, nothing more. Check the code or the git tag, not the Status line. The Shipped? column below answers for current main — the published 0.3.0 binary is a narrower surface still (CLI).

ADRTitleShipped?
0001Agent Action Records split into a signed Chain and an optional Envelopepartial — Chain ships; no Envelope store or I/O
0002aegis verify reports coverage, not pass/failyes
0003JCS JSON is the canonical form, over a constrained value spaceyes
0004The signing key is embedded, and aegis verify labels its trust levelyes
0005Approval parks the MCP request, not the audit recordpartial — record half ships; no interposer park, no production caller
0006Argument matchers target derived capability parameters, not raw JSONno
0007Confinement applies via a self-restricting re-exec, and needs no unsafeno
0008D2’s re-evaluation command is aegis recheck, not aegis replayyes
0009D4 reproduces a cross-boundary exfiltration chain, not a same-server oneno — demo not built
0010macOS confinement fast-follows M4; it does not gate itno
0011The workspace and the spec are dual Apache-2.0 OR MIT; this supersedes OQ-1yes — crates.io 0.3.0 metadata stays MIT
0012The audit sink is a public seam that declares its own retentionyes
0013Verify and Recheck share a line classifier, not a walkyes
0014The record file extension is .aarlyes — every example writes it; no code parses an extension, by design
0015Wrap may synthesize a JSON-RPC error for an opted-in enforcement refusalpartial — --policy refuses and the real hash is recorded; no approval park

The bold no rows carry an above-the-fold Not implemented banner on the ADR itself. They are kept here, unedited, because an ADR is a record of a decision — not a page that gets quietly revised once reality disagrees with it.

ADR-0001 says aegis replay throughout. That command was renamed the same day by ADR-0008. Read every replay in 0001 as recheck. Left unedited because an ADR records what was decided when.

ADR-0008 writes session.<ext> three times. The extension was genuinely open when 0008 was written, and 0008 says so in the same sentence that rules .aar out. ADR-0014 later fixed it as .aarl. Read every session.<ext> in 0008 as session.aarl. Left unedited for the same reason as 0001.

ADR-0006 decides that argument matchers target derived capability parameters, and its status line says it lands in AILAB-626. It does not: that work was canceled in AILAB-626 on 2026-08-14 and will not be built, so the no in the table above is permanent rather than pending. The derived parameters themselves did ship — they are the decision_axes a Chain Line carries — and only the matchers that would have consumed them were dropped. Left unedited for the same reason as 0001.

Agent Action Records split into a signed Chain and an optional Envelope

Status: accepted (2026-08-10) · lands in AILAB-619

Terminology note. This ADR says aegis replay throughout. That command was renamed to aegis recheck later the same day by ADR-0008 — the operation executes nothing, so “replay” was an overclaim. Read every replay below as recheck. Left unedited because an ADR records what was decided when.

The Agent Action Record must satisfy two consumers with different trust audiences: aegis verify, run by a stranger on a record they were handed, and aegis replay, run by an operator on their own machine after an incident. We split the record into three tiers and put the seam between the second and third: the signed Chain carries integrity fields (seq, prev_hash, policy_set_hash, grant_id, outcomes, signature) and the Decision Axes (tool_id, capability, role, session, matched_rule, request_digest); verbatim request bytes live in a separate, optional, content-addressed Envelope keyed by request_digest.

This resolves a contradiction inside the Execution Report: §6.3 specifies a digest-only record, while §6.4 requires a record to carry “the request” so policy can be re-evaluated offline.

Considered options

Arguments inline in the signed record — rejected on one disqualifying property: secrets in a signed record are unredactable. Once a credential lands in an argument and the record is signed, the only removal mechanism is invalidating the chain. An evidence format whose sole redaction path is destroying the evidence cannot be handed to auditors, and the format is the durable asset (report §2, thesis 2). Mechanically it also fsyncs the full payload ahead of execution — AuditWriter::append_line flushes and sync_alls every line, and CallSession::begin emits the intent line before the sandbox runs — against a §7 target of 50µs per record.

Digest-only, re-scope aegis replay — correct for the code as it exists and wrong one milestone later. PolicyRequest is four scalars with zero argument introspection today, so digest-only replay works completely; AILAB-626 then adds argument matchers and breaks the format. A format that breaks between its first two releases does not get adopted, and §10.4 makes adoption the thesis.

Defer to AILAB-641 — inverts a dependency: 641 is Medium under R0 research, 619 is Urgent and blocks all of D2.

Consequences

  • Today’s record is already insufficient for replay for a reason unrelated to arguments. It persists tool_id but not capability, role, or session, so a recorded deny cannot reproduce or explain its own verdict. Those three axes join the Chain in the same schema v2 bump.
  • matched_rule gets recorded. It is computed in policy/src/engine.rs, commented “for the audit trail”, and currently has zero consumers outside policy-crate tests. Recording it turns a replay diff from a verdict flip into an explanation.
  • aegis replay ships chain-only. Because the Decision Axes are in the Chain, AILAB-622 can meet its acceptance criteria before the Envelope exists, and SPEC.md test vectors are replayable by third parties without shipping anyone’s private paths.
  • The Envelope is conditionally required. Whether payload is needed is a static property of the Policy Set being replayed against. Inspect it first; demand the Envelope only for Calls an argument-constraining rule could match. That set is empty today.
  • Superseded in part by ADR-0006. Matchers target derived capability parameters, not raw JSON, so those parameters are decision axes and live in the Chain. Replay therefore works chain-only even after AILAB-626 lands, and the Envelope is purely forensic. The conditional logic above stays correct but should almost never fire.
  • Replay needs a fourth verdict. Indeterminate { MissingEnvelope | EnvelopeDigestMismatch | UnknownPolicySetHash }, never folded into “unchanged” — exhaustive match is what forces every future reason to be handled at compile time. (Grew to five states: ADR-0005 added NewlyParked, and ADR-0006 added a NoBinding reason. The exhaustive-match argument is exactly why both were cheap to add.)
  • Envelope entries are verbatim bytes. request_digest is SHA-256 over raw input (runtime/src/pipeline.rs:65), so an Envelope writer that pretty-prints JSON silently breaks the link. The spec must say “verbatim”. This is also what makes an unsigned local Envelope trustworthy: hash on load, compare against the signed digest, and the signature transitively authenticates the payload.
  • Digest fields get newtypes. Three bare [u8; 32] fields in one constructor cannot catch a transposition, and a chain that hashes the policy set into prev_hash verifies clean while being wrong. PrevHash / PolicySetHash / RequestDigest over a shared Digest make transposition a compile error, and retire today’s input_digest: String.
  • REPLAY absorbs this without touching the published format. The Envelope is the seam the event journal grows into — the journal is the Envelope made complete and always-on, and D2 records become a signed projection over it. AILAB-641 should record that rather than re-decide it.
  • Open within this decision: Resolved same day. The Chain covers every appended line; only outcome-class lines are signed, and a signature transitively authenticates the unsigned lines before it. seq is per appended line with prev_hash computed under the same lock as the append — never per Call, because the writer serialises concurrent calls through a Mutex and a Call’s intent and outcome are therefore not adjacent. See ADR-0002.

aegis verify reports coverage, not pass/fail

Status: accepted (2026-08-10) · lands in AILAB-619 (format) and AILAB-621 (matrix)

Because only outcome lines carry signatures (ADR-0001), content beyond the last signature is unverifiable by construction, and truncating a Chain leaves an internally consistent Chain. Truncation is therefore not detectable from the Chain alone — it is detectable only by an Anchor that asserts content exists beyond a point. So aegis verify returns a three-state verdict computed from Coverage (the highest seq covered by a valid signature) plus Anchor presence: Verified, Indeterminate with a typed reason, or Tampered.

Exit codes pin as 0 verified, 1 tampered, 2 could-not-read, 3 indeterminate. These become API the moment anyone scripts against them.

Why not binary

Fail on any unverified tail alarms on healthy systems. The obvious case is a crash, but the common one is a live file: the D3 interposer appends continuously, so verifying a running session always shows an uncovered tail. A gate that fires on every in-progress file is noise within a week.

Pass with a warning on stderr is the option to actively rule out. Exit 0 is what every CI gate and every if aegis verify; then actually reads; a warning is invisible to all of them. An evidence tool whose machine-readable answer is “fine” for a truncated file reproduces the report §1 critique — logs you must trust the vendor about — inside our own verifier.

Consequences

  • The undecidable set is one Session, not every Session. The Session-open line carries prev_session_tail, back-referencing the previous Session’s final hash. Truncating any non-final Session contradicts a later signed open — that is Tampered, detected, with no external witness. Only the final Session’s tail, with no Anchor beyond it, is undecidable. This is a materially better property to publish than “we cannot detect truncation.”
  • The verdict must not be computed from “is there a close record”. Anchors are close records, later Session-opens, and (reserved) Checkpoints. Coverage-plus-Anchor generalises correctly across the crash case, the multi-Session case, and the live-file case; a close-record boolean does not.
  • The unverified tail can contain only intent lines plus at most one torn final line, since every outcome line is signed. An outcome line in the tail is a stripped signature — Tampered, not a crash. A trailing unparseable line is a torn write — Indeterminate, with a reason distinct from “no close record”.
  • Exit-3 output names the in-flight Calls. The tail is always a set of Calls that were in progress; three intents for workspace reads is a shrug, one intent for net.post is where an operator starts looking.
  • Unknown line types cap the verdict at Indeterminate. An unrecognised line still hashes — it is bytes, the Chain stays valid — but a verifier must never report Verified over content it does not understand, or a future emitter can smuggle anything past an old auditor. This is the format’s whole extensibility story and it has to be decided at v0.1.
  • The verdict is deterministic: same bytes, same verdict, always. Asserted as a property, not a test case.
  • D3 will need --live / --allow-open-tail. Not scoped now — let D3 ask for it — but the verdict model has to leave room.
  • Drop does not run on SIGKILL. Close-on-drop covers clean exit and unwind only. That gap is precisely what produces exit 3, and the SPEC.md threat-model section must state the tail-truncation non-guarantee plainly.

JCS JSON is the canonical form, over a constrained value space

Status: accepted (2026-08-10) · lands in AILAB-619

Records hash under RFC 8785 JCS, and stay JSONL on disk. SPEC.md additionally constrains the value space so that JCS’s hard cases are unreachable: no floating-point values anywhere, digests as fixed-length lowercase hex, integers as u64 below 2^53, and absent fields omitted rather than null.

The decision turns on one property of ADR-0001: because request_digest is SHA-256 over raw request bytes and payloads live in the Envelope, arbitrary user data never enters the canonical form. The canonicalizer only ever sees Aegis’s own field set. JCS’s genuinely difficult parts — ES6 number formatting, float edge cases, unnormalized Unicode in attacker-chosen keys — all live in user data, and there is none.

Considered options

Deterministic CBOR (RFC 8949 §4.2) is the stronger canonical form in the abstract — fewer footguns, native byte strings, no hex-encoding decision to get wrong. It was rejected on the D4 exit criterion: “a stranger with the README, a laptop, and fifteen minutes can… verify the audit chain themselves.” A binary evidence artifact cannot be cat’d, grep’d, or eyeballed, and the surrounding ecosystem — the shipped JSONL audit file, MCP’s JSON-RPC — is JSON throughout. Adoption cost matters more than encoding elegance when the format is the durable asset.

An explicit length-prefixed field transcript (the Certificate Transparency approach) is arguably the easiest to reimplement correctly — no library in any language, zero number ambiguity. Rejected as bespoke: an RFC is easier to hand a vendor than a hand-rolled encoding, and every added field would need a spec revision naming its byte position.

Consequences

  • The value-space constraints are normative, not stylistic. They are what make JCS safe here, so SPEC.md states them as requirements and the record types enforce them by construction — which the digest newtypes from ADR-0001 already do for the hash fields.
  • Omit, never null. Already how wall_ms and peak_memory_bytes serialize (core/src/audit.rs:73,77). A canonical form cannot leave absent-vs-null to the emitter.
  • The u64 < 2^53 bound is for cross-language verifiers, not for Rust. A JavaScript verifier reading seq as a Number is the realistic third-party implementation, and silently losing precision above 2^53 would break verification in a way nobody would attribute to the format.
  • Storage shape is unchanged. JSONL with one record per line, as shipped. JCS defines the hash input, not the file.
  • Test vectors must include the canonicalization step, not just the final hash — otherwise an implementer who canonicalizes wrong sees only “hash mismatch” with no way to locate the error.

The signing key is embedded, and aegis verify labels its trust level

Status: accepted (2026-08-10) · lands in AILAB-620, surfaced by AILAB-621

A per-host ed25519 keypair signs outcome lines. The Session-Open line carries the public key, every signed line carries key_id (its fingerprint), and aegis verify always prints the fingerprint and distinguishes two success states: Verified (pinned to <fp>) when a key is supplied via --key or a trust store, and Verified (unpinned) when not — the latter stated as internal consistency only, explicitly not a claim about provenance. A key_id that fails to match a supplied trust store is Tampered.

The problem this addresses: with a self-embedded key and no anchor, an attacker who rewrites an entire session signs it with their own key, embeds that key, and verification passes clean. Reporting a bare “verified” there would reproduce the report §1 critique — logs you must trust the vendor about are not evidence — inside our own verifier.

Considered options

Require an external trust anchor — refuse Verified unless key_id resolves. Cryptographically the most honest and it can never overclaim. Rejected on the D4 exit criterion: the stranger with a README and fifteen minutes has no trust store, so the headline demo would either ship a key out-of-band or end on exit 3.

Sigstore-style keyless (OIDC identity + transparency log) genuinely solves anchoring by putting a third party in the loop. Rejected for v0: network dependency and a heavy toolchain, against a local-first stdio wrapper running on a laptop. Report §4 defers hosted infrastructure until pull exists. Not foreclosed — key_id is the extension point.

Hash chain only, defer signing to v0.2. Against a full-rewrite attacker a self-embedded signature adds little over a bare hash chain, so this is a defensible honest v0.1 and it would shrink AILAB-619 considerably. Rejected because adding signature and key_id later is a format break, and format stability is the thesis (§2, §10.4). Ship the fields now, strengthen what they prove later.

Consequences

  • key_id joins the record. Without it a verifier cannot select a key and rotation is inexpressible. It is the field that lets this decision be upgraded — to TOFU, to a trust store, to sigstore — without a format break.
  • The labelled verdict is the point, not a caveat. Verified (unpinned) is what makes an unanchored chain honest. Same discipline as ADR-0002: the tool never claims more than the evidence supports, and the honest answer always has somewhere to go.
  • TOFU first-seen pinning belongs at the D3 layer, reusing the §6.1 schema-hash first-seen registry rather than inventing a second registry with different semantics. Not v0.1 scope.
  • Key rotation is expressible but unspecified. key_id per signed line means a file can contain lines under two keys. SPEC.md must say whether a verifier accepts that and under what conditions, or the first rotation produces an argument.
  • Private key handling is out of the format. Where the key lives, its file permissions, and whether it is generated on first run are AILAB-620 implementation concerns, not spec surface.

Approval parks the MCP request, not the audit record

Status: accepted (2026-08-10) · lands in AILAB-629, touches AILAB-619 and AILAB-622

A PendingApproval policy decision closes its audit record immediately — the park is an outcome line carrying PolicyOutcome::PendingApproval, exactly as shipped. What parks is the JSON-RPC response inside the interposer. The human verdict is a new Decision line, and a resumed call is a new Call with its own intent and outcome, cross-referenced by approval_id.

AILAB-629’s own text anticipated this: “If G2’s reasoning still holds for the in-process runtime, the right answer may be that parking lives only in the interposer while the library keeps reject-with-token.” It does. The library keeps G2 unchanged; only the interposer parks.

Why the alternative was rejected

The obvious reading of report §6.5 — hold the CallSession open across the human’s decision — couples two lifetimes that have no reason to be coupled. The shipped pipeline already treats PendingApproval as terminal (pipeline.rs:75): it takes the not-allowed branch, marks capability denied and execution not-executed, completes the session, and returns AegisError::PendingApproval with a minted id. The evaluation is over. Reopening it to await a human buys nothing and costs:

  • A manufactured denial. A held-open session that dies at process exit writes an outcome describing the runtime’s lifecycle (session ending, session abandoned) rather than anything about the request.
  • A conditional Drop guarantee. The exactly-once guard becomes contingent on a state machine that can outlive the process.
  • Nothing gained on timeouts. An MCP client timeout still abandons a live session.

Under the decoupled model a client timeout discards a held response and touches no evidence. Worst case the file records a park and a decision and no execution — an accurate account of what happened.

Consequences

  • One new line type, not two. Decision — a human verdict, with no intent and no execution. “Park” is not a line type; it is an Outcome carrying PendingApproval, which has shipped since v1.
  • Two linkage kinds with different strengths, and SPEC.md must state both:
    • intent ↔ outcome is a hard invariant, Session-scoped, guaranteed by the Drop guard plus the borrow that makes the writer outlive every CallSession. Violation inside a closed Session is a structural error.
    • approval_id ↔ decision is a soft cross-reference that may span Sessions and files. A human approving after a restart is normal.
  • Three verifier rules follow. A PendingApproval outcome with no Decision is legal (informational). A Decision for an absent approval_id is legal (informational — parked in an earlier file). Two Decision lines for one approval_id is a structural violation, exit 1, same class as a chain break, because a correct emitter cannot produce it.
  • The Decision line records the granted scope, not just the verdict. 629 requires the approver be shown “what authority approval would grant”; that authority belongs in the record. Approval without recorded scope is a blank check in the evidence. The resumed call’s grant must then be a subset of the approved scope — §6.6’s delegation-only-narrows rule applying to human approvals for free.
  • Unmatched-intent is a consistency cross-check, not a tamper signal. Interior deletion is already Tampered via prev_hash. What the invariant adds is catching a buggy emitter, and catching a rewriting attacker who holds the signing key but drops an outcome sloppily. SPEC.md must not claim more.
  • AILAB-622 gains a verdict and a field. RecheckVerdict::NewlyParked (written ReplayVerdict when this was decided; the command was renamed hours later by ADR-0008) — replaying against a policy that newly requires approval is not “newly blocked”, because a human might have approved. And approval_ref is a decision input, so under ADR-0001 it belongs in the Chain; without it, replaying a resumed call cannot reconstruct why it was allowed.
  • A park timeout is required regardless. 629’s AC already demands defined timeout and denial paths. It is a feature of any answer here, not a competing one.

Argument matchers target derived capability parameters, not raw JSON

Status: accepted (2026-08-10) · lands in AILAB-626, changes ADR-0001

Not implemented. This is an accepted design decision, not a description of shipped behaviour. Argument matchers are not in v0.3.0 or current main: the policy parser accepts tool, capability, and role matchers only, and path_under does not exist anywhere in the engine — see Policy YAML for what evaluates today. The present-tense body below records the decision as written on 2026-08-10; it is not a product claim. Lands in AILAB-626 as ticketed.

Report §6.2’s “structured matchers over JSON arguments” is implemented as matchers over the capability parameters the runtime derives from a call — an fs path, a net host and port — not over raw JSON argument trees. A rule reads deny fs.read path_under: ~/.ssh and covers every tool regardless of whether that tool names its argument path, file_path, or params.target.location.

Turning arguments into axes requires a Binding per tool. For unmodified third-party servers, Bindings are proposed from the tool’s declared schema, confirmed by a human once at first call, and pinned — with a small curated table shipped for the servers that carry the D4 demo, so the flagship path does not depend on inference quality. The authority is the approval and the pin, never the schema.

Why not JSON Pointer into raw arguments

Addressing raw arguments couples policy to each tool’s vocabulary. The same intent then needs one rule per tool, and a server that renames its argument after approval silently escapes the rule. Deriving the axis first makes the bypass unrepresentable: whatever the argument is called, the call either resolves to an fs.read on a path or it does not.

Unconfirmed schema inference was rejected in the same breath: a wrong inference is a silent policy bypass rather than a visible error. Human confirmation plus a pin converts the guess into a recorded decision.

Scope constraint — bindings only, and it is a security property

A Binding says which argument is the path. It must not be able to say which paths are allowed. Authority stays in the policy set and the manifest.

This is what bounds the blast radius of the whole feature to misidentifying a resource, never widening a grant — which is in turn what makes “a human approves each Binding once” a tolerable amount of trust to place in a prompt. Get it wrong, let a binding file express fs.read under /, and you have shipped a policy file authored by whoever ships the MCP server.

Enforce structurally: the Binding type carries no field that could hold a limit, a scope, or a path prefix. State it as an explicit non-goal in AILAB-626 so it cannot be added later as a convenience.

This scoping is also what makes the AILAB-605 argument honest rather than rhetorical — see below.

Consequences

  • This changes ADR-0001. The Envelope was made a hard prerequisite for argument-level replay on the assumption that policy matches raw arguments. Derived parameters are decision axes, so they live in the Chain — aegis recheck (named aegis replay when this was decided; see ADR-0008) therefore works chain-only even after AILAB-626 lands, and the Envelope becomes purely forensic. The conditional-Envelope logic in AILAB-622 remains correct but should now almost never fire.
  • Derived parameters are decision axes and join the record, under the same rule as capability/role/session: if replay needs it, the Chain carries it.
  • Sensitivity moves, it does not vanish. A derived fs_path of /home/a/clients/acme/notes.md is smaller than the full argument tree but is not non-sensitive. The Chain is still the publishable artifact, so SPEC.md must say plainly that derived paths appear in it.
  • ToolManifest does not express Bindings today. It declares static needs (FsNeeds, NetNeeds — which paths a tool may touch) with no mapping from an argument position to an axis.
  • This is the feature ticket AILAB-605 was waiting for — but only at bindings scope. 605 (capability-manifest file format) was canceled because a file format would be “new product surface under a hardening heading”, with the note that “if ever wanted, it earns its own feature ticket and the fuzz target follows it.” Third-party servers cannot ship Rust builder calls, so Bindings must come from a file. If the new surface recreates manifest-as-file — limits, tool kind, root paths — 605’s rationale reappears legitimately and someone is right to call it relitigation. Keeping it to bindings is what makes “earned by a shipped requirement” true. It also keeps the earned parse surface small, which keeps the fuzz target tractable.
  • Two hashes, not one. Pin the binding-relevant projection of the schema to trigger re-approval, and record the full schema separately as evidence. A single hash over everything means a description edit re-prompts, and that churn is exactly how a human is trained to click through the one prompt that mattered.
  • The approval prompt classifies every argument, not just the bound ones. encoding → not a resource gets a line. The human must be checking a complete list, not noticing an omission — the difference is invisible at two fields and decisive at twelve.
  • “Run unbound” is a required fourth choice, alongside accept / edit / deny: the tool executes with no fs or net authority. A stranger who does not want to think about it yet gets a working, powerless tool rather than a wall. That is default-deny applied correctly, rather than default-deny as refusal.
  • AILAB-626’s 8-point estimate predates all of this. It now carries Binding surface, a file format for it, the approval integration, and the two-hash pin.

Confinement applies via a self-restricting re-exec, and needs no unsafe

Status: accepted (2026-08-10) · lands in AILAB-628 · deletes a clause from Execution Report §5

Implemented in AILAB-628. aegis wrap --confine re-execs through aegis __confine-exec, which applies Landlock and seccomp to itself and then execs the target. Without --confine, wrap still only records. v0.3.0 did not ship this. The present-tense body below is now a description of shipped behaviour on Linux.

Native MCP servers are confined by re-executing aegis as a hidden subcommand (aegis __confine-exec -- <target>). That process applies Landlock and seccomp to itself using safe wrapper APIs, then replaces its own image with the target. Landlock domains and seccomp filters are preserved across execve, so the target runs confined. Nothing in the workspace needs unsafe, and unsafe_code = forbid stays workspace-wide.

The mechanism both documents missed

Execution Report §5 names aegis-confine as “the single documented exception (raw syscall surface)”. Binding decision 3 (2026-08-09) says forbid stays workspace-wide via vetted safe wrappers. These read as a contradiction only because both assume confinement must happen between fork and exec.

CommandExt::pre_exec is unsafe. CommandExt::exec is not. It performs no fork — it replaces the current process image via execvp and does not return on success — so there is no window of async-signal-unsafe execution to make the caller responsible for. That asymmetry is the entire answer, and it means the §5 exception clause should be deleted, not honoured. The contradiction dissolves; it does not resolve toward either document.

pre_exec would not merely be unsafe, it would be wrong

This is the stronger argument, and the ADR leads with it so the choice reads as technical rather than procedural.

Code between fork and exec must be async-signal-safe: no allocation, no lock acquisition, no non-reentrant calls. Building a Landlock ruleset does all three — it allocates, and path rules require open()ing each path for an fd. Performed in a forked child of a multithreaded process, that risks deadlocking on an allocator lock held by a thread that does not exist in the child.

So the exception would be spent to do something genuinely hazardous, in the crate that most needs to be trustworthy. The helper does identical work in an ordinary process context, where allocating and opening files is simply normal.

One artifact, not two

A separate aegis-confine-exec binary would cost the single-static-binary distribution channel that report §2.3 makes the whole wedge strategy. std::env::current_exe() is safe, so aegis re-execs itself under a hidden subcommand. Same artifact, same crate, no distribution change.

Two constraints follow: dispatch __confine-exec at the very top of main, before any tokio runtime or thread pool exists — restrict_self applies to the calling thread, and a single-threaded process avoids the question — and keep that path dependency-minimal, since it is reachable before any interposer machinery.

Profile transport

stdin/stdout are the MCP transport and must pass through untouched. An inherited fd would need dup2 in pre_exec, reintroducing the problem. argv is visible to any local user via /proc/<pid>/cmdline, which would publish the confinement profile including its paths. So the profile travels in the environment (/proc/<pid>/environ is owner-readable) and is stripped with env_remove before exec.

The helper cannot be abused

It is authority-reducing only: it holds no privilege, is not setuid, and its sole powers are to narrow itself and then exec. An attacker invoking it with an empty profile gets an unconfined child — which they could have obtained by running the target directly.

Same shape as ADR-0006’s bindings-cannot-grant-authority rule, and it is what separates this from the setuid sandbox helpers that have historically been a vulnerability class. State it explicitly, because “we ship a sandbox helper binary” otherwise reads alarming.

The record must not overclaim what was enforced

Landlock’s ABI varies by kernel and the crate’s default posture is best-effort. That creates a failure worse than no confinement: the helper execs on an old kernel with partial restriction and the audit record says the call was confined.

  • Fail closed by default. If the requested confinement cannot be fully applied, refuse to exec. --best-effort is an explicit operator opt-in, and the opt-in is itself recorded.
  • Record what was actually enforced — the negotiated Landlock ABI level, whether the seccomp filter applied — not what was requested. Same rule as ADR-0002 and ADR-0004, arriving in the confinement station.
  • A seccomp kill is distinct evidence. The child dies on SIGSYS, detectable via ExitStatus::signal() (safe API). Map it to its own audit outcome rather than folding it into a generic crash — a seccomp kill and a segfault mean different things.

On the alternatives

A crate owning the fork/exec/restrict sequence is this design implemented by someone else, and would equally preserve forbid. Rejected because it moves the security-critical step into a dependency for roughly two hundred lines of code, and the landlock crate already ships a sandboxer example doing exactly this. Nothing is being invented.

Spiking first is not triggered: decision 3’s escape hatch is “if a spike shows safe wrappers are impossible”, and they are not. A smoke test across the supported kernel range is implementation work, not a decision gate.

macOS inherits the architecture. Seatbelt’s sandbox_init is a deprecated-adjacent C API that may genuinely need a crate or an exception, but restrict-self-then-exec carries over unchanged. That is AILAB-630’s call, not this one.

Unverified — confirm by smoke test before SPEC.md or the threat model cites them

  1. Landlock domains preserved across execve on the minimum supported kernel (documented, but ABI level affects what is enforceable)
  2. Whether restrict_self() sets PR_SET_NO_NEW_PRIVS, or the helper must
  3. Whether seccompiler::apply_filter sets NO_NEW_PRIVS
  4. That the filter permits execve and the dynamic loader’s syscalls — a filter correct for node’s steady state will kill it at startup otherwise

D2’s re-evaluation command is aegis recheck, not aegis replay

Status: accepted (2026-08-10) · lands in AILAB-622 · edits Execution Report §6.4

AILAB-622 takes a recorded session plus a new policy set, re-evaluates every recorded decision, and emits a would-block diff. It executes nothing. That is not a replay, and calling it one promises effects that do not re-run. The command is aegis recheck --policy new.yaml session.<ext> — the extension is deliberately unresolved here, because .aar is ruled out below.

This was framed initially as a namespace collision with REPLAY’s aegis replay (AILAB-677, R2), to be solved by nesting D2’s verbs under the spec name. That was the wrong diagnosis. The two operations were never competing for one verb — one of them was wearing the other’s name. REPLAY’s operation genuinely is a replay; D2’s is a policy what-if against a record.

Why not namespace instead

Nesting (aegis aar verify / aegis aar replay) preserves the misnomer behind a longer path, and costs three things:

  • It splits record verbs across two levels. aegis verify session.<ext> is the D4 exit criterion — the first thing a stranger types. Leaving verify top-level while nesting replay makes operation location unpredictable; moving verify down too lengthens the flagship command exactly where discoverability matters most.
  • It reserves the best verb for work that may never ship. R2 is 12–24 months out and the direction decision says REPLAY phases do not become sprint scope without an explicit call. Paying UX cost in D2 to hold a name for that is speculative.
  • Its main benefit is already delivered by the file extension. Every invocation reads … session.<ext>, so the format’s name is taught by the filename a user types a dozen times. No verb nesting required.

Nesting also would have left two operations on one verb dispatched by file type — implicit dispatch that is surprising when it works and confusing when it does not.

Consequences

  • This is the same overclaim discipline as the rest of D2. Exit 0 on an unanchored file, “verified” for an internally-checked chain, an audit record claiming confinement the kernel did not apply — and a verb promising effects that do not re-run. The CLI verb is the most-read surface in the product.
  • reeval is the fallback if recheck reads too casually for auditors. Avoid simulate — REPLAY §14 Mode E already claims it, and using it here recreates this collision one layer down. Avoid audit, already a pipeline station name.
  • Cheap now, expensive later. 622 is unstarted and report §6.4’s aegis replay --policy new.yaml is a one-line edit. After a published README it is a breaking rename.
  • The spec name is decoupled and stays with the PM (report §11.4). “Agent Action Record” is a sound prose name and, critically, vendor-neutral — a competitor can emit Agent Action Records without endorsing us; nobody would emit “Aegis Records.” That property is what §11.4 is really protecting.
  • .aar must not be the file extension. It is the Android Archive format — a hard collision, because it is an extension we would be claiming. Editors and file will misidentify records as zip archives, GitHub’s language detection is already trained on it, and searches for the format spec return Android results indefinitely. .aarl signals both the acronym and the JSONL shape. The prose name and the extension are separate decisions.
  • After-action report is a soft collision — same letters, and in audit circles it denotes a human-written retrospective. Survivable in prose; noted so the pitch does not lean on the acronym alone.

D4 reproduces a cross-boundary exfiltration chain, not a same-server one

Status: accepted (2026-08-10) · lands in AILAB-631

The wedge demo reproduces an exfiltration chain whose exfiltration leg leaves the capability envelope — reading a credential outside the grant, or egress to a host with no NetGrant. Not a chain where the exfiltration leg is a legitimate call to an allowlisted server.

The archetype matters more than the specific incident, because it decides D4’s dependencies.

Why

The best-known MCP exfiltration chains — the canonical example being an injected issue in a public repository driving an agent with GitHub MCP access to push private repository contents into a public PR — share a structure: the exfiltration is an allowlisted call to an allowlisted server doing exactly what it was designed to do. If policy permits create_pr, deterministic containment does not stop it. Blocking that requires data-flow taint, which is D5 — explicitly research-grade, design-doc-before-code, with no launch commitment.

A cross-boundary chain is blocked structurally by §6.7’s egress default-deny plus AILAB-626’s argument policy. D4 therefore ships on D1 + D2 + D3 with no D5 dependency, and the demo shows the thesis — deterministic containment that does not weaken as models get better at evasion — demonstrating itself, rather than a partial result.

Consequences

  • D4 is unblocked by D5. This is the load-bearing consequence. Had the famous chain been chosen, either D4 waits on research-grade work or it demos something it does not block.
  • Recognition is the cost, and it must not be recovered by overclaiming. The chosen chain is less famous. The demo, the README, and any writeup must state plainly which chains Aegis does not block — specifically the same-server legitimate-action class — in the same breath as what it does. Report §10.5 already sets this tone (“what this does not solve”); the demo has to carry it too, or the project trades its claim-integrity discipline for a headline.
  • The same-server chain is the motivation for D5, and should be named as such. It is the honest bridge from the wedge to the research direction, and it costs nothing to say.
  • The audit chain is the artifact, not a footnote. A denial that produces a verifiable, third-party-checkable record is the part no competitor ships. The demo should end on aegis verify, not on the denial message.
  • The blocking layer must be the broker, never OS confinement (added per ADR-0010). Argument policy, capability, and egress deny are platform-independent; Landlock and Seatbelt are not. The archetype above already satisfies this — both legs are refused by the broker — but it is a selection criterion, not a lucky property, and the demo script must run identically on Linux and macOS.
  • Specific incident selection still needs sourcing. The archetype is decided; which published chain instantiates it must be verified against primary sources before AILAB-631 cites one, per the standing rule that a claim ships with its evidence.

macOS confinement fast-follows M4; it does not gate it

Status: accepted (2026-08-10) · lands in AILAB-630, conditions AILAB-631

Not implemented. This is an accepted design decision, not a description of shipped behaviour. M4 has not launched, and neither of the two layers this ADR sequences exists in v0.3.0 or current main: no native confinement (ADR-0007) on any platform, and no argument-level policy (ADR-0006). Read “M4 launches with…” as the plan of record on 2026-08-10, not as something that happened. Lands in AILAB-630 as ticketed.

M4 launches with Linux-only native confinement. macOS ships everything else.

The deciding reason is not that AILAB-628’s fail-closed machinery already covers the degraded case — that is plumbing, and plumbing does not justify a launch decision. It is that the D4 demo and two of report §1’s three structural gaps do not depend on confinement at all.

What macOS delivers at M4

Report §3’s own layering for D3: native servers get OS confinement, WASM tools get the cell, and “everything gets argument-level policy, the approval flow, and the audit chain.” Confinement is an additional layer, not the only one.

So macOS at M4 ships argument-level policy — the §6.2 hard-20% that is the actual product — plus grants, approval, schema-hash pinning, broker-level egress deny, and the full signed chain. Against §1’s three gaps: gap 2 (evidence is a feature, not a format) and gap 3 (injection treated per-call) close completely. Gap 1 (execution is untouched) does not. Two of three, stated plainly, is a real product.

The threat-model distinction that carries it

Confinement defends against a malicious or compromised server implementation — a binary that ignores the protocol and reads ~/.ssh directly.

The published MCP exfiltration chains do not work that way. They run prompt injection → agent calls a legitimate tool with malicious arguments → data leaves. That is argument-level policy territory, and argument-level policy is platform-independent.

So “D4’s demo lands unprotected on macOS” is wrong. The demo’s exploit is blocked on macOS by the layer that was always going to block it. What macOS lacks is defence against a category of attacker the demo does not feature.

Consequences

  • AILAB-631 gains a selection criterion: choose the chain so that broker-level enforcement — argument policy, capability, egress deny — is what blocks it, never OS confinement. Then the demo script runs identically on both platforms and the macOS gap is a documentation matter rather than a demo matter. ADR-0009’s archetype already satisfies this (both legs are refused by the broker), but it should be explicit rather than lucky.
  • Gating M4 on parity would force the unsafe decision under launch pressure — a project-level invariant decided by a schedule, which is the worst available way to decide one. Fast-follow decouples them.
  • The exception may not be earned, so do not grant it. ADR-0007’s pattern generalises further on macOS than expected: sandbox-exec is the OS’s own restrict-then-exec helper — a system binary that applies a Seatbelt profile and execs the target. No FFI, no unsafe, no crate dependency, same process-boundary trick. ADR-0007’s rule applies unchanged: escalate only if a spike shows safe is impossible.
  • Three honesty surfaces are the condition on fast-follow, and they are where users meet the product, not a footnote. Shipping “gate the code, not just the call” to a macOS-majority audience while the code is not gated is the overclaim pattern again — and the one users would discover themselves.
    1. README states which layers apply per platform
    2. aegis run on macOS fails closed, naming exactly what is missing and what --best-effort gives up, with a persistent banner rather than a startup line
    3. The record carries confinement: none, so the evidence stays accurate regardless of what anyone read
  • This is what makes fast-follow defensible rather than embarrassing: the product is incomplete, and the evidence says so.

Why not the alternatives

Gate M4 on parity adds scope to a milestone whose §8 exit criteria are already written and platform-agnostic, delays launch on the platform §10.3 flags as most fragile, and partially reverses the documented decision to sequence Seatbelt after Linux precisely so a breakage would not block a milestone.

Ship macOS as WASM-only conflates confinement with protection. It discards the argument-level policy and audit chain that do work for native servers — throwing away two closed gaps to avoid admitting the third is open, and telling a macOS-majority audience they cannot wrap the servers they already run. That damages the distribution thesis more than an honest limitation does.

Unverified — a one-day mechanism spike, not a gating question

  1. Which macOS versions still ship sandbox-exec
  2. Whether its deprecation warning goes to stderr — harmless for MCP stdio since stdout is the transport, but it should be captured so it does not confuse users
  3. Whether the Seatbelt profile language can express the grant-derived scope required
  4. Whether a candidate FFI crate’s macOS support is real or aspirational

The workspace and the spec are dual Apache-2.0 OR MIT; this supersedes OQ-1

Status: accepted (2026-08-09) · lands in AILAB-634 · supersedes OQ-1 (closed MIT-only 2026-07-05, AILAB-132)

[workspace.package] license is Apache-2.0 OR MIT, and spec/SPEC.md carries the same terms. A recipient picks a branch; nobody has to ask.

OQ-1 closed MIT-only when Aegis was a runtime and the only thing being handed to anyone was a crate. The mission elevation on 2026-08-09 changed what is being handed out: the Agent Action Record is a format, and a format’s whole value is other people implementing it. MIT is silent on patents. Silence is fine for a crate someone links; it is a live objection for a legal team evaluating whether their company can emit an evidence format in an audited pipeline. Apache-2.0’s §3 grant answers that objection in the license text instead of in a thread.

Dual rather than Apache-only because MIT is what the Rust ecosystem defaults to, and dropping it would make Aegis the awkward dependency in an otherwise MIT OR Apache-2.0 graph. The OR costs nothing: consumers who want the patent grant take Apache-2.0, consumers who want the shortest possible license file take MIT.

The 0.3.0 boundary

The eight crates published to crates.io at 0.3.0 are MIT as published and stay that way. A registry tarball is immutable; its license metadata cannot be edited after upload, and republishing a spent version number is forbidden by the standing rules. Nothing was retagged and nothing was republished for this decision. The dual license covers this repository from the AILAB-634 commit onward and reaches the registry with the next release cut.

Anyone who took 0.3.0 from crates.io holds an MIT grant and keeps it. That is not a defect to be repaired — it is what “immutable release” means, and the same discipline that leaves v0.1.0 pointing at a pre-fix commit applies here.

Consequences

  • Two license texts at the root, and a pointer. LICENSE-APACHE and LICENSE-MIT hold the texts; LICENSE becomes a short either-or pointer rather than disappearing. Deleting LICENSE would break inbound links, including the ones in 0.3.0’s published metadata.
  • cargo deny is unaffected. MIT and Apache-2.0 were both already in deny.toml’s allow list, and MIT-only dependencies stay compatible with either branch a consumer takes. The supply-chain job did not need a new entry.
  • Contributions are dual by default — the standard Apache-2.0 §5 inbound clause, stated in LICENSE and the README. No CLA.
  • The spec’s grant does not create an attribution obligation. Emitting Agent Action Records requires no reference to Aegis. Vendor-neutrality is the property ADR-0008 protects, and a license that quietly taxed adoption would undo it.
  • Historical OQ-1 text is left alone. This ADR is the supersession record; rewriting the 2026-07-05 decision in place would erase why MIT-only was correct for the shape the project had then.

The audit sink is a public seam that declares its own retention

Status: accepted (2026-08-14)

Implemented 2026-08-16. Both slices have landed: the ChainSink seam, Retention, MemoryChainSink and FileChainSink in AILAB-701; the in-memory default, the removal of both temp-file constructors, and tempfile’s exit from the production dependency graph in AILAB-702. The text below is the decision as taken on 2026-08-14 and is left unedited — read its “becomes” as the plan of record on that date, not as a description of the tree. For what shipped, see CHANGELOG.md and docs/cli.md.

AuditWriter keeps owning the chain rule — stamp seq and prev_hash under the lock, sign, hash the signed form, append — and ChainState’s single-lock design is unchanged. What becomes a seam is only where the bytes land: a public ChainSink trait, with the fsync a property of the file adapter rather than an option on the writer.

The trait carries a retention declaration — Durable or Volatile — and the constructor refuses a Durable sink signed by insecure_dev_key. The default sink becomes Volatile and in-memory.

Why

G3 durability is load-bearing, and writer.rs:1 states it as a property of the module. Once the sink is an argument, that sentence describes an adapter the writer does not own: a chain appended to a third-party sink and a chain fsynced to disk are byte-identical and indistinguishable to a verifier. ADR-0007’s rule — the record states what was enforced, not what was asked for — is the same rule one layer up, and e92450a is what it costs to discover that late.

A durable: bool option on the writer was rejected outright: it would make the durability guarantee configurable in production, which is precisely what must not happen. Declaring retention on the adapter says which guarantee applies without offering to weaken it.

Retention also turns out to be the honest spelling of an invariant already in the tree. RuntimeBuilder::audit_file(path, key) takes the key as a required parameter, and check_audit_key_pair (cli/src/lib.rs:232, duplicated at mcp/src/bridge.rs:65) uses “was a path supplied?” as a proxy for “will this be retained?”. Retention is the thing that proxy was reaching for, so the pairing rule generalises: Durable sinks require a provisioned key; only Volatile sinks may carry insecure_dev_key.

Considered options

A test-utils-gated, crate-private seam was the cheaper option and was rejected. It fixes the test ergonomics and nothing else, and the trait’s value is as an extension point for embedders who need a sink Aegis does not ship.

A trait hierarchy — DurableChainSink: ChainSink adding existing_tail, making retention a bound rather than a declared value — was also considered and rejected for surface simplicity. The cost is accepted and named below.

Consequences

  • The default sink stops touching the filesystem. Runtime::default() used AuditWriter::open_temp, whose TempDir is dropped with the writer — so aegis and aegis run printed Audit: /tmp/…/audit.jsonl for a directory deleted at process exit (verified 2026-08-14 against the shipped 0.3.0 binary). A Volatile sink names no path and says so. tempfile leaves the production dependency graph as a result, not as a goal.
  • audit/benches/emission.rs must be pinned to an explicit file sink. It uses open_temp today and is measuring the fsync path; switching the default silently changes what it reports.
  • AuditWriter::path() becomes Option<&Path>, with three production callers printing it.
  • A sink can lie. With one trait and a runtime check, a sink may declare Durable and return Ok(None) from existing_tail() over a non-empty store. That is undetectable and is documented rather than engineered around. A sink that declares Durable and errors on existing_tail() fails closed at construction, matching recover_tail’s existing refusal to append onto a torn tail (writer.rs:247-252).
  • The sink has a read side. prev_session_tail requires the previous tail, so a sink that cannot answer existing_tail() produces Sessions that are permanently unanchored under ADR-0002 — see the Sink entry in CONTEXT.md.

Verify and Recheck share a line classifier, not a walk

Status: accepted (2026-08-14)

Implemented 2026-08-17 (AILAB-703). line_type_field, line_type_from_value and SessionCounter are public in botzr-aegis-core/src/audit.rs, beside AuditLineType::from_wire. Both walks number Sessions with the shared counter, and the v1 phase fallback has left the CLI binary. Neither verb’s behaviour changed: aegis verify calls the field-only reader and still refuses a line with no line_type, aegis recheck calls the fallback-aware one and still reports v1 records, and Session boundaries come from line_type alone in both. Address extraction landed as the shared Session ordinal only — Position and the two verdict types stayed with their consumers, and seq was deliberately not unified, because verify reads a missing seq as Tampered where recheck prints 0. The text below is the decision as taken on 2026-08-14 and is left unedited. For what shipped, see CHANGELOG.md.

The duplication between aegis verify’s walk (audit/src/verdict.rs) and aegis recheck’s (cli/src/recheck.rs) is resolved by extracting only what they genuinely share: line-type routing including the schema-v1 phase fallback, Session numbering, and address extraction. That lands as a small public surface in botzr-aegis-core, beside AuditLineType::from_wire.

The two walks are not merged. Each keeps its own iteration, strictness, and error policy.

Why

The obvious refactor — one Chain reader both walks consume — cannot be built without one of them changing what it means. Verify canonicalizes, hashes, checks signatures, enforces seq ordering and Session structure, and stops at the first tamper. Recheck deliberately does none of that, and recheck.rs:119-122 argues why:

an unparseable line is stepped over rather than reported: a torn tail is a statement about the file’s integrity, and answering it here would be a second, weaker verifier living next to the real one.

Recheck also understands v1’s phase spelling, which verify deliberately does not. So the disagreements between the two walks on malformed input are designed, not drift.

A reader with a Strict/Lenient parameter was rejected for the same reason ADR-0012 rejects a durable: bool: it makes a verification guarantee configurable at the call site.

core rather than audit because the two facts being extracted are wire-vocabulary facts, and AuditLineType::from_wire — the function that already turns a wire token into meaning — lives in core/src/audit.rs. Splitting “what line_type means” from “what phase meant” across two crates re-creates the problem. Anyone reading a Chain already depends on both crates, since audit does not re-export core’s types.

Consequences

  • The comments stop being the mechanism. recheck.rs:145-149 and the LOAD-BEARING paragraph at :231-239 exist to keep two independent implementations of Session numbering in agreement — the same expression written at verdict.rs:575 and recheck.rs:189. After this they cite a shared function instead of describing one.
  • Schema-v1 knowledge leaves the binary. is_outcome_shaped (cli/src/recheck.rs:244) is currently the only Rust in the workspace that knows phase was v1’s spelling of line_type; the only other record is docs/audit-schema.md, marked SUPERSEDED.
  • AILAB-688 stays open. Unifying the two walks’ strictness is the recheck-vs-verify boundary question, and this decision deliberately does not answer it.
  • The remaining duplication is accepted. Hashing, signature checking, ordering, and torn-tail policy stay per-consumer. That is the point, not a shortfall.
  • cli/src/recheck.rs:265 is not duplication. Its schema-version gate runs on raw JSON before deserialization; policy/src/recheck.rs:261 runs on a typed AuditRecord after. They are layered on purpose and both stay.

The record file extension is .aarl

Status: accepted (2026-08-28) · lands in AILAB-623 · closes the question ADR-0008 left open

Agent Action Record files are written with the extension .aarl. Every example in spec/SPEC.md, in docs/ and in the CLI README that previously wrote the placeholder session.<ext> now writes session.aarl.

This closes a question rather than overturning one, and it supersedes nothing. ADR-0008 settled the prose name and left the extension unresolved on purpose — in the same breath it ruled .aar out and named .aarl as a proposal, because the name and the extension are separate decisions and only the first was ripe. 0014 is the second half of a split 0008 made deliberately.

Why .aarl

The case against .aar belongs to ADR-0008 and is not re-argued here: it is the Android Archive format, so claiming it means editors and file misidentify records as zip archives while searches for the format spec return Android results indefinitely.

What this decision adds is the diligence that argument implies but 0008 did not perform — checking that the replacement is actually free:

  • .aarl is unclaimed. Searched 2026-08-28: no established format uses it. The nearest neighbour is .arl — one letter shorter, a different string, and itself a grab-bag with no dominant owner (Microsoft Compound File in roughly a quarter of uses, JSON and ZIP in the rest). There is no incumbent to inherit a collision from.
  • It passes the test .aar failed. A search for .aarl returns this format rather than someone else’s ecosystem. Searchability is the whole reason .aar was rejected, so a replacement that failed the same test would be no improvement.
  • It signals the shape. The trailing l reads as JSON Lines, which is what §1.1 of the spec says the file is.

The options

Only two were live, and this ADR does not manufacture a third:

  • .aar — unavailable, for ADR-0008’s reasons above.
  • Leave it unspecified, which is what the session.<ext> placeholder encoded. Tenable on the merits, since nothing parses the name — but it leaves every example writing a placeholder and every reader inventing a spelling, which is how two incompatible conventions start. The spec’s own header called the placeholder a debt due at AILAB-623.

Consequences

  • The prose name is untouched. “Agent Action Record” is ADR-0008’s decision and stands. The soft collision with after-action report is a property of the acronym, not of the extension; this decision neither creates it nor fixes it.
  • The extension is a convention, not a wire fact. Nothing in the format is carried by the filename. aegis verify and aegis recheck accept any path and validate no extension, deliberately — a verifier that rejected a record for its name would be enforcing a convention the format does not state as a MUST. That behaviour is unchanged here and should stay unchanged.
  • ADR-0008 still writes session.<ext>, and stays that way. It recorded correctly that the extension was open on the day it was written. An ADR is not revised once reality moves past it, so the note under the table in docs/adr.md points a reader here instead — the same handling ADR-0001’s stale aegis replay already gets.
  • Reversible until a filename ships in a release. No committed record, golden vector or parser depends on the spelling, so changing it costs a documentation sweep and nothing else. It stops being cheap once 0.4.0 publishes examples a reader copies.

Wrap may synthesize a JSON-RPC error for an opted-in enforcement refusal

Status: accepted (2026-09-17) · implemented in AILAB-793 (2026-09-18)

Implemented, with two corrections to the body below. aegis wrap --policy <YAML> refuses a tools/call with exactly this frame, and the record for a refused or allowed call now carries the governing Policy Set’s content hash instead of the pass-through stand-in. The body is the decision as written on 2026-09-17 and is not revised; two of its forward-looking sentences did not survive contact:

  • Telling a wrap refusal from a genuine child error, third bullet, promises “the real policy-set hash and the real grant”. Only the hash shipped. A gate decides whether a call may go and resolves no capability, so every wrap record — enforced or not — still carries the deny_all pass-through grant.
  • What enforcement still relays, last sentence, says docs/wrap.md “stays until 793 makes it false”. 793 made it false: that page now documents --policy and no longer claims nothing is ever blocked.

Not shipped, and not this ADR’s to schedule: the approval park (AILAB-629). A PendingApproval verdict is refused with data.aegis.code PENDING_APPROVAL, not parked.

Under an opted-in enforcement mode, botzr-aegis-wrap may write to the client a JSON-RPC error frame that no child produced. That is the only frame wrap is ever permitted to author, and it may answer only a tools/call that wrap refused before the request reached the child.

The default does not change

Default wrap remains a transparent interposer. Every client frame reaches the child, every child frame reaches the client, and wrap writes no JSON-RPC of its own. That is today’s behaviour and it stays the default after AILAB-793: enforcement is a mode an operator opts into, never a mode an aegis wrap invocation falls into. A default-mode wrap process is byte-identical on the client stream to one without this feature.

Silence is not a refusal

An enforcement mode that swallows a refused tools/call and writes nothing back has not protected the client, it has hung it: the client is blocked on an id that will never be answered, and the operator is left with a timeout whose cause is invisible at the protocol layer. So when enforcement refuses a tools/call, wrap must write a well-formed JSON-RPC error to the client. A hung client is worse than a labelled one.

The frame

{
  "jsonrpc": "2.0",
  "id": "<echoed from the refused request; JSON null if the request had no id>",
  "error": {
    "code": -32042,
    "message": "aegis wrap refused this tools/call",
    "data": {
      "aegis": {
        "layer": "wrap",
        "code": "POLICY_DENIED"
      }
    }
  }
}

The id is echoed from the refused request, and a request that carried no usable id is answered with JSON null — the id is a placeholder in the sketch above, not a literal. -32042 sits inside JSON-RPC 2.0’s implementation-defined server-error range (-32000..-32099), so a conforming client already knows the code is the server’s to define.

AILAB-793 may vary data.aegis.code across the gateway’s existing string table (POLICY_DENIED, CAPABILITY_DENIED, … at mcp.rs:150-165), so that a wrap refusal and a gateway denial name the same cause with the same word. It may not vary error.code, data.aegis.layer, or the error-rather-than-result envelope. Those three are the shape a client integrates against, and a mode that varied them would be a mode nothing could be written against.

Telling a wrap refusal from a genuine child error

Three signals, in increasing strength:

  • The numeric code is wrap-reserved in this project. Zero shipped children emit -32042, and the one fixture child that emits a tool error uses the generic -32000 slot (mirror_child.rs:173).
  • data.aegis.layer is the string wrap — the frame names the layer that authored it, rather than leaving a reader to infer authorship from a number.
  • Once 793 lands, wrap’s own audit record for that call carries the real policy-set hash and the real grant, not the pass-through placeholders a relayed call gets today.

The JSON marker is not a cryptographic proof, and a hostile child could copy every field of it. The audit line is the evidence that the request never left wrap; the marker is a courtesy to a well-behaved client, and this ADR claims nothing more for it.

What enforcement still relays

-32601 stays forbidden, under enforcement exactly as without it. Unknown methods, initialize, tools/list, ping and notifications all still go to the child. Wrap never answers for a child that was never asked whether the method exists — “this call is refused” is a claim wrap is in a position to make, and “no such method” is not.

Enforcement therefore spends transparency on precisely one frame: the client-facing answer to a tools/call it refused. An allowed tools/call is relayed verbatim, its response is relayed verbatim, and every other method is untouched. Users are told so here and in the wrap crate README. docs/wrap.md still says nothing is ever blocked at this layer, which is true of this build and stays until 793 makes it false.

The options rejected

  • Refuse synthesis, and keep the flat ban. Then AILAB-793 can enforce but cannot tell the client anything, and rung 4 of the adoption ladder moves to the gateway. Rejected: wrap is the named enforcement point for an unmodified third-party server, and a hung MCP client is not a transparency feature.
  • Synthesize the gateway’s result with isError: true (mcp.rs:141-145). Rejected: a JSON-RPC result is wrap speaking as the child, which is the impersonation the flat ban existed to prevent. A JSON-RPC error is wrap speaking as the interposer. The gateway’s shape is right for the gateway, which owns the tool call; it is wrong for a layer whose whole claim is that it is not the server.
  • Reuse -32601 or -32000. -32601 is the specific lie an_unknown_method_is_relayed_and_never_locally_refused exists to prevent, and this ADR does not relax it. -32000 is already the mirror child’s tool-error code (mirror_child.rs:173) and the slot any real server reaches for first, so a refusal wearing it would be indistinguishable from the child answering badly.

Consequences

  • AILAB-793 is unblocked on the client-facing shape. The code, the envelope and the layer marker are settled here, so 793 argues about enforcement and not about wire format.
  • Wrap’s crate graph does not grow a policy engine in this ticket. Wrap depends on audit, core and confine, and an ADR does not add a dependency. Where the refusal decision comes from is 793’s problem.
  • The pass-through policy_set_hash at record.rs:30-37 becomes dishonest the moment a real engine runs. Its doc comment records that nothing was scheduled to replace it; this decision schedules something. Replacing it is AILAB-793’s acceptance criterion, not this ticket’s.
  • It supersedes the flat ban in wrap’s module docs and README, openly. record.rs and the crate README both said wrap never synthesizes a response at all. That sentence was a correct description of the build and an over-broad promise about the design; both now point here instead. It supersedes no other ADR.

Aegis audit schema (v1 freeze)

⚠️ SUPERSEDED for schema version 2. AILAB-619 bumped AUDIT_SCHEMA_VERSION to 2: phase became line_type with six values, input_digest became request_digest, and every line gained chain (seq, prev_hash) and signature (signature, key_id) fields. The current wire contract is spec/SPEC.md. This page is kept as the v1 record; do not implement against it.

Status: Frozen at schema_version: 1 for v0.1 (OQ-15 T7 / AEG-34)
Authority: types in crates/botzr-aegis-core/src/audit.rs · writers in botzr-aegis-audit
Constant: AUDIT_SCHEMA_VERSION = 1 (u32)

This document is the Layer 2 / governance input contract for JSONL audit lines. CI golden snapshots freeze the wire shape; this page documents that shape for humans.

Additive-only evolution: new fields may be introduced via serde(default) and/or Option + skip_serializing_if. Consumers must ignore unknown fields. A breaking change requires bumping AUDIT_SCHEMA_VERSION (out of scope for this freeze).


Record kinds (two-phase durability)

Each tool call produces two JSONL lines (G3):

  1. Intent — appended before sandbox / host work begins (phase: "intent").
  2. Outcome — appended on every exit path (phase: "outcome"), including policy deny, capability deny, success, trap, resource exceeded, and host deny. The CallSession Drop guard makes this structural: an incomplete session always emits exactly one fail-closed outcome — a trap ("host panic during tool call") if the runtime panics mid-call, otherwise a host_denied ("session abandoned") for any other abandon / early return / error — so a forgotten complete() can never leave an orphan intent.
Rust typephase valueRole
AuditIntent"intent"Pre-work line
AuditRecord"outcome"Post-work / exit-path line

AuditPhase serializes with serde rename_all = "snake_case".


Intent fields (AuditIntent)

JSON fieldTypeNotes
schema_versionu32Always 1 today (AUDIT_SCHEMA_VERSION)
phasestringAlways "intent"
call_idstringStable id for pairing intent ↔ outcome
tool_idstringToolId (serde transparent newtype)
input_digeststringDigest of call input — not raw input

Outcome fields (AuditRecord)

JSON fieldTypeNotes
schema_versionu32Always 1 today
phasestringAlways "outcome"
call_idstringMatches the prior intent line
tool_idstringToolId
input_digeststringSame digest policy as intent
policyobjectTagged PolicyOutcome (see below)
capabilityobjectTagged CapabilityOutcome
executionobjectTagged ExecutionOutcome
wall_msu64, optionalPresent only when sandbox ran; omitted otherwise (skip_serializing_if none)
peak_memory_bytesu64, optionalPeak guest linear memory when sandbox ran; omitted otherwise

Optional helpers on the Rust side: CallMetrics { wall_ms, peak_memory_bytes } via AuditRecord::with_metrics. Those two numeric fields are flattened onto the outcome record — there is no nested metrics object on the wire.


Tagged outcomes

All three enums use #[serde(tag = "status", rename_all = "snake_case")]. The status field selects the variant; payload fields sit beside it.

PolicyOutcome (policy)

statusExtra fields
"allowed"(none)
"denied"reason (string)
"rate_limited"reason (string)
"pending_approval"approval_id (string)

CapabilityOutcome (capability)

statusExtra fields
"granted"grant (CapabilityGrant object)
"denied"reason (string); denied_capability (string | null) — optional machine-readable axis (e.g. fs, net.http, tool.registry)

CapabilityGrant (when granted) includes: grant_id, tool_id, fs / net (optional), max_memory_bytes, max_wall_ms, max_output_bytes. See crates/botzr-aegis-core/src/grant.rs.

ExecutionOutcome (execution)

statusExtra fields
"success"(none)
"trap"message (string)
"resource_exceeded"kind (string)
"host_denied"reason (string)

Digests and secrets

Shipped today

  • input_digest on both Intent and Outcome. It is runtime-derived: the pipeline computes sha256_hex(input) from the exact bytes the execution step will see. No public runtime API (execute_tool_call, execute_host_call) accepts a caller-supplied digest, so the field cannot be made to disagree with the payload.
  • No raw tool input, env, or tokens in the audit line.

Absent / deferred (not in schema v1 wire format)

  • output_digest — not a field on AuditIntent or AuditRecord. Do not expect or emit it. Threat-model wording matches this (AEG-35).

Metrics

FieldWhen presentWhen omitted
wall_msSandbox execution completed (or was measured)Policy/capability short-circuit; sandbox never ran
peak_memory_bytesSame as wall_msSame

Both use #[serde(default, skip_serializing_if = "Option::is_none")].


Sinks

SinkStatus
JSONL via AuditWriterShipped — append one JSON object per line; per-line flush + fsync (sync_all) for durability
OpenTelemetry / OTLP exportDeferred — not in v0.1; no OTel crate or export path

Schema version is validated by the writer before each emit (AUDIT_SCHEMA_VERSION).


Golden snapshots (CI freeze)

Schema drift fails CI. Cite these fixtures:

Unit / crate goldens — crates/botzr-aegis-audit/tests/golden/

The eight outcome-class goldens this freeze covers:

  • policy_deny.json
  • capability_denied.json
  • rate_limit.json
  • pending_approval.json
  • trap.json
  • resource_exceeded.json
  • panic.json — Drop panic-guard outcome (default-deny seeds + host-panic trap)
  • abandoned_session.json — begun-but-never-completed outcome (default-deny seeds + host_denied session abandoned)

That directory now holds twelve files, not eight. Schema v2 added session_open.json, session_close.json and decision.json for the three line types v1 had no concept of, and intent.json for the pre-work line this page documents but never froze. The four are outside this freeze; they are specified, with their canonical bytes and chain hashes, in spec/SPEC.md §11.2.

Deny-suite goldens — tests/deny-suite/tests/golden/

  • policy_deny.json
  • capability_net_denied.json
  • capability_unregistered.json
  • resource_memory.json

Orchestrator golden —

  • crates/botzr-aegis-runtime/tests/golden/resource_exceeded_orchestrator.json

Authority

ConcernLocation
Record types, enums, AUDIT_SCHEMA_VERSIONbotzr-aegis-core (src/audit.rs)
JSONL writer, CallSession / Drop-trapbotzr-aegis-audit
This freeze documentdocs/audit-schema.md

Security

GitHub’s security tab reads the repository-root SECURITY.md. This chapter is that file, included verbatim rather than summarised, so book readers get the whole policy — reporting channel, supported versions, in-scope crates, and the Verification posture (cargo-deny, deny.toml, and why Miri is not a v1 gate) — not just the disclosure SLA.

Read the threat model first. Scope of protection and scope of disclosure are one story.


Security Policy

Aegis is a research instrument for secure agent tool execution. This document covers how to report vulnerabilities, what we support, and what is in scope.

Threat model: docs/threat-model.md — read this first to understand what Aegis protects and its named non-goals. Scope of protection and scope of disclosure are one story.


Reporting a vulnerability

Please do not open public GitHub issues for security vulnerabilities.

Report privately to:

Email: support@botzr.com

Include:

  • Description of the issue and impact
  • Steps to reproduce (proof-of-concept welcome)
  • Affected version or commit SHA
  • Your contact for follow-up (optional)

You may also use GitHub private vulnerability reporting on the repository if you prefer that channel.

What to expect (solo maintainer SLA)

StageCommitment
AcknowledgmentWithin 72 hours of report receipt
Triage / updatesBest effort; severity-dependent
Fix & advisoryBest effort; critical issues prioritized
CreditWith reporter permission, in the GitHub Security Advisory

This is a solo-maintainer project. Timelines are honest estimates, not enterprise SLAs. We appreciate responsible disclosure and will work in good faith.

Coordinated disclosure

We follow a 90-day coordinated disclosure window by default:

  1. Reporter submits privately.
  2. Maintainer acknowledges and triages.
  3. Fix developed and validated.
  4. Advisory published via GitHub Security Advisories.
  5. RustSec / cargo audit ecosystem notified for in-scope runtime crates when applicable.

If a fix cannot ship within 90 days, we will agree on an extension with the reporter. Public disclosure before a fix is available should be coordinated with the maintainer.


Supported versions

Pre-1.0 releases receive security fixes on the latest minor release only.

VersionSupported
latest 0.x.y on main✅
older 0.x.* tags❌

Check releases for the current tag. Integrators should pin to a released version and plan upgrades.


In scope

Security issues in the Aegis runtime crates that affect confidentiality, integrity, or availability of the enforcement pipeline:

CrateScope
botzr-aegis-coreTypes/traits used in enforcement decisions
botzr-aegis-policyPolicy evaluation bypass, silent widening
botzr-aegis-capabilityGrant minting, default-deny bypass
botzr-aegis-sandboxWASM isolation, cap-std preopens, host-function grant enforcement
botzr-aegis-auditRecord emission gaps, secret leakage in audit fields, chain or signature forgery (schema v2 JSONL: request_digest plus optional response_digest, hash-chained and ed25519-signed; no raw payloads, no OTel export — see the record format spec)
botzr-aegis-runtimePipeline ordering, audit-on-all-exits

Also in scope:

  • Deny-suite or adversarial-demo tests that demonstrate a containment failure
  • Resource limit bypass (memory, wall-clock, output size)
  • Dependency CVEs in pinned wasmtime/cap-std when exploitable through Aegis’s use

Out of scope

CategoryExamples
Layer 2 governancePre-release; not part of v1 runtime
Example / fixture codeexamples/, tests/fixtures/damage-bot — deliberately adversarial test guests
Third-party WASM guestsTool code you compile and run; Aegis does not audit your guest’s business logic
Upstream wasmtime/cap-stdReport to Bytecode Alliance / upstream; we track and bump pins
Deployment misconfigurationOver-broad grants, shared host credentials, missing network segmentation
Prompt injection / return-value exfilNamed non-goals — see threat model §6
Model B marketed as full isolationDocumentation issue if our docs are wrong; design limitation if expectations exceed Model B

Hardening recommendations for integrators

These are not Aegis bugs, but common failure modes:

  1. Keep host functions small and audited. Every Model B import is a hole in the sandbox wall.
  2. Never configure the sandbox from the raw request — only from the resolved grant.
  3. Use cap-std preopens for filesystem scoping; do not hand-roll path prefix checks.
  4. Deploy process-per-tenant for multi-tenant isolation (v1 honesty boundary).
  5. Read the threat model before claiming isolation guarantees to downstream users.

Verification posture

The workspace forbids unsafe (unsafe_code = forbid workspace-wide), so Miri on Aegis crates is not a v1 verification gate — there is no first-party unsafe code for it to check. Upstream dependencies such as wasmtime and cap-std do contain their own unsafe and own their UB surface; we consume them as pinned dependencies and track RUSTSEC advisories via cargo-deny (deny.toml + CI supply-chain and weekly advisory jobs) rather than re-auditing their unsafe code here. See docs/threat-model.md for what isolation is and is not claimed.


Contact