Skip to content

Audit log & tamper-evidence

kew records governance events in a hash-chained audit log (audit_events in dispatch.db). Each entry carries the hash of the previous entry; kew audit verify checks the chain, and a daily workflow anchors the chain head to a committed audit-anchors.jsonl.

Interactive usage from kew ingest

Usage ingested via kew ingest file or kew ingest serve is observed external telemetry, not an attested kew fact. It is stored in a separate interactive_usage table (schema v15, introduced in #256) that is off the hash chainkew audit verify does not cover it and never will. Interactive rows are displayed in kew report and can trigger cap alerts, but they cannot enter the audit_events preimage and therefore cannot affect or perturb the chain.

The honest framing: kew records what Claude Code reports it spent; it does not enforce limits or make network-level attestation claims about interactive sessions.

Reported-model attestation

Each run records reported_model — the model the runner reported in its own output — bound into the dispatch_completed event's payload and therefore covered by kew audit verify. It is null for runners that report nothing (opencode) and is inference, not network attestation: it does not prove which endpoint a run actually hit; a permission-skipped agent has egress paths beyond the inference endpoint.

Additive attested facts bind through the already-hashed payload, not new entry_hash preimage slots. The fixed slots (endpoint, policy_hash, prev_hash) are the envelope's grandfathered identity fields.

Work-item addressing (item_id)

Every audit row also carries an item_id (schema v24) — the durable work-item key from the tracker (SFD-12 for Linear, the issue number for GitHub). It is an unchained additive column: only issue_num enters the entry_hash preimage, so item_id never perturbs the chain and kew audit verify is unaffected by its value. Callers thread it through audit.emit(..., item_id=…) on the paths that have it (dispatch, epic driver, merge). It is null on the classic GitHub path, where issue_num already carries the key. Address a single item's events with kew audit tail --item <id>.

Sandbox posture attestation

Each dispatch emits a sandbox_resolved event whose payload records the effective containment posture: posture is sandboxed (ran in a container), guard-only (enabled = "auto" with no runtime found — in-process guard only), disabled (enabled = false), or refused (containment was required but unsatisfiable — no runtime under a fail-closed setting, or a runner with no mapped image / no declared credential — so the dispatch did not run at all). refused is attested precisely so a dispatch that never ran leaves the same kind of record as one that ran uncontained. runtime is the resolved runtime when contained, runner is the resolved runner the posture was decided for, image is the container image that runner resolved to (null when not contained), and enabled echoes the tri-state config value. The ledger therefore shows, per run, whether it was contained and in which image.

On a refused posture, reason names which of the several causes fired — without it the causes are byte-identical on the chain:

reason Cause runtime / image
runtime_unavailable No container runtime on the host, under a fail-closed setting both null
image_unmapped The resolved runner has no [sandbox] images.<runner> entry runtime set, image null
credential_undeclared The runner has an image, but kew declares no in-container credential for it both set
credential_missing The runner's credential is not in the environment, under a fail-closed setting both set

runtime and image carry what resolution actually saw before refusing, so a refusal that happened because a runtime was present is never recorded as runtime-null. reason is null on every non-refused posture.

A refused epic node is also HELD with a sandbox_containment:-prefixed reason and writes a terminal refused run row (error_category containment_refused) keyed to the driver's reservation, so the fleet worker slot is released immediately rather than at reconcile-grace expiry. The hold is cleared by the operator with kew epic unhold once an image is mapped — kew cannot observe a config it never got far enough to resolve.

Governed-review verdict attestation

When the governed adversarial-review stage is enabled ([review].adversarial), a review round emits an adversarial_review_verdict event — an additive attested fact bound entirely through the hashed payload (no new chain preimage slots).

Wiring status

The verdict contract, payload builder, and chain-verify path are complete and unit-proven, but the per-round READY / NEEDS_CHANGES emission is not yet wired into a running pipeline — it lands with the reviewer launcher. The one verdict emitted end-to-end today is MERGED_UNREVIEWED (the out-of-band-merge race in the driver). Treat the READY / NEEDS_CHANGES / DEGRADED / PRE_POLICY shapes below as the contract those callers will attest, not as currently-active behavior.

The payload carries:

  • Join keys pr_number, item_id, head_sha — so "show me this merged PR's review provenance" is a chain lookup, not an inference.
  • round, verdict, and sidecar_digest (the digest is over kew's host-stored sidecar copy, never a deletable GitHub comment).
  • reviewer_model and prompt_artifact_sha — which model, running which pinned prompt version, produced the verdict.
  • A policy block (blocking, min_severity_to_block, max_rounds, max_review_usd) binding the verdict to the policy in force (#250 precedent): the same NEEDS_CHANGES means different things under blocking = true vs false, so the policy travels with the verdict.
  • spend_source: run_records and spend_usd — the review-pool spend is summed from kew's own RunRecords; the reviewer's self-reported cost is dropped.

Beyond the reviewer's READY / NEEDS_CHANGES sidecar verdicts, the driver attests degenerate verdicts so the chain never goes silent on a short-circuited stage: DEGRADED (advisory reviewer crash after retry), MERGED_UNREVIEWED (a human merged over a live reviewer — actor: out_of_band), and PRE_POLICY (the flag flipped after the node was already in review). All shapes hash-verify identically — kew audit verify covers them like any other event.

Commands

  • kew audit verify [--anchors PATH] [--json] — verify chain integrity. Exit 0 intact (and for an empty log), 1 tamper detected, 2 cannot run (no database, or an unreadable --anchors file).
  • kew audit anchor [--anchors PATH] — append the current chain head {id, entry_hash, count, timestamp, prev} to the anchors file, binding the previous anchor via prev (the anchor chain). Pure-local; the scheduled audit-anchor workflow runs it and commits/pushes the file. Refuses to extend a broken local chain or a re-chained anchor history.
  • kew audit tail [-n N] [--event TYPE] [--item ID] [-f] [--json] — show the most recent audit events (oldest-first). -n defaults to 20; --event filters by event type; --item filters by durable work-item id (e.g. SFD-12, or a GitHub number 42); -f polls for new events every second (like tail -f). The event line addresses each row by work item using item_ref semantics — a bare tracker id (SFD-12) for non-GitHub items and #N for GitHub numbers.
  • kew audit export [-f json|csv] [-o PATH] [--since ISO8601] — dump the full audit trail in JSON (default) or CSV to stdout or a file, optionally filtered to events at or after a given date. Useful for piping into external SIEM or compliance tooling.
  • kew audit policy [HASH] [--json] — show the current effective governance policy, or resolve a retained policy hash to the snapshot that was in force when a specific run or event was recorded. This is the companion lookup for the policy_hash field documented in Governance attestation — every dispatch_completed event carries a policy_hash; pass it here to see exactly what policy was active at dispatch time.

See kew audit verify and kew audit anchor in the CLI reference for full flag details and exit-code semantics.

Tamper-evidence and durability are complementary: the chain proves integrity of what was written; backups protect against loss. See Operations for backup/restore, retention, and migration safety.

Each event also carries a policy_hash binding it to the governance policy in force when the event was recorded (#250) — dispatch runs and the governance commands (kew stop/approve/deny) alike; because policy_hash is part of the hash-chain preimage, the policy binding is covered by kew audit verify at no extra cost.

What tamper-evidence here does and does not mean

  1. Completeness. Audit writes are best-effort and never block dispatch, so the chain proves the integrity of what was written, not that nothing is missing.
  2. No secret. There is no keyed MAC: anyone with disk access can rewrite the whole local chain by re-hashing. Tamper-evidence depends on anchoring.
  3. Anchor strength and its ceiling. Anchoring raises the bar to rewriting both the committed file/history and the GitHub Actions run logs. But: the committed audit-anchors.jsonl gives no independent assurance when the runner that pushes it shares a trust domain with the chain writer (the same GITHUB_TOKEN can force-push it); Actions log retention is limited (90 days by default, lower if configured) and logs are deletable by repo admins; and the operator chooses the head that gets anchored. The honest claim is: tamper-evident against an attacker who does not control the GitHub Actions logs, within the log-retention window. kew is not an HSM.
  4. Backfill boundary. Events recorded before #249 are chained for contiguity but are not retroactively attested — tamper-evidence begins at the first anchor.

Anchor-witness model

Anchors are recorded by the daily audit-anchor workflow (.github/workflows/audit-anchor.yml, schedule 17 4 * * *) and pushed to audit-anchors.jsonl in the repository. kew audit verify --anchors audit-anchors.jsonl compares the local chain against every anchor in that file.

Each anchor line carries {id, entry_hash, count, timestamp, prev}:

  • id — the audit-log row id at the time the anchor was taken.
  • entry_hash — the SHA-256 hash of that row. Mismatch or absence of any non-lagging anchor (id ≤ local head) is classified as tamper.
  • count — how many rows existed in the audit log when the anchor was taken. Two count checks fail the verification closed (non-zero exit), no longer advisory (#912):
    1. count must be monotonically non-decreasing across non-lagging anchors. A shrink (any anchor's count below the previously observed maximum) means audit rows that existed when a prior anchor was taken have since been deleted — tamper.
    2. Count-shrink relative to the latest anchor. The live chain must not trail the highest anchored count by more than the configured staleness window ([audit] staleness_window, default 0). In honest operation the live DB is always at or ahead of every anchor (anchors trail the head), so a live count below the anchored high-water mark means rows were truncated after anchoring — or the anchor is stale/lagging beyond tolerance.
  • prev — the anchor chain (#912): a SHA-256 hash binding the previous anchor record (id, entry_hash, count, timestamp, prev). The first anchor's prev is the genesis value (64 zeros); legacy anchors written before the anchor chain existed carry no prev and are skipped (their link is unverifiable, but they still form the preimage for the next anchor's prev). That tolerance covers only the leading run of legacy anchors: once any anchor carries a prev, a later anchor without one is a break, not a skip, so history cannot be forged by simply deleting the field (#1122). Because each new anchor binds the entire previous anchor, wholesale re-chaining of the audit log requires rewriting the anchor history too — and that history lives out-of-band in git (the daily workflow commits it), so a silent re-chain is detectable by construction. A present prev that does not bind the previous anchor is classified as re-chained history — tamper.
  • Lagging anchors (id > local head, i.e. the anchor is ahead of the local head) are tolerated only within the staleness window; beyond it they count as count-shrink and fail closed. Set [audit] staleness_window to the number of events a legitimately-behind clone may trail the latest anchor.

The evidence-pack attestation (kew report export) surfaces the same verdicts: anchors.count_shrink and anchors.chain_ok in the JSON twin, and a COUNT-SHRINK / RE-CHAINED marker in the HTML attestation section.

A3 fix. Count-shrink-as-tamper (both directions), the anchor chain, and this documented witness model constitute the A3 mitigation. Full A3 closure (signed anchors or external third-party notarization) is out of scope and deferred.