sandbox: the S7 audit trail can outlive the process that wrote it #309
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/sandbox-s7-audit-trail-survives-restart"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The defect
SPEC §2.2 grades tier S7 as "S6 + geographic pinning + immutable audit trail", and §7.7 states:
sandbox-attest's audit module repeats it, andSPEC.md:352grades the log Engineering — "the hash-chained, tamper-evident audit log are implemented and signed into the attestation; vectored" — disclosing nothing about durability.The reference state was a
Mutex<AuditLog>thatHost::newalways built fresh, with no constructor taking an existing log andaudit_head()the only accessor.AuditLogderivesSerialize/Deserialize, and nothing in the tree ever wrote or read one.So the trail did not survive a restart — and nothing showed that it hadn't.
AuditLog::verifyrecomputes the head from the entries it holds, so a freshly reset log verifies perfectly. After a restart the next sovereign run is sealed withaudit_prev=BLAKE3("sandbox.audit/1"), the genesis head — byte-identical to what a host's very first sovereign run would carry.A data-residency auditor given the post-restart host sees a short chain that verifies from genesis, while the runs before the restart have left the "immutable audit trail" without breaking anything. The one property §7.7 sells is the one that did not survive.
The fix
The pattern was already in the repo and unused here:
sandbox-build::FileBuildLedgeris a file-backed, verify-on-open JSONL ledger. This follows it.AuditLog::load_jsonl/append_jsonlgive the trail a file.Host::with_durable_audit(path)opens and replays the existing chain.AttestError::Auditis fatal by design.Host::newand every existing call site are unchanged: a host that does not ask for a durable trail behaves exactly as before, and that limit is now documented onwith_durable_auditand pinned by a test.What this does not claim
A log is not self-authenticating. A truncation that drops entries and leaves a consistent list still verifies on its own. What detects that is the
audit_preveach S7 attestation carries — the attestations are the evidence and the log is the index into them, which is exactly why the index has to survive. Said plainly in the module doc rather than left to be assumed.Tests
sandbox-attest:a_durable_trail_replays_to_the_same_head— three entries, reloaded: same length, same head, verifies, and not the genesis head.a_missing_trail_is_the_first_run.a_trail_that_does_not_parse_is_an_error.sandbox-runtime:an_s7_audit_trail_survives_a_restart— two sovereign runs sealed, process dropped, host reopened: both runs still on the trail, head unchanged, and the next run'saudit_previs the real head. Plus the discriminating half: a host with no durable trail reports the genesis head, indistinguishably from its first run.Falsification
with_durable_auditrecords the path but never loads the chainan_s7_audit_trail_survives_a_restartFAILEDan_s7_audit_trail_survives_a_restartFAILEDa_missing_trail_is_the_first_runFAILEDa_trail_that_does_not_parse_is_an_errorFAILEDS1 and S2 are the read and write halves of durability, and each is independently load-bearing. The pre-existing
s7_run_binds_locality_and_advances_audit_chainands7_requires_locality_and_audit_prevpassed throughout.Verification
On the build host (aarch64):
cargo check --workspace --all-targets(sandbox-rs) — exit 0cargo test --workspace(sandbox-rs) — exit 0, 0 failed suites, 48 passing suitescargo test -p sandbox-attest -p sandbox-runtime— 53 + 15 passNo conformance-vector impact.
sandbox-conformancecarries anAuditLogin its vectors, and this adds only methods — no field changed, so its serialization is byte-identical; the conformance suite is among the 48 green above. Every consumer ofsandbox-attest/sandbox-runtimeis inside the sandbox tree (14 manifests, none outside), so the gate above covers the whole blast radius.🤖 Generated with Claude Code
SPEC §2.2 grades tier S7 as "S6 + geographic pinning + **immutable audit trail**", and §7.7 states: "`audit_prev` — the head of the host's append-only, hash-chained audit log **before** this run ... The chain is tamper-evident — reordering or removing any entry breaks it — so the sequence of sovereign runs is itself attestable." `sandbox-attest`'s audit module repeats it, and SPEC.md:352 grades the log Engineering with "the hash-chained, tamper-evident audit log are implemented and signed into the attestation; vectored" — disclosing nothing about durability. The reference state was a `Mutex<AuditLog>` that `Host::new` always built fresh, with no constructor taking an existing log and `audit_head()` the only accessor. `AuditLog` derives Serialize and Deserialize, and nothing in the tree ever wrote or read one. So the trail did not survive a restart, and nothing showed it had not. `AuditLog::verify` recomputes the head from the entries it holds, so a freshly reset log verifies perfectly: after a restart the next sovereign run is sealed with `audit_prev` = BLAKE3("sandbox.audit/1"), the genesis head — byte-identical to what a host's very first sovereign run would carry. A data-residency auditor given the post-restart host sees a short chain that verifies from genesis, while the runs before the restart have left the "immutable audit trail" without breaking anything. The one property §7.7 sells is the one that did not survive. The pattern was already in the repo and unused here: `sandbox-build::FileBuildLedger` is a file-backed, verify-on-open JSONL ledger. This follows it. - `AuditLog::load_jsonl` / `append_jsonl` give the trail a file. - The file stores **only the entry list**; the head is replayed from it, never stored. So the file cannot assert a head it did not earn, and a loaded log verifies by construction. - `Host::with_durable_audit(path)` opens and replays the existing chain. - The append writes the line **before** the in-memory head advances. A crash between the two leaves the file ahead of memory and the next load replays to the head the attestation already committed to; the other order would seal a sovereign run against a head no file records. - A missing file is the first run. A file that does not parse is an error, not a shorter history: the runs whose lines are unreadable are exactly the ones the trail exists to hold. `AttestError::Audit` is fatal by design. What this does not claim: a log is not self-authenticating. A truncation that drops entries *and* leaves a consistent list still verifies on its own. What detects that is the `audit_prev` each S7 attestation carries — the attestations are the evidence and the log is the index into them, which is exactly why the index has to survive. Said plainly in the module doc rather than left to be assumed. `Host::new` and every existing call site are unchanged: a host that does not ask for a durable trail behaves as before, and the limit is now documented on `with_durable_audit` and pinned by a test. Co-Authored-By: Claude Opus 5 <[email protected]>