fix(audit): make host-audit recording ordered and loss-accounted - #2003
Conversation
joshuajbouw
left a comment
There was a problem hiding this comment.
Reviewed eff6be4. No blocking findings in this head. The per-principal FIFO, retry-before-later-work behavior, signed overflow records, and durable restart marker address the ordering/loss problem without putting Codewall policy into Astrid. Fail-closed admission runs after authorization and before the protected effect.
Independently ran 254 tests across audit/config and the focused kernel and capsule audit paths (3 ignored), plus focused audit/config Clippy with warnings denied. All passed. This includes refusal before a TCP effect, failed-batch recovery, overflow accounting, unreadable/unavailable markers, and restart gaps.
Please preserve capsule identity in the run key when integrating #2004, as noted in the description; that combined change needs its attribution regression checked. This approval covers this draft head, not that future integration. GitHub currently reports no CI checks for this branch.
Preserve the ordered host-audit implementation from PR astrid-runtime#2003 while integrating the landed retention configuration and mount cleanup fix. Co-authored-by: Pavel Grigorenko <pavel@unicity-labs.com> Signed-off-by: Joshua J. Bouw <jjb@unicity-labs.com>
eff6be4 to
c92562e
Compare
… changes and capsule loads (#2004) ## Linked Issue Closes #1998 Part of #1997. #1996, #2002, #2003, and #2008 are merged. Current head `c0f4052305da424d9625c9f96332448667a9259b` integrates this PR with main `e63f65cc`. Remaining order: this PR, then #2005 (entry format v2). The integration retains #2003's ordered, loss-accounted writer and fail-closed admission. Capsule identity is part of the run key, so alternating capsules cannot fold together. HTTP/tool/approval coverage records remain individual queue entries; overflow still records a payload-bound loss summary. Existing unattributed call digests keep their exact v1 encoding; attributed and new coverage calls use the documented `astrid.audit.host-call.v2` digest domain. This is a call-fold encoding distinction, not #2005's entry-format change. Ordering boundary: queued records retain per-principal FIFO order. Synchronous HTTP/approval commits still use the existing durable append path, like direct administrative audit writes; no new global ordering guarantee between that path and queued records is claimed. ## Summary The audit log did not record the events that determine what an agent did: - LLM and other HTTP exchanges appeared only in `tracing::debug!`, so an agent turn, including its model request, added no entry; - capsule tool calls and approval decisions had no producer; - capability grants and revocations were recorded as `AdminRequest` rows at authorization time, before the change was applied and without its result, and grant-on-use was not recorded at all; - capsule install and load were not recorded, and install hooks ran without an audit sink; - host-call entries did not name the capsule, and `FileWrite` stored a zero hash. This PR records all of these on the signed log, without changing the v1 entry format. Content never enters the log, only commitments to it. Provider credentials can now be injected by the host, so they never enter guest memory. ## Changes - **`astrid-audit`: entry kinds.** - `HttpRequest` (pre-commit) and `HttpResponse` (completion). - `CapabilityChanged`, `CapsuleInstalled` and `CapsuleLoaded`. - A `CapsuleActor` (capsule id and wasm hash) on file, network, process, tool-call and approval entries. - Linking fields on `CapsuleToolCall` and the approval entries. New fields are optional and omitted when unset, and new variants are appended after the existing ones. Entries written before this change re-serialize to the same signed bytes. - **HTTP exchanges** (`astrid-capsule` HTTP host, `astrid-kernel`). - Each wire request, each redirect hop included, gets a durable `HttpRequest` pre-commit before it is sent. The pre-commit holds the method, host, port, BLAKE3 commitments to the path, headers and body, the redirect hop, and a per-principal sequence number scoped to the kernel run. - An `HttpResponse` completion links to its pre-commit. It carries the status, provider request ids, and a hash of the response body as it was read (incrementally for streams). - Completions are appended durably. - Refusals by the scheme check, egress, the security gate or the SSRF airlock are recorded as denied requests and take a sequence number. - Credential headers, credential query parameters and every secret value the capsule received are redacted before hashing. The redaction rules are documented so a verifier can recompute the commitments. - Recording is best-effort, like the rest of the audit log since 0.9.0. If the pre-commit cannot be written, the request is still sent, a security event is logged, and the missing entry shows as a gap in the run's sequence numbers. Approval prompts behave the same way. Making these fail closed is an operator policy; it would fit as an `http` class of `audit.host_fail_closed` from #2003 once both have landed. - **Host-side credentials.** - A header value can name a manifest-declared secret as `{{secret:NAME}}`, and the host substitutes it on the wire. - Undeclared names are refused and recorded. Placeholders are stripped on a cross-origin redirect. - Commitments cover the placeholder form, and the entry lists injected secret names, never values. - `docs/models.md` describes how a provider capsule adopts this. - **Tool calls and approvals.** - One `CapsuleToolCall` entry per `tool.v1.execute.<tool>` invocation, with the call id, argument and result hashes, and the capsule. - Approval prompts are committed before they are published. Every decision is linked to its prompt, with how it was reached: user, session allowance, remembered consent, timeout and so on. - Grant-on-use prompts are committed by the dispatcher before `GrantRequired` is published. An approval is committed before `GrantResult` is published, so the chain shows it before the grant it caused. - **Capability changes.** Applied changes are recorded on the affected principal's chain after they are saved: token mint and revoke, caps grant and revoke (only the patterns actually added), group and capsule membership changes, distro self-grants, and grant-on-use. - **Code identity.** - `CapsuleInstalled` binds the wasm and exact `Capsule.toml` hashes. - `CapsuleLoaded` (on load or replace) binds the verified wasm hash, the manifest hash and the engine profile. - Install and upgrade hooks run by the daemon get the signed sink, attributed to the hook's component. - **Attribution.** - Host-call entries name the capsule and wasm hash, stamped by the host from the verified component; a guest cannot choose them. - `write-file` reports the hash of the bytes written. - Changelog fragment. ## Verification Current signed integration head: **111 audit tests passed / 2 ignored; 777 capsule tests passed; 643 kernel tests passed / 1 ignored** (1,531 passed in total). Commands: `cargo test --locked -p astrid-audit -p astrid-capsule -p astrid-kernel --lib -- --quiet`; `cargo check --locked --workspace`; `cargo clippy --locked -p astrid-audit -p astrid-capsule -p astrid-kernel --lib --tests -- -D warnings`; formatting and diff checks. All passed locally on macOS. Added integration regressions bind capsule/wasm identity in folded digests and prove coverage events remain individual while overflow binds the original payload. The attribution test now asserts FIFO order across alternating capsules instead of expecting the old unordered folding behavior. Fresh platform CI is still required. Prior author verification before integration: - **Test suites:** `astrid-audit` 71, `astrid-capsule` 777, `astrid-capsule-install` 106 and CLI 818 all pass. The kernel lib passes 601 of 602 with `--test-threads=6` and umask 0022. The remaining failure needs a copy-on-write workspace backend and also fails on `main`. - **Unit and integration tests:** - legacy JSON encodings pinned, and a legacy entry re-verified after a round trip; - pre-commits resolve before a loopback server sees the request; - buffered and streamed responses hashed and linked, including streams closed early, transport failures and gate denials; - redaction: short secrets, many secrets, overlapping secrets; - credential injection end to end: the server receives the secret, the guest never reads it, and the commitment covers the placeholder; - placeholder rules: undeclared, malformed and unterminated placeholders refused, also after a blank secret, and an omitted header names no secret; - tool results captured through the IPC host function, with error, missing, mismatched and cancelled cases; - approval prompts committed before they are visible on the bus; - capability grant, revoke and membership entries, with none for a no-op re-grant, and a grant-on-use approval ahead of its grant; - a daemon install producing `CapsuleInstalled` and `CapsuleLoaded` with the expected hashes, and an upgrade recorded as load then replace. - **Lint:** `cargo fmt` and `cargo clippy --all-features --all-targets -- -D warnings` on the touched crates. `cargo check --target wasm32-unknown-unknown` of `astrid-audit`, `astrid-capsule` and `astrid-kernel`. ## AI / Tool Assistance Assisted-by: Claude Code:claude-opus-5-5. Covers design, implementation and tests in all touched crates, and this description. Assisted-by: Codex CLI. Review passes over the diff. Integration onto the landed recorder and associated regression tests assisted by Codex. The earlier source review does not constitute independent review of these integration edits. ## Checklist - [x] Linked to an issue - [x] Changelog fragment added under `changes/{issue}.{kind}.md` (docs/CI-only may skip; release PRs roll fragments into the version section instead of adding one) - [x] I understand every change in this PR and can explain its design, risks, and validation. - [x] I reviewed and tested any meaningful tool-generated output included in this PR. - [x] Every non-bot, non-merge commit has a matching `Signed-off-by` trailer. --------- Signed-off-by: Pavel Grigorenko <pavel@unicity-labs.com> Signed-off-by: Joshua J. Bouw <jjb@unicity-labs.com> Co-authored-by: Joshua J. Bouw <jjb@unicity-labs.com>
…d-key verification (#2005) ## Linked Issue Closes #2000 Part of #1997. The current integration includes the landed #1996, #2002, #2003 and #2004. Proposed merge order: #1996, #2002, #2003 (ordered recording), #2004 (recording coverage), this PR. v2 derives an entry's sections from the serde form of the action, authorization and outcome. The new action variants in #2003 and #2004 therefore encode without any change to this format or its specification. ## Summary Format v1 cannot be verified outside Astrid with confidence: - its signed bytes mix binary fields with `serde_json` of the action and authorization, and drop either one if serialization fails; - it signs whole-second time, and only a success bit of the outcome; - it has no sequence number; - verification trusts the public key embedded in each entry, so anyone with write access to the store can re-sign a rewritten chain under any key and still pass; - one runtime key signs audit entries, capability tokens and builds. This PR adds format v2, which is off by default and enabled with `[audit] entry_format = "v2"`: - a deterministic CBOR body that signs every stored field; - a per-chain sequence number; - a dedicated audit key; - verification against a cross-signed key registry instead of the embedded key. v1 history is kept as it is, and the v2 chain links to it. ## Changes - **`astrid-crypto`:** `verify_strict` (RFC 8032 strict Ed25519) and a `random_bytes` helper. - **`astrid-audit`: `entry_v2`.** The module documentation is the byte-level specification, with a known-answer test that an implementation written from the specification alone reproduces. - The body (RFC 8949 §4.2.1 deterministic CBOR) carries: - the format tag; - a chain id derived from the registry, session, principal UID and alias; - a sequence number and the previous hash; - nanosecond time, entry and session ids, and the acting capsule; - the action, authorization and outcome as `[tag, {field => commitment}]` sections derived from their serde form; - the signer key and its key epoch. - Field values are salted commitments. Salts are HMAC-SHA256 of a per-entry salt key, so one field can be disclosed without the others. - The entry hash is SHA-256 of the body, and the audit key signs a domain-separated wrapper of it. - **`astrid-audit`: key registry.** - A hash chain of records binding keys to roles: audit, capability, build and audit-v1. - The genesis is signed by every key it binds, and a rotation by both the old and the new key. A key can never be registered twice. - **`astrid-audit`: verification.** `ChainVerifier` accepts a v2 entry only if all of these hold: - its signer holds the audit role at the entry's epoch; - its chain id derives from the registry; - sequence numbers run 1, 2, 3; - links hold; - epochs never decrease. With a registry present, v1 entries and archive receipts must be signed by registered keys. v2 receipts carry the signer's epoch. - **`astrid-audit`: writing.** - `AuditLog::enable_entry_v2` writes the genesis on first use. - The first v2 entry of each chain has sequence 1 and links to the chain's last v1 entry. - After that, v1 appends are refused (`V1Closed`). - Storage enforces the v1 closure and the current key epoch at commit time, under the durable append lock, so another opener's switch or rotation cannot be bypassed. - The same check applies to a prune before anything is deleted: a new deletion plan is accepted only if its receipt's signer is the registry's current audit key (a receipt without an epoch only while no registry exists). A plan accepted earlier still finishes after a rotation. - `rotate_audit_key` appends a cross-signed rotation. - **`astrid-config`, `astrid-kernel`: `[audit] entry_format`.** It is operator-only, and the default is `"v1"`. - With `"v2"`, the kernel loads or creates `keys/audit.key` (owner-only), enables v2, and on first use writes a genesis that registers the runtime key for the capability, build and v1-audit roles. - A store with a registry stays on v2. - Boot stops if the registry does not verify, if the audit key is missing or different, or if the configuration cannot be read on a store that is not yet on v2. - `keys/audit.key` is protected from admin filesystem edits. - It applies on every native host. - **Docs and changelog.** - `docs/config.md` documents the switch and its one-way migration. - Rolling back to a release without v2 is not supported. - Changelog fragment. With v2 off, v1 behaviour, verification results and archive keys are unchanged. ## Verification ### Current stack integration Head `0dd52fdd48fd25eab5c778353ecadd9701033ff8` reconciles landed #2004 (`3b3360fd`) with integration `c225dfc7`. Its tree is byte-identical to `c225dfc7` (tree `c81645720ec1d81d2c1e447aecdf328f54b4e0a0`). All Actions jobs, including both MUSL smokes, passed on that tree. Three CodeQL hard-coded-value alerts were individually investigated and dismissed as false positives: an HMAC output buffer fully overwritten before return, a test-only known-answer key, and an OS-randomness buffer that cannot return on RNG failure. No scanner rule or test was weakened. Required checks on the ancestry-only commit must still complete before merge. - Retains both anchor-watermark and signing-key-epoch checks before prune-plan acceptance, as well as ordered host recording and capsule attribution. - Keeps `entry_format`, retention settings and fail-closed settings in the shared `AuditConfig`, with both operator-only restrictions intact. - Adds regressions for v2 pruning at the anchor boundary and commitment to host-stamped capsule identity. - The forged-receipt test still requires signature rejection. It now restores the authentic receipt before its subsequent legitimate-prune scenario, because immutable receipt history correctly rejects replacing that generation. - Audit: 171 passed, 2 ignored. Config: 143 passed. Crypto: 28 passed. Kernel: 648 passed, 1 ignored with `--test-threads=4`. The first parallel kernel run hit two unchanged 2-second response timeouts; all three tests in that group passed unchanged in an isolated rerun (0.71s), then the complete four-thread kernel run passed (51.72s). - Audit doctests: 2 passed. Workspace `cargo check --locked --workspace`, formatting and focused audit/config/crypto/kernel Clippy (`--lib --tests -- -D warnings`) passed. - `cargo check --locked -p astrid-kernel --target wasm32-unknown-unknown` passed, with unused/dead-code warnings; this is build evidence, not a browser runtime test. - The prior independent review applies to the earlier source head. These integration checks do not claim a new independent review; completed CI on the identical earlier tree is distinguished from checks on the current commit. ### Original author validation (before integration) - **Test suites:** `astrid-audit` 123 (+2 doc), `astrid-config` 140 and `astrid-crypto` 31 all pass. The kernel lib passes 592 of 593 with `--test-threads=6` and umask 0022. The remaining failure needs a copy-on-write workspace backend and also fails on `main`. - **Unit and integration tests:** - the known-answer test, and CBOR against RFC 8949 vectors, with non-canonical input rejected; - every stored field covered by the signature: each JSON leaf of a stored entry, and each field of every action, authorization and outcome variant; - sequence gaps, including an entry deleted from the store; - unregistered, foreign, retired and backdated keys rejected, v1 after v2 rejected, and epoch regression across a chain change detected; - registry genesis, rotation and tamper rules; - v1 to v2 migration with mixed chains, persistence and rotation across restart, and principal UID binding; - concurrent single and batch appends producing gap-free sequences; - commit-time refusal of v1 after another opener enables v2, and of a stale epoch after another opener rotates; - archive receipts bound to a registry epoch, and forged receipts rejected; - a stale handle's prune refused, with entries, receipts and verification unchanged: one without v2 after another enabled it, and one at epoch 0 after another rotated to epoch 1; a plan accepted before a rotation finishes after it; - kernel boot: v1 default, key creation, v2 persistence with the setting back at v1, and a missing or replaced key or unreadable config stopping boot. - **Scratch daemon:** boot on v1, switch to v2, then restart with the setting back at v1. The chain reads 8 v1 entries followed by v2 entries 1 to 10, bound to the principal UID, under one registry. It verifies with registered v1 keys required. - **Lint:** `cargo fmt` and `cargo clippy --all-features --all-targets -- -D warnings` on the touched crates. `cargo check --target wasm32-unknown-unknown` of `astrid-kernel`. ## AI / Tool Assistance Assisted-by: Claude Code:claude-opus-5-5. Covers design, implementation and tests in all touched crates, the format specification, and this description. Assisted-by: Codex CLI. Review passes over the diff. Merge after #2004 and successful checks on the current integration. ## Checklist - [x] Linked to an issue - [x] Changelog fragment added under `changes/{issue}.{kind}.md` (docs/CI-only may skip; release PRs roll fragments into the version section instead of adding one) - [x] I understand every change in this PR and can explain its design, risks, and validation. - [x] I reviewed and tested any meaningful tool-generated output included in this PR. - [x] Every non-bot, non-merge commit has a matching `Signed-off-by` trailer. --------- Signed-off-by: Pavel Grigorenko <pavel@unicity-labs.com> Signed-off-by: Joshua J. Bouw <jjb@unicity-labs.com> Co-authored-by: Joshua J. Bouw <jjb@unicity-labs.com>
Linked Issue
Closes #1999
Part of #1997. #1996, #2002, and the mount cleanup fix #2008 are merged. Current head
c92562e65087fe47e313e4c029781e89a69927d4is based on main26a500a6. Remaining merge order: this PR, #2004 (recording coverage), #2005 (entry format v2).The integration preserves Pavel's ordered host-audit implementation. The overlapping configuration is resolved in the existing
astrid-config::auditmodule, retaining both retention validation and fail-closed host-call settings. Kernel startup loads both settings from the same audit configuration.This PR replaces the host-audit writer. #2004 changes the old writer's fold key to include the capsule. Whichever of the two lands second is rebased onto the other, and the capsule then becomes part of the run key.
Summary
The host-audit sink could lose calls and reorder them without a trace:
host_coalesce_ms, and appendedrepeats=Nto the outcome details. The entry signature covers the outcome only as a success bit, so that count was not signed.host_queue_capacity, default 4,096 keys) was dropped, and only a counter moved.As a result, even an externally anchored log could not show that it was complete.
This PR records host calls in call order, and makes every loss visible as a signed entry on the chain. It also lets an operator make chosen host-call classes fail closed.
Changes
astrid-audit: signed host-call records. These actions are covered by the entry signature:HostCallRun: consecutive calls of one principal. It holds the call count, the first and last call times, a tally per class and outcome, and a fold over every call.HostCallLoss: calls that were accepted but not recorded individually.HostCallGap: an earlier run of the lane stopped without draining.HostCallAdmitted: the write-ahead entry of a fail-closed call.astrid_audit::host_callspecifies the per-call digest and the fold, so a verifier can recompute them without the kernel. Known-answer tests pin them.astrid-kernel: ordered lane (audit_sink/lane.rs,writer.rs).append_batch_with_principal.astrid-kernel: lossless coalescing.astrid-kernel: loss and gap entries.HostCallLossat its place in the chain.system:control:audit-lanerecords the run and the chains it wrote. After an unclean stop, the next start writesHostCallGapinto each of those chains and into the session's system chain.astrid-storage: the state-owner resolver admitssystem:control:audit-laneas a system control projection, likeaudit,invitesandpair-tokens.audit.host_fail_closed(astrid-config,astrid-capsule,astrid-kernel).file_read,file_write,file_delete,net_connect,net_bind,process_spawn. The default is empty.HostAuditSink::admit()is called by the fs, net and process host functions after the security gate and before the effect.unknown("audit unavailable")and is recorded as a denial.Health:
acceptedandpersistednow count calls, andlost,gaps_recordedanddropped_after_shutdownare new. The adminAuditHealthwire type is unchanged.Changelog fragment.
Consequences.
Overhead (release build, in-memory log, per producer thread;
audit_sink::tests::bench, ignored by default):Verification
Current integrated tree: full kernel library 628 passed, 1 ignored; capsule audit tests 45 passed. Earlier validation of the same recorder/retention integration: audit 106 passed / 2 ignored, config 140 passed, kernel audit filter 51 passed / 1 ignored; audit/config/kernel library Clippy passed with warnings denied. The published tree is byte-identical to the tested combined tree; CI must pass on this new head before merge.
Prior author verification (before this integration):
astrid-audit74,astrid-capsule748,astrid-config139 andastrid-storage864 all pass. The kernel lib passes 610 of 611 with--test-threads=6and umask 0022. The remaining failure needs a copy-on-write workspace backend and also fails onmain.matches_calls;main);admit()returns;connect-tcpandbind-tcprefusing without a connection attempt.cargo fmtandcargo clippy --all-features --all-targets -- -D warningson the touched crates.AI / Tool Assistance
Assisted-by: Claude Code:claude-opus-5-5. Covers design, implementation and tests in all touched crates, and this description.
Assisted-by: Codex CLI. Review passes over the diff.
Current integration and verification assisted by Codex. Fresh CI pending; no claim of merge readiness until those checks complete.
Checklist
changes/{issue}.{kind}.md(docs/CI-only may skip; release PRs roll fragments into the version section instead of adding one)Signed-off-bytrailer.