Skip to content

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 PreToolUse hook denies the tool call before it runs (and the deny holds under bypassPermissions), plus an end-of-turn Stop sweep 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 over base...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 --staged catches what the agent layer missed: human commits, other agents.
  • CI — tamperward check --diff base...head, the authority for main. The policy is read from the merge-base, so the PR under review cannot govern its own verdict: an ignore: 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 compact PR label (tw1:<digest>) generated by tamperward signoff-label --rule <rule> --head <full-head-sha> (optionally with --file) and applied by someone with triage access or higher (label permission is approval permission — restrict it accordingly), never a committed file. The token hashes the exact rule/file/head tuple; legacy tamperward:allow:<rule>@<head-sha> labels remain accepted. Applying or revoking the label re-evaluates the gate. verify reads the same labels: generate one for verify to accept 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.

The repository root, from any subdirectory ​

Every git view reports paths relative to the repository root, and since 2.23.6 every command that reads the working tree resolves that root first (git rev-parse --show-toplevel, plus --absolute-git-dir for session state) and reads against it: the policy file, the after-content of every changed file, the protected-tree snapshot, the sign-off ledger and the unjudgeable-path probe. check --worktree, check --staged, sweep claude, hook claude, allow, doctor and init therefore produce the same verdict or plan from packages/x as from the root — before that, a session started in a subdirectory resolved every changed file to a path that did not exist, the content detectors went blind, and the root .tamperward.yml silently gave way to the baseline (fail-open, #412). A .tamperward.yml planted in a subdirectory governs nothing. The hook service is bound to the repository root whichever directory --dir names, and serves any subdirectory of it. init from a subdirectory wires the root and says so on stderr; onboard still refuses a child directory, since it is interactive and can ask. A directory outside any repository keeps its previous behaviour (the not-inside-a-repository refusal; a policy file beside it is read as before).

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. The policy file is read the same way: .tamperward.yml at the repository root is loaded only as a regular file, and a link there (wherever it points), a FIFO, a device or a directory is a policy error — check exits 2 and the hooks deny, as for a policy that does not parse.

The persistent hook service (opt-in, off by default) ​

Every Claude Code tool call launches the pinned hook as a fresh Node process, and most of the call's wall time was process startup rather than gate work. Since 2.22.0 two things address that, in order of how little they ask you to trust:

  1. The typescript parser (and yaml, picomatch) are loaded lazily. The eight AST detectors used to import the parser at startup, so every hook call — a Bash command, an edit to a Python test, --help — paid to read and evaluate a 9 MB module it might never use. It is now read on first use, through require, which also skips the ESM named-export discovery pass; a protected JS/TS edit reaches the same AST path with the same verdict (test/ts-lazy.test.ts). On a 1,000-protected-file fixture a Bash-payload hook call went from ~600 ms to ~190 ms, and a JS edit (which still needs the parser) from ~600 ms to ~400 ms. Nothing to enable; nothing about trust changes.
  2. An optional persistent hook service amortises the remaining startup across calls: tamperward hook-service start keeps one warm process per user and repository, and the hook hands it the stdin payload over a private unix socket instead of loading the engine itself (~115–135 ms per call on the same fixture, whatever the payload).

Enabling the service ​

bash
tamperward hook-service start --dir /path/to/repo   # foreground; run it from a terminal,
                                                    # a SessionStart hook, or a supervisor
TAMPERWARD_HOOK_SERVICE=1 claude                    # hooks consult the service only under this
tamperward hook-service status [--dir /path/to/repo]  # pid, version, served count, cache hit rate;
                                                    # with --dir, a listener bound elsewhere is reported
tamperward hook-service stop [--dir /path/to/repo]    # SIGTERM; the socket and state file are removed;
                                                    # with --dir, a listener bound elsewhere is left alone

Both halves are required: a running service is never consulted unless TAMPERWARD_HOOK_SERVICE=1 is in Claude Code's own environment (a hook inherits it from there; the hook command init writes is unchanged), and the variable does nothing without a service. The socket lives at $XDG_RUNTIME_DIR/tamperward-hook/hook.sock or <tmpdir>/tamperward-hook-<uid>/hook.sock (TAMPERWARD_HOOK_SERVICE_DIR overrides the directory), mode 0600, in a directory the service holds at 0700. Not available on Windows: hook-service start refuses with a clear message, and the hook runs in-process there as it always has.

The default runtime socket is per-user, so one service can accelerate one repository at a time. A hook from another repository is explicitly refused by that service and falls back to the ordinary in-process gate. Set TAMPERWARD_HOOK_SERVICE_DIR to a different private directory when deliberately supervising separate service instances.

What the service is, and is not, trusted with ​

The service runs the same functions on the same bytes: hook claude reads stdin, sends the raw payload with its cwd and the request-time environment that affects hook semantics (TAMPERWARD_DENYLOG, TAMPERWARD_FSEVENTS, TAMPERWARD_TRANSIENT, CLAUDE_CONFIG_DIR, HOME, USERPROFILE), and the service runs preToolUseFromRaw / stopFromRaw — the in-process entry points — and relays the HookResult unchanged. The wire contract with Claude Code (JSON on stdout at exit 0) is untouched; only where the evaluation happens moves. A parity test replays a fixture set through both paths and requires byte-identical verdicts.

The hook never fails open on the service's account. Before a request is handed to the socket, refusal means the engine loads and evaluates in-process exactly as before: the opt-in is absent; the socket cannot be reached; its directory/socket fails uid, type or mode checks; or the service explicitly refuses before evaluation (for example, the cwd is outside its bound repository or the protocol/version is wrong). After the client connects and hands the request off, fallback is no longer safe: the service may already be mutating the same session baseline / protected-tree / cursor state. An ambiguous timeout, disconnect, oversized or malformed final response therefore returns a fail-closed hook denial instead of launching a second in-process evaluation. The service sends an accepted protocol line before synchronous evaluation, and tests pin both the explicit-refusal fallback and post-handoff fail-closed paths. stop removes the socket and state file even when nothing was running.

The snapshot cache never believes the stat triple. The one thing the service keeps warm besides the process is the protected-tree snapshot, in its own memory, and the P1-8 lesson stands: mtime, size and mode are the candidate's to set (touch -r restores an mtime byte for byte; a same-length rewrite keeps the size), so a hash remembered against them is a hash the candidate can keep alive over content it changed. The cache key is (device, inode, size, mode, mtime, ctime) at nanosecond precision, and ctime is not settable from user space — every write, rename, chmod and utimes call, the touch -r itself, moves it to the kernel clock. An entry is reused only when all six match and the file had been stable for two seconds when its hash was taken (git's own "racy" rule, wider than any filesystem's timestamp granularity); anything younger is rehashed every call. The whole map is dropped every minute, so a full content verification is never further away than that, and the map lives only in the service's memory — never on disk, where a same-uid candidate could write an entry into it. The in-process hook has no cache at all and hashes every protected file every time, as it always has. test/hook-service.test.ts proves the touch -r same-size rewrite is still reported as drift through the cache.

Residual, stated plainly. The service runs as the same uid as the candidate. A candidate that can kill a same-uid process and start its own listener on the socket can answer "allow" to the hook — the same class of move as rewriting .git/tamperward/* or the settings file that wires the hook, which the loop layer has never been able to prevent and CI has never depended on. The service adds no authority: the Stop sweep, run, pre-commit and CI read none of its state. The ownership and mode checks close the cross-uid case (a shared /tmp on a multi-user host) and the accidental one (a stale socket, a wrong version); they do not, and cannot, make the loop layer the authority.

Apache-2.0. Every headline number is measured; the pre-registered predictions — including the refuted ones — are committed to the repo.