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