Audit history and tamperward stats
TamperWard can keep a privacy-safe record of the integrity findings its Claude Code hooks raise. This is measurement, not enforcement: failing to write an audit event never changes an allow/deny decision, and the audit history is never read as an authority input by check, verify, run, the hook, or CI.
This is useful for dogfooding questions such as:
- which rules fire most often while agents work;
- whether findings are coming from PreToolUse or the Stop sweep;
- how the mix changes over time;
- how much enforcement activity a repository actually sees.
A finding is not proof of intent. Legitimate refactors can trip an integrity rule, and a count of findings must not be described as a count of deliberate weakening attempts.
Enable the structured local audit
Set TAMPERWARD_AUDIT_LOG in the environment Claude Code inherits:
export TAMPERWARD_AUDIT_LOG=autoauto writes to the repository's Git directory:
.git/tamperward/audit.jsonlFor linked worktrees it uses that worktree's actual Git directory rather than assuming .git is a directory. An explicit file path is also accepted.
The v1 event is intentionally small:
{
"schema_version": 1,
"id": "sha256:…",
"timestamp": "2026-09-14T14:52:11.000Z",
"surface": "pretooluse",
"agent": "claude-code",
"rule": "test-skip",
"severity": "block",
"decision": "deny",
"session": "sha256:…"
}The writer is allowlist-only. It does not serialise prompts, tool command bodies, source/evidence text, filenames, absolute paths, environment values, credentials, or the raw Claude session id. The session identifier is one-way hashed before it leaves the hook.
The published event schema is schemas/audit-v1.schema.json. The stats --json aggregate has its own schemas/stats-v1.schema.json.
The older deny log
TAMPERWARD_DENYLOG remains supported for harness compatibility. It is a compact, best-effort rule-id trace and some warning records can contain filenames or diagnostic detail. Do not upload a deny log to the public GitHub audit store.
Use TAMPERWARD_AUDIT_LOG for durable statistics.
Read the statistics
From the repository:
npx tamperward stats
npx tamperward stats --since 30d
npx tamperward stats --since 12h
npx tamperward stats --jsonBy default stats reads the auto audit path. Use --file for an exported or downloaded JSONL file:
npx tamperward stats --file ./events.jsonlThe text view reports event, block, warning and hashed-session counts, followed by counts by rule and enforcement surface. --json emits the same deterministic aggregate as one JSON document.
--since accepts m, h, and d relative windows (90m, 12h, 30d) or an ISO timestamp.
Keep the durable history in GitHub
TamperWard itself ships .github/workflows/tamperward-audit.yml. The hook does not push anything to GitHub. Publishing goes through a pull request, and GitHub Actions is the writer. The invariant is not "nothing automatic ever writes the evidence branch" but: nothing unreviewed or candidate-controlled may cause evidence to enter it — automation only ingests, deterministically, what reached main through a PR. How strong "reviewed" is depends on the repository's branch protection (a CODEOWNERS entry covers audit/ and the workflow, binding where "Require review from Code Owners" and a minimum approval count are enabled); independently, committed batches are validated pre-merge by CI and the write-capable job runs no candidate code (see below). There are two entry points, both running only from the trusted copy on main:
- an operator dispatches a validated batch by hand (
workflow_dispatch), or - a reviewed PR adds an immutable batch file
audit/pending/<batch-id>.jsonland, on merge tomain, the workflow ingests any batch whose id/content hash is not already recorded.
There is deliberately no pull_request / pull_request_target trigger: an untrusted fork PR must never run the workflow that writes the evidence branch. The post-merge push fires only after a merge, is gated again by if: github.ref == 'refs/heads/main', and re-validates the schema before touching the branch. A merge with no new batch is a clean no-op that never touches the evidence branch and never even builds.
The workflow:
- runs only from the copy committed on
main, whether dispatched by hand or fired by a post-merge push; - hashes each committed batch and ingests only those whose id/content hash is not already in the ledger, so repeated runs are idempotent;
- validates every new batch with TamperWard's strict audit-v1 parser, and fails closed if an already-ingested batch id reappears with changed content;
- rejects unknown fields rather than trying to redact them after upload;
- creates or updates a separate
tamperward-auditbranch; - deduplicates records by event id;
- writes
events/all.jsonland records each batch iningested/batches.jsonlwith its source commit SHA, content hash, schema version and timestamp; - regenerates
summaries/all-time.jsonand a human-readable branchREADME.md.
The write credential is isolated: a read-only prepare job builds and computes the append (no write token while npm ci/build/candidate code runs), and a minimal publish job holds the write token but runs no npm ci and no candidate code — only first-party actions, a dependency-free re-validation against the committed schema, and git. Committed batches are additionally validated pre-merge by the normal CI suite, so a malformed batch never reaches the privileged job.
On merge to main (the reviewed-batch path)
Add each set of events as an immutable audit/pending/<batch-id>.jsonl in a pull request — review is the curation step — and merge. See audit/README.md. Batches are immutable once merged: to correct evidence, add a new batch rather than editing an old one. Ingestion never writes main; it only ever appends to the dedicated tamperward-audit branch.
By hand (the dispatch path)
A typical bounded upload is:
AUDIT="$(git rev-parse --absolute-git-dir)/tamperward/audit.jsonl"
# Validate locally first.
npx tamperward stats --file "$AUDIT" --json >/dev/null
# GitHub workflow inputs are bounded, so send a recent batch. Re-sending records is
# safe: the store deduplicates by audit event id.
gh workflow run tamperward-audit.yml --ref main \
-f batch="$(tail -n 100 "$AUDIT")"Only submit the structured TAMPERWARD_AUDIT_LOG. Workflow-dispatch inputs become GitHub data before the job can validate them, so the workflow cannot make an unsafe input private after the fact.
The tamperward-audit branch is historical evidence only. It should be protected from ordinary developer/agent pushes, but even an altered audit branch cannot clear a finding, approve a PR, change a policy, or make an enforcement verdict green.
Interpreting dogfooding numbers
Prefer wording such as:
TamperWard raised 31 integrity findings in 84 recorded agent sessions.
Do not automatically rewrite that as:
Agents tried to cheat 31 times.
To make claims about behaviour after a finding, correlate the audit events with a separate, explicit outcome classification. The v1 audit deliberately does not infer honest correction, repeated weakening, abandoned, legitimate refactor, or human sign-off from timing alone.