The rules
Seventeen mechanical rules on the diff, command and event surfaces, deterministic by construction — nine block, eight warn — plus one measured JS/TS heuristic, assertion-weakening, which ships warn, and the outcome layer, tamperward verify, which interprets no diff at all. guard-removal remains the one reserved/unbuilt heuristic name. Every id here is a key under rules: in .tamperward.yml, and the baseline severities are the ones defaultPolicy ships.
| rule | severity | catches |
|---|---|---|
test-deletion | block | deleted spec files, renames out of the tests glob (a policy.ignore entry cannot hide one: a rename is suppressed only when both of its ends are ignored and the old path is not protected), renames into a path the runner does not walk while still inside the glob — vitest/jest's default exclude (cypress/, node_modules/, dist/, any dot-directory), pytest's default norecursedirs (build/, dist/, venv/, dot-directories), a Rust file under tests/ deeper than tests/<name>.rs or tests/<name>/main.rs, a Go path with a testdata, _* or .* component — as a file change or an mv, and only when the old path was itself walked, net removal of it()/test() blocks (counted via the AST; a literal it.each table counts one test per row), shell mutation of protected test paths(rm / unlink / shred, sed -i, truncate, redirects,cp / install / rsync / ln / mv of something else onto a spec, tee,sponge, dd of=, find -delete,… | xargs rm and its kin over a spec named earlier in the line, git checkout <rev> [--] <path>, a directory that holds protected specs, the whole cwd as . / * / a ..-relativetoken — the path read as the shell would hand it, embedded quotes and backslashes joined and a glob expanded against the repository listing; the command read in command position past sudo / env / nice / time / command / a VAR= prefix, so grep "rm " spec and node scripts/rm-cache.js spec are reads), and the runner told not to open a spec — jest testPathIgnorePatterns / testMatch / testRegex / roots / rootDir / modulePathIgnorePatterns / projects, vitest test.exclude / test.include (a ! entry included) / test.dir / test.projects, a vitest.workspace.* file, a vite.config.* carrying a test: key — evaluated as the selection predicate over the repository's own spec files (a multi-project config is the union of its projects; typecheck.* and benchmark.* are other suites; a fixtures/ / __mocks__/ ignore is not a narrowing), and the package.json test scripts read as check invocations the way ci-tampering reads a workflow line (since 2.23.11): test, test:ci, check and pretest are the suite — a script that now runs no check or a check of another kind ("test": "echo ok", npm run lint, && npm test dropped from check, pretest deleted) is a removal, and one whose status is masked (|| true, ; exit 0, a pipe, --passWithNoTests, --if-present), cut short (timeout 1), whose nyc --check-coverage is dropped or --cov-fail-under lowered, or whose suite is narrowed by a flag (--testPathPattern, -t, --exclude, --dir, --project, --shard, jest --root / --rootDir / --testMatch / --testRegex / --modulePathIgnorePatterns / --selectProjects, pytest -k / -m / --deselect / --ignore, mocha --grep / -g, go test -run, cargo test -- --skip, and the per-runner table ci-tampering reads), a spec path as a positional (vitest run src/easy.test.ts) or a --config pointing at a file the policy does not protect or one the same change adds is a neutralisation; test:unit / test:integration are slices whose flag or positional is design, read only for a masked status, a timeout or a replacement; a runner migration that keeps the suite (jest → vitest run, mocha → vitest), an env prefix (cross-env, NODE_OPTIONS=), a wrapper (nyc, c8, nub exec, $(yarn bin jest)), python -m pytest, a reporter flag, a flag that was already there, a further run added beside the kept one, the failing npm-init placeholder, and a check moved into a script the same change adds stay clean; a describe.each table multiplies the tests it encloses; a test moved into another spec in the same change (split, merge) is a relocation, not a deletion — held only by added tests that have a body, never by it("noop", () => {}) stubs. Since 2.7.1 the predicate is read the way the runner reads it: it.for / describe.for count like each and a loop over a literal array counts once per element; --exclude e2e/** is set-up; extends: true projects inherit the root selection while extends: '<path>', mergeConfig over an imported base and a ...base spread are opaque; test.root rebases like test.dir; a .snap inside __tests__/ belongs to snapshot-rewrite; git checkout main -- test/ while on main restores nothing older. Since 2.24.4 the block count is a reachability count: an it() / test() counts only where the runner reaches it — from the module top level or a describe callback — so a block wrapped in if (false) (or any condition that folds to a constant), written after an unconditional return / throw, or moved into a function nothing calls is removed; if (process.env.CI), a helper called from a test, a hoisted function called before the return, an IIFE stay counted. pytest is read the way it collects: a def test_* inside a class counts only under a Test* class or a TestCase subclass, so renaming class TestMath to class MathTests is a deletion. Since 2.24.3 a modifier between the runner and .each / .for (it.concurrent.each, test.skip.each, describe.only.for, in any order) is unwrapped, so it.each → it.concurrent.each is a refactor with the same block count, not "3 → 0 blocks". Since 2.25.0 (#447) the protected runner config going opaque is itself reported: export { default } from './vitest.real', module.exports = require('./jest.real'), a ...base spread, mergeConfig(base, …), extends / preset, or a projects entry that now points at a file this change adds or that no protected glob covers — a delegation to a file the gate can still read stays silent — and testNamePattern (per-runner spellings included) is read as a further selection predicate |
test-content-removal | block | content stripped out of a spec that survives — the failing rows of a data-driven table, the expected-message arguments of throws(...) calls, a gutted mock-setup region — which leaves the block count untouched and walks past test-deletion; a removed significant line is excused only if its text is still kept in the changeset's protected tests after the edit, and a comment does not count as kept; rows of an each-table that spreads from elsewhere (or is built by a call) are compared as elements — one-line tables included — and two rows gone that reappear nowhere in the change fire on their own; content added to a non-spec file under a test directory (a case table moved to test/fixtures/) counts as kept. Since 2.24.4 a line is content only where it can run: three or more assertions parked in a string or template literal, wrapped in if (false), or written after a return in the test body (Python: after a bare return / raise, or under if False:) are removed content even though their text is still in the file, and a removed line is excused into the kept pool only where its text occurs as code — reformatting still passes, quoting does not |
test-skip | block | added .skip / .only / .todo / xit / xdescribe — .only narrows the suite, same class. Read per language of the protected file since 1.15.0: @pytest.mark.skip / pytest.skip() / @unittest.skip, Go t.Skip(), and since 2.24.2 (#441) Go Skip()/Skipf()/SkipNow() on any receiver (tb.Skip(), testify s.T().Skip()) plus the pytest conftest hooks that drop or rewrite tests without a marker — pytest_collection_modifyitems, pytest_pycollect_makeitem, pytest_runtest_makereport, a rep.outcome = 'passed' rewrite, add_marker(pytest.mark.skip), and sk = pytest.mark.skip with its @sk (a conftest that only defines fixtures stays clean), Rust #[ignore], Ruby skip / xit, JUnit @Disabled / @Ignore, PHPUnit markTestSkipped(), .NET [Ignore] / Skip = "…". For a JS/TS spec with full content the TypeScript AST resolves the runner behind the call — a known runner module's import (named, default, import * as v / require(...)), a base.extend(...) of one, an alias, or the export const test = base.extend(...) of a relative fixture module the change or the repository can read — and stays silent only for a root it PROVES is not a runner (a parameter, a local function it(), a local object); a root it cannot classify falls back to the line matcher, so a Playwright-style repo whose fixtures cannot be read is judged the same way on full content and on a bare diff (2.23.8). Since 2.24.4: a Go TestMain added without m.Run(), any //go:build / // +build constraint added to a _test.go, a Rust #[cfg(…)] other than cfg(test) on mod tests or beside #[test], JUnit 5 @EnabledIf… / @DisabledIf…, RSpec it 'x', if: false / unless: true. Since 2.24.3 a .skip / .only / .todo anywhere before .each / .for is the marker — describe.only.each(rows), it.skip.each(rows), it.concurrent.skip.each, split across lines or not — so a focused or skipped table blocks as test-skip rather than by accident as a deletion |
assertion-weakening | warn | JS/TS AST-proven one-way weakening inside the same unambiguous suite-qualified test: a statically proven exact/structural value becomes only truthy/defined, positive `toThrow(message |
ts-any-cast | block | added as any / <any> casts, as never, and the double cast through unknown / never / {} / object / Object ((x as {}) as T launders exactly as x as unknown as T); a local type alias of any of those keywords is resolved, so type A = any; x as A is as any. Also a live @ts-ignore / @ts-expect-error / @ts-nocheck directive — counted on the actual comment ranges, so a comment that merely mentions one (or the directive text inside a string, template or regex) is not one — and the JSDoc @type {any} / {*} / {?} cast in a JS file. The unambiguous escape hatches, rare in honest code; as unknown / a lone as {} / as object are honest widenings or ordinary assertions, not row 4 |
ts-any-launder | warn | any introduced in an annotation or generic position (: any, Record<string, any>, Array<any>) — the spelling agents launder with, but common enough in honest code that it surfaces for human review rather than blocking; a permanent warn |
ts-cast-growth | warn | net growth of ordinary as T / <T>x and non-null x! assertions in non-test JS/TS source, counted on the AST net of casts the same change removed; as any / as never / the widening double cast (and its inner step) stay ts-any-cast, and as const, as unknown, satisfies, generated/vendored/declaration files and test files are outside the budget — a warning that fires on 8.7% of legitimate mainline pairs, so it prompts review and never blocks by default |
lint-suppression | block | added eslint-disable (inline or block), prettier-ignore, biome-ignore; per language since 1.15.0: # noqa, # type: ignore, # pylint: disable, //nolint, # rubocop:disable, @SuppressWarnings, phpcs:ignore, #pragma warning disable. Rust #[allow] is deliberately excluded (too common in honest code for a block rule) |
coverage-lowering | block | thresholds lowered, moved, or deleted — Jest and Vitest shapes, all four metrics, package.json included; --coverage stripped from scripts, --coverage=false / --coverageThreshold= added; passWithNoTests as a config key, vitest thresholds.autoUpdate; the denominator narrowed (collectCoverageFrom / vitest coverage.include compared as a predicate over the repository's own source files, so src/** → src/**/*.{ts,tsx} in a TypeScript repo is silent while src/** → src/index.ts is not; coveragePathIgnorePatterns / coverage.exclude grown, .coveragerc [run] omit grown, codecov ignore: grown); vitest coverage.enabled: false and thresholds.perFile dropped; .coveragerc / pyproject.toml fail_under, nyc thresholds and check-coverage (in .nycrc or under package.json's nyc key), codecov target (a number, or auto) / threshold / informational / project: off, each compared within its own project: / patch: scope. It never claims a removal it cannot see: a spread, an expression, a stricter override naming one metric, an override removed for a file no longer in the repo — and a gate that moved to another config in the same change (.coveragerc → pyproject.toml, jest.config.js → package.json) with numbers no lower is a move, not a deletion. Housekeeping, not weakening (2.7.1): exemptions of generated code, migrations, *.generated.* / *.gen.* / *.pb.*, *.e2e-spec.*, manage.py / wsgi.py / asgi.py / __init__.py and main.ts (**/index.ts stays reported); vitest thresholds: { 100: true } read as every metric at 100; codecov informational: true under patch: beside an untouched project target; --passWithNoTests in a package whose directory holds no spec; since 2.24.0 (#438): jest collectCoverage and vitest coverage.all switched off, nyc --check-coverage dropped from a script, --cov-fail-under lowered or dropped in a script, pytest.ini / pyproject.toml addopts, a tox.ini command or a workflow step, setup.cfg / tox.ini [coverage:report] fail_under; and jest's regex ignore list reads .md as the wildcard it is — only \\.md$ / \\.d\\.ts$ is a benign suffix there, while a glob *.md stays benign |
coverage-exclusion | warn | an inline coverage exclusion added to a non-test source file — /* istanbul ignore … */, /* c8 ignore … */, /* v8 ignore … */, /* node:coverage ignore … */, # pragma: no cover, #[coverage(off)], or a //go:build constraint added to an existing Go file. Text in a string or behind a line comment, a moved marker, a range closer, a new platform-split Go file and generated / vendored / declaration / config paths are not findings. Also the ordinary way to mark a genuinely unreachable branch: 0/460 legitimate mainline pairs fire, and every real add in the deeper history is an honest one (harness/fp-study/COVERAGE-EXCLUSION-CORPUS.md), so it prompts review and never blocks by default |
test-support | warn | the files that SERVE the specs under the same tests globs but define none of their own — a helper, a setup module, a mock, a JSON fixture — deleted, renamed out of the globs, or net-shrunk by three or more significant lines with none of the removed lines surviving elsewhere in the change. Which files are spec-shaped is one predicate shared with test-deletion and test-content-removal: a runner-selected suffix (.test.ts, _test.go, test_*.py, *Test.java, conftest.py, …) is always a spec, a fixtures/ / __fixtures__/ / __mocks__/ / __snapshots__/ path component is always support, a file in no language the detectors read (JSON, YAML, a note) is support, and otherwise the content decides — zero test definitions on both sides is support, unreadable or uncountable content stays a spec. The two block rules cede exactly this class: a fixture trimmed here is a review prompt, not a gutted spec, because it holds no test of its own to lose (#443) |
config-weakening | warn | tsconfig*.json, .eslintrc* / eslint.config.* / .eslintignore, and biome.json[c] were protected by name but nothing read them (#447) — this rule reads each family the way its tool does: a tsconfig strictness flag that was on (explicitly, or implied by strict) switched off, or a loosening flag (skipLibCheck, allowUnreachableCode, …) added; exclude grown with a pattern that hides a tracked source file; extends dropped, or pointed at a file this change adds or that no protected glob covers (a flag lowered in one tsconfig and raised in another of the same change moved, and is silent); an eslint rule turned off / 0 that was not off in any config of the family before the edit (a .eslintrc → eslint.config.js migration carries its off rules over silently; a base rule retired beside its plugin-prefixed twin switched on is a replacement, not a removal); ignores / ignorePatterns / globalIgnores([...]) / .eslintignore grown the same way as tsconfig exclude; biome linter.enabled: false, a rule or rules.recommended set off, files.ignore / linter.ignore grown, a ! entry added to files.includes; and a NEW jest/vitest setupFiles / setupFilesAfterEnv / globalSetup / runner / testEnvironment / reporters entry pointing at a file this change adds or that no protected glob covers — a module the runner executes with the suite's globals in reach. "Hides source" is judged over the repository listing when the gate has one, else by conventional housekeeping names. Ships warn pending an fp-study replay (harness/fp-study/CONFIG-WEAKENING-CORPUS.md) — every one of these has an honest spelling (a build-only tsconfig excludes the tests; a rule retired for its typed twin) |
ci-tampering | block | check steps removed from workflows (a moved check is not a removed one, and neither is one edited in place — a flag added, npm test → npm run test / pnpm test, an action bumped @v3 → @v4), a kept check line whose block was neutralised (set +e, exit 0, if false; then, a heredoc comment, shell: bash {0}, || true, --passWithNoTests), continue-on-error: true and if: false in any spelling that folds to a constant (quoted booleans, ${{ 1 == 1 }}, ${{ 'a' == 'b' }}, ${{ !true }}, and the short-circuits ${{ context && false }} / ${{ context || true }}, which decide whatever the context holds — anything that genuinely depends on a context reference stays reachable) on a step that runs a check or at the level of a job that runs one (a job-level if: false on a deploy job neuters no check), and on: narrowed so the workflow no longer runs (push / pull_request removed, paths-ignore: ['**'], paths: that no source file can match, pull_request.types without opened / synchronize, branches: no longer naming the default branch — read from origin/HEAD; [main] → [master] is accepted when the repository cannot say which is default). Kept, not removed: a check respelled as another invocation of the same kind (npm test → npm run test:ci, npx jest → npx vitest run) and a check moved into a reusable workflow the same change adds. Reported as neutralised: a spec path as a positional (npm test -- test/a.test.ts, npx jest test/a) and timeout N wrapping the check — the same invocation reading test-deletion applies to the package.json test scripts since 2.23.11. Read the way the runner reads it since 2.7.1: a workflow expression folds to its constant or reads as one token, so a --shard or --project valued by the matrix, the strategy or a workflow input is not a narrowing while a literal --project=unit still is; node --test, mocha, biome ci, oxlint, tsgo, deno test are checks of their kind; a path positional narrows a test, not a lint over src/; cd apps/web && npm test places the check; shell: bash -euo pipefail {0} keeps fail-fast; continue-on-error on a reporter or upload action (dorny/test-reporter, upload-artifact, codecov) is not on the check; [master] → [main] is a rename when the branch it names exists, whatever a stale origin/HEAD says. Since 2.23.10 a respelling of the same kind that runs nothing is reported as neutralised rather than kept: npm run tests --if-present, npm test --prefix packages/empty, -w empty, pnpm test --filter nothing, jest --shard=1/1000 (a matrix-valued shard is still the matrix), --testMatch / --testRegex / --root / --rootDir / --modulePathIgnorePatterns / --selectProjects / --grep, pytest -k / -m / --deselect / --ignore / -p no:python (-p no:cacheprovider selects nothing and is not reported) / a lowered --cov-fail-under, cargo test -- --skip, go test -run; a working-directory added to a check step (or as defaults.run) that points at a directory holding no tracked code file or package manifest, and an actions/checkout step given a literal ref: (an expression such as ${{ github.event.pull_request.head.sha }} is not reported); a removed check only survives where the shell would run it — not inside a heredoc body, a run: > folded scalar's continuation, an echo, a step name: or a comment; if: contains('a', 'b'), ${{ fromJSON('false') }} and the other expression functions fold when every argument is a constant; and on: narrowings that start from an implicit every-branch filter (push: → push: branches: [never-exists]), a tags:-only filter replacing branches, a !main negation, or a paths-ignore that covers every source file are reported. Since 2.25.2 the rule reaches past the workflow files (issue #437): a composite action a workflow uses: (.github/actions/**/action.y?ml, runs.using: composite) gets the same removal/neutralisation pass over its runs.steps — the workflow keeping uses: ./.github/actions/test while the action's run: npm test becomes echo ok no longer goes unreported; the entry files of the other CI systems (.gitlab-ci.yml, .circleci/config.yml, Jenkinsfile, azure-pipelines.yml, bitbucket-pipelines.yml, .travis.yml) get the generic check-line pass, read from script: / command: / sh / bash / pwsh lines as from run:, with none of the GitHub-shaped logic (on: triggers, if: folding, continue-on-error, shell: … {0}) since those keys mean other things there. A neutralising suffix the removed line already carried is a respelling, not a new neutraliser, and a deleted entry file whose checks move into a workflow the same change adds is a migration, not a removal |
hook-tampering | block | the wiring tamperward init writes — the two Claude settings gate entries (project, local, user or managed file — ~/.claude or $CLAUDE_CONFIG_DIR, through a symlink, from a Write or from the shell) and the pre-commit script — compared to the exact shape init writes, not for whether a tamperward token is still present: any other key on the entry (async, if, args, shell, a short timeout), any text around the invocation, another launcher, a narrowed or malformed matcher (read with the runtime's own exact-list / regex semantics — Edit, Write and Edit|Write are the same list), a top-level env key added or changed in the file that carries the gate (the reason names the mechanism for PATH, NODE_OPTIONS, BASH_ENV, BASH_FUNC_*, LD_*, npm_config_*, …), a version pin lowered, removed or raised past the gate judging the edit, the disableAllHooks: false init declares removed or flipped, a hook entry added beside the gate or a value the runtime's schema rejects on any entry; a hand-written hook script held byte-for-byte to its before — the only clean edits are raising its tamperward@<ver> pin (a plain version, never above the gate judging it) and a trailing newline; every other edit, honest or not, is a sign-off, with what the liveness model reads under the way that hook is executed (husky's sh -e, git's direct exec) attached as the detail — swallowed by a || chain that ends in success, a passing trap or set +e, a command/env in front of the gate shadowed by a function or alias, a PATH= / NODE_OPTIONS= / npm_config_* assignment before or on it, --staged swapped for --diff or --worktree, & wait without a pid, a pin lowered or no longer plain (@^1, @latest), a cd away; a new hand-written hook script must run the gate live or it is a sign-off; lefthook / pre-commit / package.json entries by the command they run (skip/only in any shape, the mode flag, --cwd and pre-commit args part of the gate's identity, a command-level env that reroutes the gate, a pin no longer plain, additional_dependencies on the gate entry, lefthook-local.yml as an overlay, stages inherited from default_stages, echo husky is not husky); CODEOWNERS over the gate's concrete files with GitHub's glob semantics; the experimental runtimes' project hook wiring (2.39.23) — Codex's .codex/config.toml by its [hooks] tables and the Copilot CLI's .github/hooks/tamperward.json by its hooks member, at any depth: a deletion or rename, a gate entry removed or changed, a hook added beside it, a file that no longer parses, while an edit outside the hook tables / member and hooks arriving where there were none are the project's own; chmod/tee/sed/xargs/find -exec/inline-interpreter writes and >|/&> redirections against protected paths — the user and managed settings files included, ~/$HOME/$CLAUDE_CONFIG_DIR expanded and symlinks resolved; .tamperward.yml weakened — a lowered or disabled rule, a lowered version:, an added ignore / exclude glob, and since 2.23.9 a protected-glob addition judged by the rules' reach rather than the list: a glob added to protected.snapshots (which demotes the spec rules on those paths), a negated glob (!x matches every path) in any category, a tests glob that also matches snapshot paths, and any path a rule judged before the edit and no longer judges after it, named with the rule (the repository's own files when available); a new tests glob naming a real layout is not reported; sign-off, while an added .pre-commit-config.yaml or lefthook.yml is a config, not a script — clean unless it arrives with its own gate skipped, scoped or not live — and an added .husky/.gitignore or README is nothing at all (2.24.1) |
no-verify | block | --no-verify, git commit -n, HUSKY=0, HUSKY_SKIP_HOOKS, --no-hooks, core.hooksPath, LEFTHOOK=0, LEFTHOOK_EXCLUDE=, and the pre-commit framework's SKIP=<hook-id> in a git or pre-commit invocation — quoted or not (HUSKY="0"), one shell deeper (sh -c, eval), or in a git alias body (git config alias.ci "commit --no-verify", git -c alias.ci=…); pre-commit / lefthook / husky uninstall and rm of .git/hooks/<hook>. The hook-less commit paths — git commit-tree, update-ref, am, cherry-pick, rebase — warn rather than block (see below) |
snapshot-rewrite | warn | runner update mode (jest -u, --updateSnapshot), regeneration scripts by name, modify/delete of *.snap, __snapshots__/, golden/ |
snapshot-only-rewrite | warn | snapshots moved with no accompanying change, judged only at commit granularity (pre-commit and CI diffs); ~0.06% FP on audited mainlines, 7/7 TP on observed tampers — the graduation candidate |
transient-protected-mutation | warn | judged at the Stop sweep from the tamperward watch event log: a protected file whose recorded content or mode churned during the turn while its net state at Stop is unchanged — modified and restored, so neither the per-call drift check nor the turn diff can see it, while anything that consulted the suite mid-churn consulted a weakened version. TAMPERWARD_TRANSIENT=block may raise it and can never lower it |
Customised wiring needs a sign-off
hook-tampering holds the wiring tamperward init writes to the shape init writes. The two Claude settings entries and the pre-commit script are compared byte-for-byte — modulo a version pin, which may only go up — not for whether a tamperward token is still somewhere in the file. That is deliberate: every bypass this rule has closed left the token in place and changed what the runtime did with it (| head -c0 after the command, "async": true beside it, a pin of 0.1.0, a PATH= in front). The cost is that an honest customisation of init's wiring is reported too — a linter step added to the init-written pre-commit script, a short timeout on the hook entry, a different launcher, a hook entry of your own added beside the gate in .claude/settings.json, an env key in that file — with the evidence the gate script no longer matches the shape init wrote; re-run init or sign off, or the reason the settings comparison gives. A comma-separated matcher (Edit, Write) and a statusMessage on the entry are not customisations: the runtime documents both, and they pass.
The two honest paths are exactly those. Re-run tamperward init to restore the shape, or sign off once (tamperward allow hook-tampering --reason "…") and keep the customised wiring. From then on the file is hand-written wiring, and it is held byte-for-byte to the version you signed off. The only edits that pass without another sign-off are raising the tamperward@<ver> pin (a plain version, never above the gate judging it, or pinning an invocation that had no pin) and a trailing newline. Every other edit — a linter step added, npx → pnpm exec, an if ! … ; then exit 1; fi around the gate, a comment line — is reported with the reason the gate script changed; sign off, and cleared the same way as the first one: tamperward allow hook-tampering --file <path> locally, the compact tw1:<digest> label generated with tamperward signoff-label --rule hook-tampering --file <path> --head <full-sha> in CI.
That is deliberate too. Three adversarial passes over a shell-script liveness model, the last against a model rebuilt after the second, each found a dozen shapes a real sh -e runs one way and a line-by-line reading runs another: ( gate || true ), an exit 0 inside a brace, a trap by function name, a PATH= line, check --diff HEAD...HEAD, a multi-line string, gate & wait. A reading that cannot be sound cannot be the verdict. It is kept as evidence: when it recognises what changed, the finding says so — || true, NODE_OPTIONS= in front of the gate, --staged swapped for --diff, a pin no longer a plain version — so the person signing off sees the mechanism named rather than a bare "changed"; when it sees nothing, the finding says only that the script changed, and that silence is not clearance.
A new hand-written hook script has no before to be held to. It is judged by the model alone and must run tamperward check live under the way that hook is executed (husky runs sh -e <file>; git execs a hook in core.hooksPath directly, so there a gate that is not the last statement needs set -e), or the add is a sign-off as well. The one exception is husky's own v9 runtime: the files it writes under .husky/_/ when npx husky runs (usually the prepare step of an ordinary npm install) are recognised by their exact byte content while .husky/pre-commit is a regular file that runs the commit backstop itself — exactly tamperward check --staged, live, at the pin the judging release carries. Its mere existence is not enough: an ordinary husky pre-commit whose real gate is elsewhere would be displaced, and a live but ineffective gate (--worktree, --diff HEAD...HEAD, a --cwd away from the root, an old or unpinned version) is not the backstop. So an install does not need a sign-off; a file that is not byte-for-byte husky's, or a pre-commit that is not that backstop, still does.
The sign-off stance is for scripts. A file added under protected.hooks is classified by what it is before it is judged (2.24.1): a .pre-commit-config.yaml, lefthook.yml, .lefthook.yml or lefthook-local.yml is a hook configuration and is read by the config comparator against a fresh base that carries every gate entry the file names as init would write it — live, unskipped, unscoped, on the commit stages. Onboarding a linter (repos: … trailing-whitespace, a lefthook lint command) names no gate and is clean; a config that arrives with the gate live is clean; one that arrives with its own gate under skip:/only:, scoped by glob/exclude/tags or stages: [manual], rerouted by an env, or not live (… || true) is reported as the weakening it is. Husky's own .husky/.gitignore, a .gitattributes, a README or .txt note in the hooks directory is nothing that runs and is silent, added or edited. A new .husky/pre-commit that does not run the gate live is still the sign-off above.
Hooks merge across the Claude settings files, so a settings.local.json that only adds hooks of its own is not a finding; one that sets disableAllHooks, an env the gate resolves through, or a neutered gate entry is. In the file that carries the gate, though, every hook entry added beside it and every env key added or changed is a sign-off: a sibling's output combines with the gate's verdict (an updatedInput after an allow, a continue: false over the sweep's block) and env reaches every hook command, whatever the key is called.
Two more things the wiring holds in place. init writes disableAllHooks: false into the project file, because the runtime reads that key from any settings file — the user file included, which no repository glob names and which a shell write reaches within seconds — with the project value overriding the user's; removing or flipping the declaration is a finding, and a file init wrote before 2.9.0 without it stays clean until the gate is written anew. And the version pin may only go up, and only as far as the gate judging the edit: a pin npm cannot resolve fails the hook open. The honest raise is re-running tamperward init from the newer version, which raises the pre-commit script, the workflow and the judging gate together.
Two findings that are not rules
Two ids can appear in a verdict without being keys under rules:. Neither can be disabled, lowered or excluded by policy, because each reports that the gate could not do its job — and a gate that cannot evaluate must deny, never pass.
| finding | where | meaning |
|---|---|---|
detector-error | every view — the PreToolUse hook, the Stop sweep, check --staged / --worktree / --diff | a detector threw on this change. Repository content that makes a rule crash used to remove that rule from the verdict; now it is a blocking finding naming the rule. Until 2.23.14 only the staged, worktree and range views carried it, so a spec that made test-skip overflow the parser was denied at pre-commit and allowed at PreToolUse and Stop (#444); the hook's deny channel carries it now. The crash itself is no longer reachable from content: every JS/TS parse goes through one guarded entry with a 4 MiB size ceiling and a 256-level bracket-nesting ceiling that declines (the rule's line-level matcher judges the file instead) rather than throwing. |
hidden-drift | the PreToolUse drift check and the Stop sweep | a protected file changed outside git's view — git update-index --skip-worktree / --assume-unchanged, or a gitignored protected file — and its last sanctioned content could not be reconstructed, so the change cannot be judged. When it can be reconstructed the ordinary rules judge it instead. Restore the file and bring it back into git's view. Also a protected path the gate cannot judge by content at all — a symbolic link (never followed: git records it as its target text, the runner reads what it points at), a FIFO, a socket, a device, a directory where a file is expected, a file above 64 MiB, or a read error — that is new or changed since the session began: blocked by name in the drift check, the Stop sweep and check --worktree. Put a regular file there, or remove it. |
The Stop sweep also scans untracked protected files since 2.2.0: a new conftest.py, a shadowing runner config, or a settings override the turn created is judged as the add it is, with its content read as an edit. Ignored protected files are judged the same way: a protected file created behind a .gitignore line, the agent-writable .git/info/exclude, or the global excludes (which carry .claude/settings.local.json) is in the tree the runner executes whatever git lists, so the PreToolUse drift check, the Stop sweep, and the run envelope's working-tree check all read it as an add — and a protected addition no git view can list at all is reconstructed from disk by the drift check rather than absorbed. A protected file that already existed, untracked or ignored, when the session began is not re-litigated; the turn tree that says so can only excuse a file the drift state also sanctioned. Untracked and ignored files outside the protected globs are not scanned; node_modules is never walked, and the drift check's walk does not enter a directory git reports as wholly ignored (a dist/ of 50k files costs one listing per call, not a stat per file) — the protected files git lists under it are snapshotted from that listing, and an ignored directory that also holds a tracked file is walked as before. Every disk read is git's view of the path: a symbolic link is its target text and is never followed, a regular file is read up to 64 MiB, and nothing else is content. A path carrying a control character (a newline in a directory name) is protected in every category, since no glob can match it and no honest path carries one; it is shown escaped in the verdict.
The outcome layer: tamperward verify
The one question no diff rule can answer is whether the original suite still passes against the current source. tamperward verify runs the suite twice, in two separate copies of the working tree: as-is (the visible run), and with every protected test, snapshot and config file restored from the trusted base — agent-added files on those surfaces removed — (the pristine run). Visible green with pristine red is a masked failure (exit 1); "could not verify" fails closed (exit 2). It is mechanical at the outcome level, so it is immune to spelling — it identified every masked failure in the round-1 trajectories while diff-time detection was routed around. It runs at commit and CI granularity, not per tool call, and the verify: block that configures it is itself a guarded surface. See Getting started.
assertion-weakening is now built but warning-only. Its first committed measurement lives in harness/fp-study/AW-CORPUS.md: 20 adjudicated legitimate assertion-touching negatives retained from the earlier 2,304-diff OSS study plus 12 mutation positives, with 12/12 precision among fires and 0/20 false positives. That measurement permits shipping the narrow predicate, not graduating it to block. guard-removal remains reserved and unbuilt until it has its own measured negatives corpus (SPEC §7.A).
Why snapshot-rewrite warns
It was swept over 1,652 real mainline commits (prettier, jest, docusaurus, immer): 216 touched snapshots, all legitimately. A rule that fires on a routine workflow cannot block — so it asks a human to confirm the new expectation instead. The full study, pre-registered, is committed under harness/fp-study/. Its narrow signal — the one commit in 1,652 that changed only snapshots — ships as the distinct snapshot-only-rewrite warn, so that when a policy version: graduates it to block, the broad rule stays warn and nobody is opted in to anything they didn't write down.
Why the hook-less commit paths warn
git commit-tree (with git update-ref to publish what it made), git am, git cherry-pick and git rebase write commits that the pre-commit hook never sees — none of them runs it, so each is a way to land a tree the hook would have refused. They are also how ordinary history work is done: a rebase onto main, a cherry-picked fix, a mailed patch, every one of them carrying commits the hook already checked when they were made. The 1,511 recorded agent commands in the harness corpus hold none of them either way, so there is no measurement that would let block refuse a rebase on evidence. no-verify therefore reports them at warn whatever the rule's severity — a review prompt naming the path, not a gate — while the literal --no-verify on any of them stays block. Resuming or abandoning an operation in progress (--continue, --abort, --skip, --quit) is not a new path and is clean, as is git cherry-pick --no-commit, which hands the change to git commit where the hook runs.
Rule graduations are opt-in
A baseline rule promoted warn → block at policy version N blocks only for policies declaring version: >= N — so graduations ship as minors and never turn an un-opted-in build red. An explicit severity: you wrote always wins, in either direction.