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 chain — kew 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 newentry_hashpreimage 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, andsidecar_digest(the digest is over kew's host-stored sidecar copy, never a deletable GitHub comment).reviewer_modelandprompt_artifact_sha— which model, running which pinned prompt version, produced the verdict.- A
policyblock (blocking,min_severity_to_block,max_rounds,max_review_usd) binding the verdict to the policy in force (#250 precedent): the sameNEEDS_CHANGESmeans different things underblocking = truevsfalse, so the policy travels with the verdict. spend_source: run_recordsandspend_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. Exit0intact (and for an empty log),1tamper detected,2cannot run (no database, or an unreadable--anchorsfile).kew audit anchor [--anchors PATH]— append the current chain head{id, entry_hash, count, timestamp, prev}to the anchors file, binding the previous anchor viaprev(the anchor chain). Pure-local; the scheduledaudit-anchorworkflow 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).-ndefaults to 20;--eventfilters by event type;--itemfilters by durable work-item id (e.g.SFD-12, or a GitHub number42);-fpolls for new events every second (liketail -f). The event line addresses each row by work item usingitem_refsemantics — a bare tracker id (SFD-12) for non-GitHub items and#Nfor 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 thepolicy_hashfield documented in Governance attestation — everydispatch_completedevent carries apolicy_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¶
- 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.
- 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.
- 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.jsonlgives no independent assurance when the runner that pushes it shares a trust domain with the chain writer (the sameGITHUB_TOKENcan 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. - 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):countmust be monotonically non-decreasing across non-lagging anchors. A shrink (any anchor'scountbelow the previously observed maximum) means audit rows that existed when a prior anchor was taken have since been deleted — tamper.- Count-shrink relative to the latest anchor. The live chain must not
trail the highest anchored
countby 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'sprevis the genesis value (64 zeros); legacy anchors written before the anchor chain existed carry noprevand are skipped (their link is unverifiable, but they still form the preimage for the next anchor'sprev). That tolerance covers only the leading run of legacy anchors: once any anchor carries aprev, 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 presentprevthat 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_windowto 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.