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
- How to install and run one Model A call
- The load-bearing pipeline and the two trust models
- The CLI, including
aegis wrap— records everytools/call; evaluates policy only with--policy; confines the child only with--confine(Linux) - Policy YAML as it ships today (
tool/capability/rolematchers only) - Evidence: threat model, findings, record format
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
aegissubcommand exceptrun—keygen,verify,recheck, andwrapall landed after0.3.0was 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.
| Layer | Linux | macOS |
|---|---|---|
| Policy evaluation — tool id, role and capability axes | yes | yes |
| Capability grants — default-deny, minted per call | yes | yes |
Model A cell — a per-call wasmtime Store built from the grant | yes | yes |
Signed, hash-chained record (schema_version: 2) on every exit path | yes | yes |
aegis verify — pinned or unpinned | yes | yes |
aegis recheck — executes nothing | yes | yes |
aegis wrap --policy — refuses a tools/call | yes | yes |
aegis wrap --confine — Landlock fs + a seccomp network deny-list | yes | no |
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
| Station | Role |
|---|---|
| Policy | Role gate, approval gate, rate limits — sync eval over a parsed-once, immutable PolicySet the engine holds in an ArcSwap for hot reload |
| Capability | Default-deny manifest resolution → minted grant; a denial never reaches the sandbox |
| Sandbox | Configure a per-call wasmtime Store from the grant, then run (cap-std preopens; epoch + memory limits) |
| Audit | Schema-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, androleonly. 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.0binary differs here. At tagv0.3.0there is no--signing-keyat all:--audit <PATH>is accepted alone, and the records it writes are unsigned — no signing code shipped at that tag, so a0.3.0audit file carries no signature to attribute andaegis verifyhas nothing to check. It also defaults to a temp file sink and prints that/tmp/…/audit.jsonlpath 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, aremainchanges that reach the registry with the next cut. Flags on this page aremain’s; do not read them against an installed0.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-formedtools/callbefore the frame may reach the child. A denied one never reaches it and the client is answered with JSON-RPC-32042rather than left blocked on an id (ADR-0015). Without--policywrap evaluates no rules and blocks nothing — everytools/callis relayed, and the client stream is byte-for-byte the child’s. A rule here matches tool identity; wrap never inspectsparams.arguments, and mints no capability under either mode, so even an allowed call records thedeny_allpass-through grant.--confineapplies 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):
| Exit | Meaning |
|---|---|
0 | Verified |
1 | Tampered — or a usage error |
2 | Could not read the record or the trust store |
3 | Indeterminate |
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 everyopenkey 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.
| Exit | Meaning |
|---|---|
0 | Every call unchanged |
1 | A call is newly blocked, allowed, or parked — or a usage error |
2 | Could not read the policy or the record |
3 | Indeterminate — 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: everytools/callis relayed and nothing is blocked at this layer.--policy <YAML>(AILAB-793) is the opt-in — see Enforcing a policy — and a deniedtools/callis then refused with a JSON-RPC-32042error (ADR-0015). NoPolicyEnginelives in the wrap crate either way: it asks aCallGateand the CLI implements one. - No argument matching. Wrap does not look at
params.argumentsat 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 thedeny_allpass-through grant. - No filesystem or network restriction unless
--confine. Default wrap is an ordinary OS process under the operator’s account.--confineapplies Landlock and seccomp derived from--allow-read/--allow-write/--allow-net(Linux).--confinewith no--allow-*is deny-everything.--best-effortis 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-netit kills the socket/connect/bind family onSIGSYS; everything it does not name —ptrace,unshare,mount— is permitted. With--allow-netthe filter is installed and denies nothing, which is why the enforcement record carriesseccomp_network_deniedalongsideseccomp_applied. Read the pair, neverseccomp_appliedalone. - 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 saidseccomp_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 onSIGSYSunder a network-denying profile. Give it--allow-netor 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
--confineis 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:
- Deny-overrides — any matching
denywins outright. - Among the rest, most-specific wins (more constrained match axes).
- Ties broken by explicit rule
priority(higher wins). - No match → the set’s
defaultaction.
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 aHostEffectContextbuilt 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": "..." }.
| Tool | Default policy | Purpose |
|---|---|---|
echo | allow | Happy-path tools/call through the full pipeline |
exfil | deny (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.0caveat 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.
| Station | Role |
|---|---|
| Policy | Role gate, approval gate, rate limits (sync; parsed-once Arc<PolicySet>) |
| Capability | Default-deny manifest resolution → minted grant (denial never reaches sandbox) |
| Sandbox | Configure wasmtime Store from the grant, then run (per-call store; epoch + memory limits) |
| Audit | Schema-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
| Asset | Why it matters |
|---|---|
| Host filesystem outside granted preopens | Prompt-injected agents must not read/write arbitrary paths |
| Host network outside granted hosts | Exfiltration and lateral movement via outbound HTTP |
| Capability grant integrity | The grant is the sole authority that configures the sandbox |
| Audit trail integrity | Forensics and accountability for every tool call |
| Host process stability | Runaway or malicious guests must not exhaust host resources indefinitely |
| Caller secrets in tool input | Raw secrets must not appear in audit records |
Adversaries assumed
| Adversary | Capability |
|---|---|
| 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 operator | Over-broad grants, missing host-function checks, shared host credentials |
| Compromised host process | Attacker 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-stdpreopens (no hand-rolledpath.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:
| Path | Enforcement | Status |
|---|---|---|
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_call | Structural — 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 unchecked | Supported |
Raw closure passed to Runtime::execute_host_call_with | Convention only — the runtime hands the closure a &CapabilityGrant and checks nothing before the effect runs; it applies the output cap afterwards | Research 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:
- Default deny. Unregistered tools, undeclared capabilities, and policy denials never reach sandbox instantiation.
- Grant-driven sandbox config. The
Storeis configured from the resolved grant, never from the raw request. - Filesystem containment (Model A). cap-std preopens block
..traversal, symlink escape, and TOCTOU races that defeat naive path-prefix checks. - Network containment (Model B). Host HTTP imports check
NetGrant.httpallow-list entries (host/ports/methods) before any outbound request. - Resource bounds. Per-call memory cap, epoch wall-clock budget, and
max_output_byteslimit 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. - 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.
| Approach | What it protects well | What it does not protect |
|---|---|---|
| eBPF / LSM (kernel) | Syscall-level enforcement on known processes; network egress at the host | Tool semantics, prompt-injected argument choices, return-value exfil to the LLM, WASM guest logic before syscalls |
| Network proxy / MCP gateway | Egress filtering, request logging, rate limits at the wire | In-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 tools | Only 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 tools | Not 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 context | Not 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:
| Mitigation | Effect |
|---|---|
max_output_bytes | Caps single-call return size, runtime-enforced on returned bytes (default 1 MiB) |
request_digest in audit | Forensic 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 audit | Forensic 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 limits | Friction 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
| Risk | Severity | Notes |
|---|---|---|
| Missing grant check in a new host function | Critical | #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-std | High | Bypassable via .., symlinks, TOCTOU — use cap-std preopens for Model A |
| Policy evaluated after capability minted | High | Pipeline order violation; denied calls must never get a Store |
| Hung Model B I/O ignoring epoch | Medium | Wall-clock timeout + cancellation token required for host I/O |
| Audit sink failure | Medium | Fail-closed vs fail-open is a deployment choice (see G3) |
| wasmtime / cap-std CVE | Platform | Track upstream advisories; in-scope for SECURITY.md |
| Return-value exfil (G9) | Accepted non-goal | Partial 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.
| Artifact | What it shows |
|---|---|
| DamageBot demo | Six adversarial cases through Runtime::execute_tool_call |
| Deny suite | Policy/capability/sandbox denial paths + audit emission |
| Record format spec | Field-level line contract, chain rule, verdict model, and the non-guarantees stated plainly |
| Hot-path benchmarks | Policy ≪ 100 µs; combined pipeline ≪ 1 ms (cited hardware) |
| Findings report | Measured 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
ResourceExceededaudit 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_stringOOB 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 —ToolManifestis 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 testsrejects_fs_write_escalation,rejects_output_cap_escalation),crates/botzr-aegis-capability/tests/capability.rs(proptestnarrowed_grant_never_broader_than_parent) -
Command:
cargo test -p botzr-aegis-capabilityTargeted 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 undertests/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 testaudit_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-Nintent 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 -Vvhead,uname -a, timestampdeny-suite.log—cargo test -p aegis-deny-suiteadversarial-demo.log—cargo test -p aegis-adversarial-demostress.log—cargo test -p aegis-stress-suitepointers.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
exfilcall returns"code": "POLICY_DENIED"with"isError": true, and the audit file carries aschema_version: 2outcome forexfilwhosepolicy.statusisdenied,capability.statusisdenied(“policy blocked before capability”) andexecution.statusishost_denied(“not executed”).aegis verify --key <public_key>reportsVerified (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 asbotzr-aegis-mcptests undercargo test --workspace. - A recording is committed at
docs/demos/mcp-live-deny.cast— replay it withasciinema 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_all13.4 ns median,policy_eval/multi_rule31.8 ns,hot_path/multi_rule2.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/warm49.339 µs median against a <0.5 ms target, andinstantiation/cold39.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 throughSandboxEngine::execute, sobuild_storeand a trivial WITrunare 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 intoSandboxEngine::new); the figure has not been re-measured since.audit_emission/begin_completeis 4.7185 ms and is write+fsync latency, not an Aegis-side overhead figure — aserialize_onlycontrol 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 (coldspans 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.mdcampaign 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_idand a publishedpublic_key, and a verifier needs nothing else. The implementation is specified incrates/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:
| Exit | Meaning |
|---|---|
0 | Verified |
1 | Tampered, or a usage error — an unknown flag, a --key that is not 64 hex, a missing <PATH> |
2 | The record file or the trust store could not be read |
3 | Indeterminate, 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 form | Meaning |
|---|---|
| digest | 64 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 key | 64 lowercase hex characters — a 32-byte ed25519 public key. |
| signature | 128 lowercase hex characters — a 64-byte ed25519 signature. |
| integer | JSON number, non-negative, u64, strictly below 2^53 (§3.2). |
| string | JSON 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
- 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.
- No insignificant whitespace: no spaces after
:or,, no indentation, no newlines inside a Line. - Array order is preserved exactly. Arrays are never sorted.
- Strings escape only what JSON requires —
",\, and control characters — using\b,\t,\n,\f,\rwhere those short forms exist and lowercase\u00xxotherwise. 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 readingseqas aNumber, 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 nearu64::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
nullanywhere in a Line is invalid. The one field that is always present but may be empty isdecision_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:
- Assign
seq— the next position in this Session — andprev_hash— the hash of the Line written immediately before it in this Session. - Compute
signing_input= the canonical form of the Line with thesignaturemember omitted andkey_idpresent. - If the Line is in the signed set (§5.1):
signature= ed25519 oversigning_input; write bothsignatureandkey_idinto the Line. - Compute
line_hash= SHA-256 over the canonical form of the complete Line, signature included. - Write the canonical form as one JSON Lines row; flush; fsync.
- Set the Session tail to
line_hash. The next Line’sprev_hashis 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:
| Field | Type | Notes |
|---|---|---|
schema_version | integer | 2 for this specification. |
line_type | string | See §5.1. |
seq | integer | Position within the Session, from 0. |
prev_hash | digest | Hash 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_type | Signed | Emitted by v0 | Role |
|---|---|---|---|
open | yes | yes | First Line of a Session. Carries the public key and prev_session_tail. |
intent | no | yes | Pre-execution Line for a Call. |
outcome | yes | yes | The Agent Action Record — one per Call, on every exit path. |
decision | yes | no — no caller | A human approval verdict (ADR-0005). |
close | yes | yes | Last Line of a Session. |
checkpoint | yes | no — reserved | Defined 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 typefooat 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
| Field | Type | Notes |
|---|---|---|
schema_version, line_type, seq, prev_hash | — | seq is 0; prev_hash is the genesis digest (64 zeros). |
prev_session_tail | digest, optional | The previous Session’s final line_hash. Omitted for a fresh file. See §7. |
public_key | public key | The 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
| Field | Type | Notes |
|---|---|---|
schema_version, line_type, seq, prev_hash | — | |
call_id | string | Pairs this Line with its outcome. |
tool_id | string | |
request_digest | digest | SHA-256 over the verbatim request bytes (§9). |
No signature, no key_id. An emitter MUST NOT sign an intent Line.
outcome
| Field | Type | Notes |
|---|---|---|
schema_version, line_type, seq, prev_hash | — | |
call_id | string | Matches the Call’s intent Line. |
tool_id | string | |
request_digest | digest | Identical to the intent Line’s. |
policy_set_hash | digest | Which Policy Set governed this Call (§6). |
policy | object | Tagged by status: allowed · denied {reason} · rate_limited {reason} · pending_approval {approval_id}. |
capability | object | Tagged by status: granted {grant} · denied {reason, denied_capability?}. |
execution | object | Tagged by status: success · trap {message} · resource_exceeded {kind} · host_denied {reason}. |
grant_id | string, optional | The grant the Call ran under. Omitted when none was minted. |
response_digest | digest, optional | SHA-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_ms | integer, optional | Omitted when the sandbox never ran. |
peak_memory_bytes | integer, optional | Omitted when the sandbox never ran. |
decision_axes | object | Always 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.
| Field | Type | Notes |
|---|---|---|
capability | string, optional | The capability axis the Call requested, e.g. fs.read. |
role | string, optional | The role asserted by the caller. |
session | string, optional | The policy session scope — the PolicyRequest scalar, not the audit Session. |
matched_rule | string, optional | The rule that decided it. |
approval_ref | string, optional | The approval a resumed Call was allowed under (ADR-0005). |
fs | object, optional | Derived filesystem parameter — the grant’s single root: {path_raw, path_canonical}. |
net | object, optional | Derived 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
| Field | Type | Notes |
|---|---|---|
schema_version, line_type, seq, prev_hash | — | |
approval_id | string | The park this verdict answers. |
verdict | object | Tagged 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
| Field | Type | Notes |
|---|---|---|
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
openLine atseq0. seqrestarts at 0 in every Session. A file with two Sessions has two Lines numberedseq5.- An
openLine’sprev_hashis 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_tailon theopenLine, and MUST equal the previous Session’s finalline_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-casesopenbecause that is where the public key is. - A Session ends with a
closeLine, 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:
| Verdict | Meaning |
|---|---|
Verified | Every Line parsed, chained and — where required — verified, and the file ends with a verified close. |
Indeterminate | Nothing contradicts the file, but something could not be decided. Carries a typed reason. |
Tampered | The 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_hashis not the hash of the Line before it — an edit, a removed Line, or a forked chain. seqrepeated 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
outcomeLine lands here. - A Line’s
key_iddoes not match the key its Session published (§8.4). - An
openLine’sprev_session_taildoes 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
openLine publishes a public key that is not in a trust slice the caller supplied (§8.4). NotVerified (unpinned): the caller stated which keys it accepts and the file answered with another one. - A second
decisionLine for anapproval_idalready 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: acheckpointthat is unsigned or whose signature does not verify isTampered(§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
closeand 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 —
seqjumped forward whileprev_hashstill matched. Only a writer can produce this (§4); an attacker cannot, because removing a Line breaks the next Line’sprev_hashand re-signing the remainder needs the key. So this is a durability incident, not a forgery, and calling itTamperedwould 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:
- The rotation rule is enforced across the whole signed set. A foreign
key_idon anopen,outcome,decisionorcloseLine isTamperedwith a key-mismatch reason, and a Session-opening rotation is accepted, both under test.checkpointis in the signed set and is held to it: acheckpointwhose signature does not verify isTampered, not capped — discarding that result would let a forged Checkpoint hide behind theIndeterminatecap. An unknown-type Line is judged on what it carries: a signature that is present and does not verify isTampered, 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 asVerified. - Pinning ships. The walker takes an optional slice of public keys the caller
anchored out of band, and
aegis verifyfills it from--keyand--trust-store(see Theaegis verifycommand surface, above), so both success labels are reachable. EveryopenLine’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 isTamperedwith 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 fromVerified (unpinned)toVerified (pinned to <fp>), or toVerified (pinned)when the file legally rotates across several anchored keys. - Remaining gap: a signature on an
intentLine is ignored rather than rejected. It is still covered byline_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 theintentandoutcomeLines 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_digeston 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↔outcomeis 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↔decisionis a soft cross-reference that may span Sessions and files. A human approving after a restart is normal. Apending_approvaloutcome with nodecisionis legal and informational; adecisionfor an absentapproval_idis legal and informational. TwodecisionLines for oneapproval_idis 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:
| Quantity | Value |
|---|---|
key_id = SHA-256 of the 32 public-key bytes | 77a2c2f5952039243c043b69e7e812a2deb69e3271adb3013b8f24d3b8ea40f6 |
line_hash = SHA-256 of the canonical bytes above | ace4118a6a4ec1bf47503431aaa769ebb74de9c61174bf64470561a99abf4066 |
Line 1’s prev_hash | the 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_idis identical on every machine. A Line it signs proves that some Aegis build wrote it, never which — a verifier can only reportVerified (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 class | Verdict and reason | How it is damaged |
|---|---|---|---|
prev_hash_mismatch.aarl | A Line’s prev_hash is not the hash of the Line before it | Tampered — chain broken at (session 0, seq 3) | The close Line’s prev_hash is overwritten with 64 fs |
seq_out_of_order.aarl | seq repeated or went backwards | Tampered — seq out of order in session 0: expected 4, found 1 | The unsigned intent Line is replayed at the end, keeping seq 1, with its hash link repaired |
bad_signature.aarl | A Line in the signed set carries no signature | Tampered — bad signature at (session 0, seq 2): line carries no signature | The close is dropped and the outcome Line it exposes loses its signature member |
key_id_mismatch.aarl | A Line’s key_id does not match the key its Session published | Tampered — 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.aarl | An open Line’s prev_session_tail does not match the previous Session’s final Line | Tampered — session boundary broken at session 1 | Two Sessions in one file, with the first Session’s close removed |
malformed_line.aarl | A Line outside the format | Tampered — malformed line 3 | The bytes not json at all are inserted before the final Line |
untrusted_key.aarl | An open Line publishes a key that is not in a trust slice the caller supplied | Verified with no trust slice; Tampered — untrusted key at (session 0, seq 0) against one | Not damaged at all. See below |
duplicate_decision.aarl | A second decision Line for an approval_id already decided in the file | Tampered — duplicate decision at (session 0, seq 2) for apr-vector-8 | Two 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:
| Quantity | Value |
|---|---|
What the close Line claims as prev_hash | ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff |
| What the Line before it actually hashes to | 9a2c3c9f6c9f58effd1636bbb31c30fad2fcbcbd25ea5d2679c835740370cb9c |
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 class | Verdict and reason | How it is damaged |
|---|---|---|---|
unknown_line_type.aarl | Unknown line type | Indeterminate — 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.aarl | Reserved checkpoint | Indeterminate — 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.aarl | Torn final Line | Indeterminate — final Line 5 does not parse | A closed Session with a Line cut off mid-write appended after it, and no trailing newline — a torn write ends mid-Line |
unanchored_tail.aarl | Unanchored tail | Indeterminate — session 0 has no close record; 1 Call in flight, call-unanchored-tail | A Session’s outcome and close are both dropped, leaving open and intent |
missing_line.aarl | Missing Line | Indeterminate — session 0 skips seq 2..3 with the chain intact | The outcome append was refused by the Sink, so seq 2 was consumed and never written; the Session then closed at seq 3 |
empty_chain.aarl | Empty chain | Indeterminate — no Lines to verify | Not 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
Verifiedover aline_typeit does not recognise (§5.2). - MUST treat a Line missing
line_type,seqorprev_hashas 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.
| Scope | Group | Target | Status |
|---|---|---|---|
| Policy eval alone | policy_eval/allow_all, policy_eval/multi_rule | < 100 µs | pass |
| Combined policy + capability | hot_path/multi_rule | < 1 ms | pass |
| Warm cell instantiation | instantiation/warm | < 0.5 ms | pass |
| Cold instantiation | instantiation/cold | < 5 ms | miss — 39.490 ms, ~7.9× over |
| ed25519 line signing | audit_signing/sign_outcome_line | < 50 µs | pass |
Wrap relay per recorded tools/call | wrap_relay/tools_call_recorded | 0.5–2 ms (informational) | miss — 4.371 ms/call, ~2.19× over |
| Audit emission | audit_emission/begin_complete | no target set (fsync-bound) | n/a |
| Rate-limit path, capability alone, attribution splits | — | informational only | n/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
| Group | Median (approx.) | Target | Status |
|---|---|---|---|
policy_eval/allow_all | 13.4 ns | < 100 µs | pass |
policy_eval/multi_rule | 31.8 ns | < 100 µs | pass |
policy_eval/rate_limit | 183 ns | informational (mutex) | n/a |
capability_resolve/registered_tool | 2.43 µs | no hard gate | n/a — bench id and measured path both changed 2026-08-24; see AILAB-707 below |
hot_path/allow_all | 2.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 ms | pass — 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-capabilitycannot reachRuntime::register_tool(runtime depends on capability, so the reverse edge is a dependency cycle), so the bench moved toresolve_manifest, the supported one-off mint route shipped foraegis wrap --confine. Two pieces of work left the measured path: theHashMap<ToolId, ToolManifest>registry lookup, and theResourceCeiling::combinefold. Ceiling semantics are unchanged — the old call passedResourceCeiling::default()and the standing ceiling is also default, socombinewas 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 insidebotzr-aegis-runtime, so it now registers throughRuntime::register_toolin setup and measures againstrt.capabilities(). The closure still runsevaluate→decision.limits→resolve_with_ceiling, the registry is still a one-entry map, and registration is setup, never measured. The fixture manifest’sToolKindbecameHostso the handler matches; nothing on the production mint path branches on kind (everyToolKindin capability’smint.rs/narrow.rsis 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.
| Bench | pre-change | post r1 | post r2 | post r3 |
|---|---|---|---|---|
capability_resolve/mint_from_manifest | 2.5731 | 2.6472 | 2.5833 | 2.6314 |
hot_path/allow_all | 2.6238 | 3.6966 | 2.7764 | 2.7480 |
hot_path/multi_rule | 2.7286 | 2.8755 | 3.4096 | 2.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 throughRuntime::register_toolin setup and callsresolve_with_ceilinginsideb.iter— the changed path.capability_resolve/mint_from_manifestcallsresolve_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 whataegis wrap --confineuses. 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]:
| Bench | before (9b1d484) | after | median 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
| Group | Median | Target | Status |
|---|---|---|---|
instantiation/warm | 49.339 µs | < 0.5 ms | pass (~10× under) |
instantiation/cold | 39.490 ms | < 5 ms | fail (~7.9× over) |
instantiation/cold_engine_only | 456.08 µs | informational | n/a |
instantiation/cold_compile_only | 29.351 ms | informational | n/a |
audit_emission/begin_complete | 4.7185 ms | none, deliberately — see below | n/a |
audit_emission/serialize_only | 468.46 ns | informational | n/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.
| Run | warm | cold | cold_engine_only | cold_compile_only | begin_complete |
|---|---|---|---|---|---|
| 1 | 54.519 µs | 27.799 ms | — | — | 4.6620 ms |
| 2 | 57.894 µs | 37.237 ms | 412.58 µs | 29.806 ms | — |
| 3 | 54.493 µs | 29.700 ms | 441.35 µs | 29.601 ms | 5.0875 ms |
| 4 (published) | 49.339 µs | 39.490 ms | 456.08 µs | 29.351 ms | 4.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:
block_onruns 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 inSandboxEngine::newand 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);build_store— WASI ctx from the grant, memory limiter, epoch deadline;tool_pre.instantiate_async— the actual warm instantiation;- the WIT
runexport.
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
| Group | Median | Target | Status |
|---|---|---|---|
audit_signing/sign_outcome_line | 13.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
| Group | Range across six runs (provisional) | Target | Status |
|---|---|---|---|
audited_call/durable_sink | 2.86 – 17.5 ms — spans 6.1× across six runs in two sessions; not reproducible | none — see begin_complete above | n/a |
audited_call/volatile_sink | 32.9 – 37.4 µs — reproduces within 1.14× | none set | n/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.
| Run | Session | durable_sink | volatile_sink | ratio |
|---|---|---|---|---|
| 1 | 2026-09-01 | 3.2586 ms | 34.432 µs | 94.6× |
| 2 | 2026-09-01 | 2.8585 ms | 34.791 µs | 82.2× |
| 3 | 2026-09-01 | 3.7153 ms | 32.889 µs | 113.0× |
| 4 | 2026-09-07 | 17.505 ms | 34.889 µs | 501.7× |
| 5 | 2026-09-07 | 9.832 ms | 37.370 µs | 263.1× |
| 6 | 2026-09-07 | 13.895 ms | 33.566 µs | 414.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-utilsfeature on, because a pre-existing self dev-dependency (crates/botzr-aegis-sandbox/Cargo.toml:43) enables it for all dev targets sotests/sandbox.rscan reach the fixture API. The bench uses notest-utilsitem: the feature gates only additiveprepare_fixture/execute_fixture/PreparedFixture, none of which is on the measuredprepare/executepath. - No public API was added to either crate to make these benches possible.
serialize_onlyusesto_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.
| Group | Median / session (50 calls) | Median / call | Target | Status |
|---|---|---|---|---|
wrap_relay/tools_call_recorded | 218.54 ms | 4.371 ms | 0.5–2 ms (informational) | miss (~2.19× over the 2 ms ceiling) |
wrap_relay/ping_relayed_only | 6.8027 ms | 136.05 µs | informational (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.
| Run | tools_call_recorded / call | ping_relayed_only / call | derived recording / call | outliers (rec / ping) |
|---|---|---|---|---|
| 1 (published) | 4.371 ms | 136.05 µs | 4.235 ms | 11% / 18% |
| 2 | 4.176 ms | 136.70 µs | 4.040 ms | 3% / 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 +
CallSessionbegin/complete, with no child and no byte relay — was considered and skipped:record::observe_client_lineandcomplete_relayedarepub(crate)and a bench is an external crate, so the only way to reach them would have been to widenbotzr-aegis-wrap’s public surface for a measurement. The attribution is obtained by differencing two shipped-path groups instead, andbenches/results/cell_and_audit.md’saudit_emission/begin_completealready 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 ofdrain(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_bytesis recorded as0= “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:
scripts/mcp-stdio-smoke.sh --deny— the assertion gate. It spawns the MCP stdio gateway over the real pipeline, allows anechocall, refuses anexfilcall, 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:
- 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. - The
schema_version: 2audit outcome that same call wrote:policy.statusdenied,capability.statusdenied(“policy blocked before capability”),execution.statushost_denied(“not executed”) — each station stated separately, the whole line signed. 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.
| Crate | Responsibility | 0.3.0 |
|---|---|---|
botzr-aegis-core | Pure types and traits; zero I/O | published |
botzr-aegis-policy | YAML → Arc<PolicySet>; sync eval | published |
botzr-aegis-capability | Default-deny resolver and grant minting | published |
botzr-aegis-sandbox | wasmtime host; cap-std preopens; resource limits | published |
botzr-aegis-audit | Schema-versioned records, always emitted | published |
botzr-aegis-runtime | Orchestrator — Runtime::execute_tool_call | published |
botzr-aegis-mcp | MCP stdio gateway for Aegis’s own catalog | published |
botzr-aegis-cli | Binary aegis | published |
botzr-aegis-wrap | Stdio MCP interposer — always records; confines only with --confine (Linux), evaluates policy only with --policy. Default wrap relays every call and blocks nothing | in-tree only |
botzr-aegis-confine | Linux 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).
| ADR | Title | Shipped? |
|---|---|---|
| 0001 | Agent Action Records split into a signed Chain and an optional Envelope | partial — Chain ships; no Envelope store or I/O |
| 0002 | aegis verify reports coverage, not pass/fail | yes |
| 0003 | JCS JSON is the canonical form, over a constrained value space | yes |
| 0004 | The signing key is embedded, and aegis verify labels its trust level | yes |
| 0005 | Approval parks the MCP request, not the audit record | partial — record half ships; no interposer park, no production caller |
| 0006 | Argument matchers target derived capability parameters, not raw JSON | no |
| 0007 | Confinement applies via a self-restricting re-exec, and needs no unsafe | no |
| 0008 | D2’s re-evaluation command is aegis recheck, not aegis replay | yes |
| 0009 | D4 reproduces a cross-boundary exfiltration chain, not a same-server one | no — demo not built |
| 0010 | macOS confinement fast-follows M4; it does not gate it | no |
| 0011 | The workspace and the spec are dual Apache-2.0 OR MIT; this supersedes OQ-1 | yes — crates.io 0.3.0 metadata stays MIT |
| 0012 | The audit sink is a public seam that declares its own retention | yes |
| 0013 | Verify and Recheck share a line classifier, not a walk | yes |
| 0014 | The record file extension is .aarl | yes — every example writes it; no code parses an extension, by design |
| 0015 | Wrap may synthesize a JSON-RPC error for an opted-in enforcement refusal | partial — --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 replaythroughout. That command was renamed toaegis rechecklater the same day by ADR-0008 — the operation executes nothing, so “replay” was an overclaim. Read everyreplaybelow asrecheck. 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_idbut notcapability,role, orsession, so a recorded deny cannot reproduce or explain its own verdict. Those three axes join the Chain in the same schema v2 bump. matched_rulegets recorded. It is computed inpolicy/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 replayships 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” — exhaustivematchis what forces every future reason to be handled at compile time. (Grew to five states: ADR-0005 addedNewlyParked, and ADR-0006 added aNoBindingreason. The exhaustive-match argument is exactly why both were cheap to add.) - Envelope entries are verbatim bytes.
request_digestis 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 intoprev_hashverifies clean while being wrong.PrevHash/PolicySetHash/RequestDigestover a sharedDigestmake transposition a compile error, and retire today’sinput_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.seqis per appended line withprev_hashcomputed under the same lock as the append — never per Call, because the writer serialises concurrent calls through aMutexand 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 isTampered, 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.postis 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 reportVerifiedover 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. Dropdoes 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_msandpeak_memory_bytesserialize (core/src/audit.rs:73,77). A canonical form cannot leave absent-vs-null to the emitter. - The
u64 < 2^53bound is for cross-language verifiers, not for Rust. A JavaScript verifier readingseqas aNumberis 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_idjoins 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_idper 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
Dropguarantee. 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 anOutcomecarryingPendingApproval, which has shipped since v1. - Two linkage kinds with different strengths, and SPEC.md must state both:
intent ↔ outcomeis a hard invariant, Session-scoped, guaranteed by theDropguard plus the borrow that makes the writer outlive everyCallSession. Violation inside a closed Session is a structural error.approval_id ↔ decisionis a soft cross-reference that may span Sessions and files. A human approving after a restart is normal.
- Three verifier rules follow. A
PendingApprovaloutcome with noDecisionis legal (informational). ADecisionfor an absentapproval_idis legal (informational — parked in an earlier file). TwoDecisionlines for oneapproval_idis a structural violation, exit 1, same class as a chain break, because a correct emitter cannot produce it. - The
Decisionline 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
Tamperedviaprev_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(writtenReplayVerdictwhen 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. Andapproval_refis 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.0or currentmain: the policy parser acceptstool,capability, androlematchers only, andpath_underdoes 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(namedaegis replaywhen 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_pathof/home/a/clients/acme/notes.mdis 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. ToolManifestdoes 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 resourcegets 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
fsornetauthority. 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 --confinere-execs throughaegis __confine-exec, which applies Landlock and seccomp to itself and then execs the target. Without--confine, wrap still only records.v0.3.0did 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-effortis 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 viaExitStatus::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
- Landlock domains preserved across
execveon the minimum supported kernel (documented, but ABI level affects what is enforceable) - Whether
restrict_self()setsPR_SET_NO_NEW_PRIVS, or the helper must - Whether
seccompiler::apply_filtersetsNO_NEW_PRIVS - That the filter permits
execveand 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. Leavingverifytop-level while nestingreplaymakes operation location unpredictable; movingverifydown 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.
reevalis the fallback ifrecheckreads too casually for auditors. Avoidsimulate— REPLAY §14 Mode E already claims it, and using it here recreates this collision one layer down. Avoidaudit, already a pipeline station name.- Cheap now, expensive later. 622 is unstarted and report §6.4’s
aegis replay --policy new.yamlis 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.
.aarmust not be the file extension. It is the Android Archive format — a hard collision, because it is an extension we would be claiming. Editors andfilewill misidentify records as zip archives, GitHub’s language detection is already trained on it, and searches for the format spec return Android results indefinitely..aarlsignals 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.0or currentmain: 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
unsafedecision 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-execis the OS’s own restrict-then-exec helper — a system binary that applies a Seatbelt profile and execs the target. No FFI, nounsafe, 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.
- README states which layers apply per platform
aegis runon macOS fails closed, naming exactly what is missing and what--best-effortgives up, with a persistent banner rather than a startup line- 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
- Which macOS versions still ship
sandbox-exec - 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
- Whether the Seatbelt profile language can express the grant-derived scope required
- 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-APACHEandLICENSE-MIThold the texts;LICENSEbecomes a short either-or pointer rather than disappearing. DeletingLICENSEwould break inbound links, including the ones in0.3.0’s published metadata. cargo denyis unaffected.MITandApache-2.0were both already indeny.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
LICENSEand 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
ChainSinkseam,Retention,MemoryChainSinkandFileChainSinkin AILAB-701; the in-memory default, the removal of both temp-file constructors, andtempfile’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, seeCHANGELOG.mdanddocs/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()usedAuditWriter::open_temp, whoseTempDiris dropped with the writer — soaegisandaegis runprintedAudit: /tmp/…/audit.jsonlfor a directory deleted at process exit (verified 2026-08-14 against the shipped0.3.0binary). A Volatile sink names no path and says so.tempfileleaves the production dependency graph as a result, not as a goal. audit/benches/emission.rsmust be pinned to an explicit file sink. It usesopen_temptoday and is measuring the fsync path; switching the default silently changes what it reports.AuditWriter::path()becomesOption<&Path>, with three production callers printing it.- A sink can lie. With one trait and a runtime check, a sink may declare
Durableand returnOk(None)fromexisting_tail()over a non-empty store. That is undetectable and is documented rather than engineered around. A sink that declaresDurableand errors onexisting_tail()fails closed at construction, matchingrecover_tail’s existing refusal to append onto a torn tail (writer.rs:247-252). - The sink has a read side.
prev_session_tailrequires the previous tail, so a sink that cannot answerexisting_tail()produces Sessions that are permanently unanchored under ADR-0002 — see the Sink entry inCONTEXT.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_valueandSessionCounterare public inbotzr-aegis-core/src/audit.rs, besideAuditLineType::from_wire. Both walks number Sessions with the shared counter, and the v1phasefallback has left the CLI binary. Neither verb’s behaviour changed:aegis verifycalls the field-only reader and still refuses a line with noline_type,aegis recheckcalls the fallback-aware one and still reports v1 records, and Session boundaries come fromline_typealone in both. Address extraction landed as the shared Session ordinal only —Positionand the two verdict types stayed with their consumers, andseqwas deliberately not unified, because verify reads a missingseqasTamperedwhere recheck prints 0. The text below is the decision as taken on 2026-08-14 and is left unedited. For what shipped, seeCHANGELOG.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-149and the LOAD-BEARING paragraph at:231-239exist to keep two independent implementations of Session numbering in agreement — the same expression written atverdict.rs:575andrecheck.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 knowsphasewas v1’s spelling ofline_type; the only other record isdocs/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:265is not duplication. Its schema-version gate runs on raw JSON before deserialization;policy/src/recheck.rs:261runs on a typedAuditRecordafter. 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:
.aarlis 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
.aarfailed. A search for.aarlreturns this format rather than someone else’s ecosystem. Searchability is the whole reason.aarwas rejected, so a replacement that failed the same test would be no improvement. - It signals the shape. The trailing
lreads 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 verifyandaegis recheckaccept 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 indocs/adr.mdpoints a reader here instead — the same handling ADR-0001’s staleaegis replayalready 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.0publishes 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 atools/callwith 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_allpass-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--policyand no longer claims nothing is ever blocked.Not shipped, and not this ADR’s to schedule: the approval park (AILAB-629). A
PendingApprovalverdict is refused withdata.aegis.codePENDING_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-32000slot (mirror_child.rs:173). data.aegis.layeris the stringwrap— 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
resultwithisError: true(mcp.rs:141-145). Rejected: a JSON-RPCresultis wrap speaking as the child, which is the impersonation the flat ban existed to prevent. A JSON-RPCerroris 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
-32601or-32000.-32601is the specific liean_unknown_method_is_relayed_and_never_locally_refusedexists to prevent, and this ADR does not relax it.-32000is 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
layermarker 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,coreandconfine, and an ADR does not add a dependency. Where the refusal decision comes from is 793’s problem. - The pass-through
policy_set_hashatrecord.rs:30-37becomes 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.rsand 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_VERSIONto2:phasebecameline_typewith six values,input_digestbecamerequest_digest, and every line gained chain (seq,prev_hash) and signature (signature,key_id) fields. The current wire contract isspec/SPEC.md. This page is kept as the v1 record; do not implement against it.
Status: Frozen at
schema_version: 1for v0.1 (OQ-15 T7 / AEG-34)
Authority: types incrates/botzr-aegis-core/src/audit.rs· writers inbotzr-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):
- Intent — appended before sandbox / host work begins (
phase: "intent"). - Outcome — appended on every exit path (
phase: "outcome"), including policy deny, capability deny, success, trap, resource exceeded, and host deny. TheCallSessionDropguard makes this structural: an incomplete session always emits exactly one fail-closed outcome — atrap("host panic during tool call") if the runtime panics mid-call, otherwise ahost_denied("session abandoned") for any other abandon / early return / error — so a forgottencomplete()can never leave an orphan intent.
| Rust type | phase value | Role |
|---|---|---|
AuditIntent | "intent" | Pre-work line |
AuditRecord | "outcome" | Post-work / exit-path line |
AuditPhase serializes with serde rename_all = "snake_case".
Intent fields (AuditIntent)
| JSON field | Type | Notes |
|---|---|---|
schema_version | u32 | Always 1 today (AUDIT_SCHEMA_VERSION) |
phase | string | Always "intent" |
call_id | string | Stable id for pairing intent ↔ outcome |
tool_id | string | ToolId (serde transparent newtype) |
input_digest | string | Digest of call input — not raw input |
Outcome fields (AuditRecord)
| JSON field | Type | Notes |
|---|---|---|
schema_version | u32 | Always 1 today |
phase | string | Always "outcome" |
call_id | string | Matches the prior intent line |
tool_id | string | ToolId |
input_digest | string | Same digest policy as intent |
policy | object | Tagged PolicyOutcome (see below) |
capability | object | Tagged CapabilityOutcome |
execution | object | Tagged ExecutionOutcome |
wall_ms | u64, optional | Present only when sandbox ran; omitted otherwise (skip_serializing_if none) |
peak_memory_bytes | u64, optional | Peak 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)
status | Extra fields |
|---|---|
"allowed" | (none) |
"denied" | reason (string) |
"rate_limited" | reason (string) |
"pending_approval" | approval_id (string) |
CapabilityOutcome (capability)
status | Extra 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)
status | Extra fields |
|---|---|
"success" | (none) |
"trap" | message (string) |
"resource_exceeded" | kind (string) |
"host_denied" | reason (string) |
Digests and secrets
Shipped today
input_digeston both Intent and Outcome. It is runtime-derived: the pipeline computessha256_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 onAuditIntentorAuditRecord. Do not expect or emit it. Threat-model wording matches this (AEG-35).
Metrics
| Field | When present | When omitted |
|---|---|---|
wall_ms | Sandbox execution completed (or was measured) | Policy/capability short-circuit; sandbox never ran |
peak_memory_bytes | Same as wall_ms | Same |
Both use #[serde(default, skip_serializing_if = "Option::is_none")].
Sinks
| Sink | Status |
|---|---|
JSONL via AuditWriter | Shipped — append one JSON object per line; per-line flush + fsync (sync_all) for durability |
| OpenTelemetry / OTLP export | Deferred — 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.jsoncapability_denied.jsonrate_limit.jsonpending_approval.jsontrap.jsonresource_exceeded.jsonpanic.json—Droppanic-guard outcome (default-deny seeds + host-panic trap)abandoned_session.json— begun-but-never-completed outcome (default-deny seeds +host_deniedsession abandoned)
That directory now holds twelve files, not eight. Schema v2 added
session_open.json,session_close.jsonanddecision.jsonfor the three line types v1 had no concept of, andintent.jsonfor 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, inspec/SPEC.md§11.2.
Deny-suite goldens — tests/deny-suite/tests/golden/
policy_deny.jsoncapability_net_denied.jsoncapability_unregistered.jsonresource_memory.json
Orchestrator golden —
crates/botzr-aegis-runtime/tests/golden/resource_exceeded_orchestrator.json
Authority
| Concern | Location |
|---|---|
Record types, enums, AUDIT_SCHEMA_VERSION | botzr-aegis-core (src/audit.rs) |
JSONL writer, CallSession / Drop-trap | botzr-aegis-audit |
| This freeze document | docs/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)
| Stage | Commitment |
|---|---|
| Acknowledgment | Within 72 hours of report receipt |
| Triage / updates | Best effort; severity-dependent |
| Fix & advisory | Best effort; critical issues prioritized |
| Credit | With 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:
- Reporter submits privately.
- Maintainer acknowledges and triages.
- Fix developed and validated.
- Advisory published via GitHub Security Advisories.
- RustSec /
cargo auditecosystem 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.
| Version | Supported |
|---|---|
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:
| Crate | Scope |
|---|---|
botzr-aegis-core | Types/traits used in enforcement decisions |
botzr-aegis-policy | Policy evaluation bypass, silent widening |
botzr-aegis-capability | Grant minting, default-deny bypass |
botzr-aegis-sandbox | WASM isolation, cap-std preopens, host-function grant enforcement |
botzr-aegis-audit | Record 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-runtime | Pipeline 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
| Category | Examples |
|---|---|
| Layer 2 governance | Pre-release; not part of v1 runtime |
| Example / fixture code | examples/, tests/fixtures/damage-bot — deliberately adversarial test guests |
| Third-party WASM guests | Tool code you compile and run; Aegis does not audit your guest’s business logic |
| Upstream wasmtime/cap-std | Report to Bytecode Alliance / upstream; we track and bump pins |
| Deployment misconfiguration | Over-broad grants, shared host credentials, missing network segmentation |
| Prompt injection / return-value exfil | Named non-goals — see threat model §6 |
| Model B marketed as full isolation | Documentation 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:
- Keep host functions small and audited. Every Model B import is a hole in the sandbox wall.
- Never configure the sandbox from the raw request — only from the resolved grant.
- Use cap-std preopens for filesystem scoping; do not hand-roll path prefix checks.
- Deploy process-per-tenant for multi-tenant isolation (v1 honesty boundary).
- 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
- Security reports: support@botzr.com
- Repository: https://github.com/botzrDev/aegis