A reproducible runtime for testing what agent tool isolation actually guarantees.
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. Every tool call walks one enforcement pipeline, and the runtime emits an audit record on every exit path.
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.
Docs. The stranger-facing hub is published at
https://botzrdev.github.io/aegis/. Source lives in docs/; from a
clone, cd docs && mdbook serve. CI builds the book on every PR and redeploys
the site on every push to main.
The instrument tests a single claim:
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.
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 Arc<PolicySet> |
| 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 |
Aegis supports two execution models with different blast radii. Conflating them is the primary way a sandbox becomes decorative.
-
Model A — WASM tool. Tool logic compiles to
wasm32-wasip2and 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. -
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. Details and evidence: threat model §3.
Every layer below is on main today; the quickstart exercises the
first four and aegis wrap --policy. Native OS confinement is Linux-only — that
qualifier belongs beside any sentence calling Aegis containment, not in a
footnote further down
(ADR-0010).
| Layer | Linux | macOS |
|---|---|---|
Policy evaluation — tool id, role and capability axes, sync over a parsed-once Arc<PolicySet> |
yes | yes |
| Capability grants — default-deny resolution, minted per call | yes | yes |
Model A cell — a per-call wasmtime Store configured from the grant |
yes | yes |
Signed, hash-chained record (schema_version: 2), emitted on every exit path |
yes | yes |
aegis verify — walks a chain, labelled pinned or unpinned (ADR-0004) |
yes | yes |
aegis recheck — re-evaluates a record against other rules; executes nothing (ADR-0008) |
yes | yes |
aegis wrap --policy — refuses a tools/call with JSON-RPC -32042 (ADR-0015) |
yes | yes |
aegis wrap --confine — Landlock filesystem scoping + a seccomp network deny-list |
yes | no |
Read that last row against docs/wrap.md before relying on it:
the filesystem half is enforced at the LSM layer, the network half is an
enumerated deny-list whose default action is allow, and macOS Seatbelt is
AILAB-630 and is not built.
Not shipped on any platform, and deliberately absent from the table:
matching on a call's arguments (AILAB-626, canceled — a policy rule matches
tool identity, nothing else), parking a call for human approval (AILAB-629),
pinning a tool schema by hash (AILAB-627), and Landlock AccessNet for the
network half (AILAB-810). If you read those in
ADR-0010, note its
Not implemented banner: it records a plan from 2026-08-10, not behaviour.
Ten crates in the workspace (unsafe_code = forbid workspace-wide); eight are on
crates.io at 0.3.0:
| Crate | Responsibility |
|---|---|
botzr-aegis-core |
Pure types and traits used in enforcement decisions; zero I/O |
botzr-aegis-policy |
YAML policy parsed once → Arc<PolicySet>; sync evaluation |
botzr-aegis-capability |
Default-deny resolver and grant minting (core enforcement IP) |
botzr-aegis-sandbox |
wasmtime component-model host; cap-std preopens; resource limits |
botzr-aegis-audit |
Schema-versioned audit records, always emitted |
botzr-aegis-runtime |
Orchestrator — walks the pipeline (Runtime::execute_tool_call) |
botzr-aegis-mcp |
Phase 2 MCP stdio gateway — Aegis's own catalog, not an interposer |
botzr-aegis-wrap |
Transparent stdio MCP interposer — always records; confines the child only with --confine (Linux) and evaluates policy only with --policy. Default wrap relays every call and blocks nothing. In-tree; not on crates.io at 0.3.0. See aegis wrap |
botzr-aegis-confine |
Linux Landlock + seccomp derived from a grant; UnsupportedConfiner everywhere else. Depends on core only. In-tree; not on crates.io at 0.3.0 |
botzr-aegis-cli |
Binary aegis — run, plus keygen / verify / recheck / wrap on main |
governance/ is a separate Python (Layer 2) service — audit ingest, narrow-only
policy proposals, drift findings, and versioned policy packs. It is not a workspace
member and never writes into the Rust runtime. See
governance/README.md.
Requires Rust (MSRV 1.86) with the wasm32-wasip2 target and
cargo-component for the WASM
fixtures:
rustup target add wasm32-wasip2
cargo install cargo-componentRun the full workspace gate:
cargo test --workspaceCoverage is gated by a ratchet: CI fails any change that drops total line
coverage below the committed high-water mark in
coverage/baseline.json. Requires
cargo-llvm-cov:
./scripts/coverage.sh report # measure and print totals
./scripts/coverage.sh check # what CI runs — fail on any drop vs baseline
./scripts/coverage.sh bump # raise the baseline after improving coverageIf check fails, the fix is normally to add tests. A drop can be legitimate —
deleting or refactoring heavily tested code — in which case the baseline is
hand-edited in the same PR, with a rationale; bump will not lower it.
docs/coverage-ratchet.md documents that procedure,
the float tolerance, and the provenance fields.
Execute one Model A tool call from the CLI (registers the echo fixture, walks the full pipeline, writes audit JSONL):
# 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
# inspect Intent + Outcome lines in /tmp/aegis-audit.jsonl
# then pin them: cargo run -p botzr-aegis-cli -- verify --key <public_key> /tmp/aegis-audit.jsonlInterpose on a stdio MCP server and refuse one tools/call. This beat needs a
clone: crates.io 0.3.0 has neither wrap nor keygen (see Status).
# From a clone of `main`. crates.io 0.3.0 cannot do this.
cargo build -p botzr-aegis-cli -p botzr-aegis-mcp --release
# A policy that denies the tool `echo` and allows everything else.
cat > /tmp/deny-echo.yaml <<'EOF'
version: 1
default: allow
rules:
- id: deny-echo
action: deny
tool: echo
reason: "632 quickstart"
EOF
./target/release/aegis keygen --out /tmp/aegis-signing.key
# stdout: public_key <hex> / key_id <hex>
# One `tools/call` named `echo`, piped into wrap. The child here is Aegis's own
# MCP gateway standing in for any stdio MCP server — substitute yours after `--`.
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>)Drop --policy and the same command relays the call and prints the echo result:
default wrap records and blocks nothing, and --policy is the whole opt-in.
Two record files, on purpose. Wrap keeps the record of the call it carried;
the gateway keeps the record of the call it executed. Pointing both at one path
would interleave two Chains and neither would verify — the invariant
crates/botzr-aegis-mcp/tests/wrap_interop.rs
holds. Here /tmp/gateway-audit.aarl records no call at all, because the refused
frame never reached the child. Records use the .aarl extension
(ADR-0014).
aegis verify without --key prints Verified (unpinned) — internal
consistency only, not provenance. An attacker who rewrites a whole Session signs
it with their own key and an unpinned walk comes out clean. A bare "Verified" that
does not say which is the overclaim
ADR-0004 exists to prevent.
What this beat is not. Wrap runs nothing inside wasmtime, so it is not
Model A isolation — it is closer to Model B and weaker than either, because the
effect executes in a child process wrap does not control. --policy matches on
tool identity, never on params.arguments, and mints no capability, so even an
allowed call records the deny_all pass-through grant. --confine is a separate
flag, Linux-only, answering a different question. A chain that abuses a
legitimate tool on this same server is not refused here and is not in scope
(D5). docs/wrap.md carries the full list before you describe
wrap as a sandbox, a firewall, or a guard.
Reproduce the adversarial containment demo (a deliberately malicious wasip2 guest
driven through the full pipeline):
./scripts/build-fixtures.sh # builds the DamageBot guest wasm
cargo test -p aegis-adversarial-demo # write-under-readonly, .. traversal, symlink escape, http exfil — all refusedReproduce the hot-path benchmarks (policy eval and capability resolution only):
cargo bench -p botzr-aegis-policy -p botzr-aegis-capability -p botzr-aegis-runtimePublished results, on an AMD Ryzen AI 5 340 under WSL2 with Criterion 0.5.1:
policy_eval/multi_rule at 31.8 ns (rustc 1.96.0, 2026-07-09) and
hot_path/multi_rule at 263.4 ns (rustc 1.86.0, 2026-09-07, after path
canonicalization moved to registration). Both figures, their toolchains and the
before/after tables are in
benches/results/hot_path.md.
Quote the second one carefully: hot_path is stations 1–2 only — not what a
call costs. An audited call end to end is
benches/results/cell_and_audit.md —
32.9–37.4 µs against the shipped Volatile sink, and 2.86–17.5 ms against a
Durable one, published as a range because the durable arm spread 6.1× across two
sessions and has no reproducible median. Interposing has its own file,
benches/results/wrap_overhead.md: 4.371 ms
per recorded tools/call against an informational 0.5–2 ms budget — a ~2.19×
miss, and the honest outcome rather than a regression, because two sync_all
calls cost ~4.2 ms on that filesystem on their own. A merely relayed message is
136.05 µs. This box swings
±20% on identical binaries, so every digit here is provisional; the quiet-machine
re-baseline is AILAB-796.
These in-repo artifacts support the claims above. They demonstrate specific containment cases and measured costs — they do not certify the instrument.
| Artifact | What it shows |
|---|---|
| Docs book | Stranger-facing hub (mdBook). cd docs && mdbook build — CI fails the PR if the book does not compile |
| Threat model | Scope, trust boundaries, named non-goals, residual risks |
| Findings report | What isolation is measured to guarantee — and not; five reproducible case studies, bundled via scripts/evidence-bundle.sh |
| OQ-15 Part B review | Structured packaging peer review (solo-maintainer exception logged) |
| Record format spec | schema_version: 2 wire contract — line types, hash chain, JCS canonical form, signatures, verdicts, Envelope boundary, non-guarantees |
| Audit schema freeze | Superseded: the schema_version: 1 wire contract, kept as a record |
SECURITY.md |
Private disclosure process and in-scope crates |
| DamageBot demo | Six adversarial cases refused through Runtime::execute_tool_call (Model A + Model B) |
| Stage 2 demo | A minimal wasip2 path detector through the full pipeline; native-vs-wasm equivalence scorecard |
| Hot-path benchmarks | Policy ≪ 100 µs; combined hot path ≪ 1 ms, on cited hardware (bench notes) |
| MCP gateway | Out-of-process MCP stdio gateway (research scaffold, not a production firewall) |
| MCP live-deny cast | Watchable reproduction: an exfil call refused with POLICY_DENIED, the schema_version: 2 record that refusal wrote, and aegis verify over it — replay the cast or run it live with scripts/mcp-live-deny-demo.sh |
The enforcement pipeline is wired and tested end-to-end; demos, benchmarks, the threat
model, and the findings report are published. The current release is
v0.3.0 — the first
lockstep release, in which all eight crates carry the same version. It adds a fuzz
harness over the policy YAML parse surface, a stress suite proving audit exactly-once
under concurrency, and supply-chain gates. The published 0.3.0 CLI has one
subcommand, aegis run, for one-shot WASM execution through the pipeline.
Four more verbs exist on main and reach the registry with the next cut:
aegis keygen (mint a signing key), aegis verify (walk a record chain,
labelled pinned or unpinned), aegis recheck (re-evaluate recorded outcomes
against a different policy — it executes nothing), and aegis wrap (interpose
on a stdio MCP server and record every tools/call). Default wrap relays
everything and blocks nothing; --policy <YAML> is an opt-in under which a
denied tools/call is refused with JSON-RPC -32042
(ADR-0015), and --confine
is a separate Linux-only opt-in that restricts the child process. None of them
mints a capability.
Eight crates are published on crates.io at
0.3.0 — core, policy, capability, sandbox, runtime, audit, mcp, cli —
and the dependency graph resolves, so the run-only 0.3.0 binary installs
directly:
cargo install botzr-aegis-cli # the 0.3.0 binary: `aegis run`, and nothing elseThat binary cannot wrap, cannot keygen, and writes schema_version: 1 records
— unsigned, with no seq / prev_hash chain — so today's aegis verify has
nothing to check in one. The hash-chained, signed schema v2 this repository
describes arrives with the next cut, which is not dated here.
botzr-aegis-wrap and botzr-aegis-confine are in-tree and are not on
crates.io at 0.3.0; both first appear on the next cut, which publishes ten
crates (docs/release-checklist.md). Until then,
build them from main — that is what the
wrap beat above does, and cargo install botzr-aegis-cli is not a
substitute for it. Building from main or the tag is also what you want for the
in-repo demos and benchmark harnesses.
Earlier releases were a split set — core at 0.2.0, sandbox at 0.1.1, the other six at
0.1.0. From 0.3.0 the whole workspace moves as one version; see the versioning note in
the CHANGELOG. A retired name, botzr-aegis-sidecar, is yanked:
the Phase 2 gateway is MCP over stdio, so use botzr-aegis-mcp instead. Do not
confuse that gateway (Aegis's own catalog) with aegis wrap (an interposer in
front of someone else's server).
Setup, the gates CI enforces, and the invariants a reviewer will check are in
CONTRIBUTING.md. Participation is governed by the
Code of Conduct.
Security vulnerabilities do not go in the issue tracker — follow
SECURITY.md.
If you cite Aegis in published work, CITATION.cff carries the
metadata; GitHub renders it as a "Cite this repository" button in the sidebar.
Dual licensed under either of
- Apache License, Version 2.0 —
LICENSE-APACHEor http://www.apache.org/licenses/LICENSE-2.0 - MIT license —
LICENSE-MIT
at your option. LICENSE is the pointer to both. Unless you state
otherwise, any contribution you intentionally submit for inclusion in this work,
as defined in Apache-2.0, is dual licensed as above with no additional terms.
Apache-2.0 is here for its explicit patent grant, which matters most for the Agent Action Record spec — a format nobody should have to adopt on a handshake. See ADR-0011.
The 0.3.0 crates on crates.io are MIT as published and stay that way — a
published registry tarball cannot be relicensed. The dual license applies to this
repository and to every release cut after 0.3.0.