Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Aegis findings report

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

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


1. Claim under test

The README states the hypothesis:

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

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


2. What we guarantee (measured)

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

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

3. What we do not guarantee

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

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

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

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


4. Case studies

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

4.1 Grant narrowing cannot broaden authority

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

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

  • Command:

    cargo test -p botzr-aegis-capability
    

    Targeted variants:

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

4.2 Adversarial guests are refused in both trust models

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

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

  • Command:

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

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

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

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

  • Command:

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

4.4 Audit emission is exactly-once under concurrency

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

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

  • Command:

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

4.5 Policy YAML parse surface survived its first fuzz campaign

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

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

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

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


5. How to reproduce

5.1 Evidence bundle

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

./scripts/evidence-bundle.sh

This creates evidence/YYYYMMDD-HHMMSS/ containing:

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

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

5.2 MCP live-deny demo

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

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

6. Hardware / versions

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

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

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