Enforcement & sign-off
One engine, one Finding[], at every stage a change passes through. CI is the authority; the earlier layers exist to shorten the feedback loop:
- Agent loop — a Claude Code
PreToolUsehook denies the tool call before it runs (and the deny holds underbypassPermissions), plus an end-of-turnStopsweep that diffs the working tree — so a change made through the shell, or committed mid-turn, is still the turn's work. This layer exists only where the hooks do — Claude Code today. Any other agent, and any human, meets the gate at pre-commit and CI instead, where a denial arrives later and redirects less. - Run envelope —
tamperward run -- <agent command...>wraps the whole agent invocation and treats the runtime's exit as untrusted: the trusted base is recorded before the agent starts, and whatever tree exists at termination — committed or not — is re-adjudicated against it (policy overbase...HEAD, policy over the worktree, pristine verification). The measured reason this layer exists: in our round-2 experiment, a hook denied 42 mutations and rejected every one of 25 stop attempts, and the session still completed with a masked-green tree, because hooks decide but runtimes terminate. The envelope owns the boundary from outside; the hooks remain the in-session fast path that steers the agent toward honest work. - Pre-commit —
tamperward check --stagedcatches what the agent layer missed: human commits, other agents. - CI —
tamperward check --diff base...head, the authority formain. The policy is read from the merge-base, so the PR under review cannot govern its own verdict: anignore:line or a lowered severity on the branch is reported, and takes effect only after a human merges it.
The sign-off model
The escape valve exists — legitimate exceptions are real — but the agent can never author its own:
- Agent layer: honours nothing. A block is a block.
- Local:
tamperward allow <rule> --reason "..."writes a fingerprint-bound ledger entry — a human at a keyboard. - CI: out-of-band only — a PR label (
tamperward:allow:<rule>@<head-sha>) applied by someone with triage access or higher (label permission is approval permission — restrict it accordingly), never a committed file. Applying or revoking the label re-evaluates the gate.verifyreads the same labels:tamperward:allow:verify@<head-sha>accepts a masked failure a reviewer has judged — the original suite is genuinely wrong for an intended behaviour change — and clears nothing else. A red suite, or a run that could not verify, stays red.
Fail closed
If Tamperward cannot evaluate a change — an unparseable policy, a git failure — the change is denied, not waved through. A crashed gate must never become an absent gate. At the CLI that means exit 2 with one tamperward: … line on stderr, whatever threw (a revision git cannot resolve, a directory where init expected a file); the hook and the sweep deny as JSON at exit 0 instead, as their wire contract requires.
The generated local wiring also protects the step before that contract begins. npm does not read a project .npmrc in global mode, so candidate call, workspace, proxy, CA and startup settings cannot control the pinned npx authority. If npm still cannot start it, the hook command converts that launcher failure to Claude's blocking-error exit channel rather than leaving an ordinary non-zero exit that the runtime could treat as non-blocking. Generated CI installs before checkout and invokes the installed binary directly.
Generated local shell wiring requires a POSIX host with /dev/null. The user rc is fixed to that OS device; the global rc is fixed to /dev/null/npmrc-global, which cannot exist below a device. Distinct paths avoid npm's double-load error. HOME cannot select those sources. The invoking environment and Node/npm installation remain trusted prerequisites. Native Windows launchers need a separate template.
The policy loader is strict for the same reason: an unknown top-level key (Rules:, ignored:) is refused rather than silently ignored, and signoff.ledger must stay inside the repository. A leading / on a protected, ignore or exclude glob is dropped when the policy loads — paths in every git view are repo-relative, so /e2e/** and e2e/** are the same rule.
The same holds for a protected file that changes where git cannot see it: the hook judges it from the trusted content when it can, and blocks as hidden-drift when it cannot (see the rules) — and for a protected path that is not a regular file the gate can read. A symbolic link is never followed (git records it as its target text), a FIFO, a socket or a device is never opened for content, and a file above 64 MiB is not read: a new or changed one at a protected path is blocked by name, not read through.