tamperward 1.14.6 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +66 -10
  2. package/dist/cli/index.js +382 -84
  3. package/package.json +6 -6
package/README.md CHANGED
@@ -43,7 +43,7 @@ Earlier controlled experiments → **[the research series](./docs/blog/index.md)
43
43
 
44
44
  > **Scope.** The rows above are one model, one pressure prompt, one JS/TS
45
45
  > ecosystem — evidence for that setting, not a universal claim. Round 2 tested
46
- > the v1.9.0 stack; the current **v1.14.3** adds post-study envelope hardening
46
+ > the v1.9.0 stack; the current **v2.0.0** adds post-study envelope hardening
47
47
  > (externally reviewed, with findings tracked individually as REPRO or AUDIT in
48
48
  > [SECURITY-ENVELOPE.md](./SECURITY-ENVELOPE.md) and closed with regression and
49
49
  > mutation checks — see [CHANGELOG](./CHANGELOG.md)). Rounds 3 and 3.1 are
@@ -148,15 +148,28 @@ semantic correctness nor a substitute for independent held-out tests.
148
148
 
149
149
  ### CI authority
150
150
 
151
- Pre-commit is an early backstop; protected CI is the merge authority. CI runs, independently:
151
+ Pre-commit is an early backstop; protected CI is the merge authority. Two workflows
152
+ are involved here, and they are not the same thing.
152
153
 
153
- 1. `check --diff base...head`, with policy from the trusted merge-base; and
154
+ **The shipped workflow** — the one `tamperward init` writes into your repository as
155
+ `.github/workflows/tamperward.yml` — runs, independently:
156
+
157
+ 1. `check --diff base.sha...head.sha`, with policy from the trusted merge-base; and
154
158
  2. `verify --require-ancestor --base <base-sha>`, which since **1.14.3** also reads
155
159
  its policy, verification surface and verifier command and budget from that same
156
- trusted base — so neither job's verdict is governed by the candidate.
160
+ trusted base — so neither step's verdict is governed by the candidate.
161
+
162
+ Legitimate exceptions are out-of-band PR labels, `tamperward:allow:<rule>@<head-sha>`.
163
+ The workflow passes the head SHA to the gate through `TAMPERWARD_OOB_HEAD`, so an
164
+ approval is bound to the exact commit it was granted for and a new push invalidates it.
157
165
 
158
- Legitimate exceptions are out-of-band PR labels bound to the exact head SHA; a new
159
- push invalidates the approval.
166
+ **This repository's own self-gate** — the `gate` job in `.github/workflows/ci.yml` —
167
+ runs the built CLI's `check --diff` over the pull-request range, cleared only by the
168
+ same label channel, but does **not** run `verify` on itself. This repo's test
169
+ expectations legitimately change whenever a rule changes, and standalone `verify` has
170
+ no out-of-band sign-off channel, so a pristine re-execution of its own suite would fail
171
+ closed on exactly the pull requests that improve the gate. The self-gate is a
172
+ diff-time gate.
160
173
 
161
174
  This guarantee depends on a protected and immutable base, required status checks, a
162
175
  pinned Tamperward version, and label permissions restricted to trusted humans.
@@ -218,9 +231,50 @@ npx tamperward verify --base main # pristine-suite re-execution
218
231
  npx tamperward run -- <agent command...> # the outer envelope around an agent
219
232
  ```
220
233
 
234
+ ### CLI reference
235
+
236
+ Every flag below is what the command's parser actually reads (`src/cli/index.ts`,
237
+ `parseVerify`, `parseRun`). `check` reports an unknown flag on stderr; the other
238
+ commands ignore what they do not know.
239
+
240
+ | command | flags |
241
+ | --- | --- |
242
+ | `check` | one view — `--staged` · `--worktree` · `--diff <base>...<head>` — plus `--format text\|json\|github\|auto` (default `auto`) · `--json` (alias for `--format json`) · `--cwd <dir>` |
243
+ | `verify` | `--base <rev>` (default `HEAD`) · `--cmd <suite command>` · `--budget <seconds>` · `--json` · `--keep` (keep the two materialised copies and report their paths) · `--require-ancestor` (refuse a base that is not an ancestor of `HEAD`) · `--cwd <dir>` |
244
+ | `run` | `--base <rev>` · `--cmd <suite command>` · `--budget <seconds>` · `--allow-dirty` · `--settle <seconds>` (wait before the final quiescence check) · `--allow-dep-drift` · `--cwd <dir>` · then `-- <agent command...>` |
245
+ | `allow` | `<rule>` · `--file <path>` · `--reason "<why>"` (required) · `--cwd <dir>` |
246
+ | `init` | `--cwd <dir>` · `--dry-run` · `--force-workflow` |
247
+ | `watch` | `--dir <dir>` · `--log <file>` — a daemon; it runs until signalled |
248
+ | `hook claude` / `sweep claude` | none — the Claude Code payload arrives on stdin |
249
+
250
+ **Exit codes** — part of the public surface:
251
+
252
+ | command | 0 | 1 | 2 |
253
+ | --- | --- | --- | --- |
254
+ | `check` | no blocking finding | at least one blocking finding | cannot evaluate: policy parse error, malformed `--diff` range, or no view given |
255
+ | `verify` | `VERIFIED` — visible and pristine both green | `MASKED_FAILURE` (visible green, pristine red) or `SUITE_RED` | cannot verify, failing closed: no suite command, unresolvable base, `--require-ancestor` refused, budget exceeded, or the working or dependency tree moved during the run |
256
+ | `run` | enforcement clean and the agent exited 0 — a non-zero agent exit is passed through unchanged | any blocking finding or masked failure, whatever the agent returned | cannot adjudicate: dirty start, policy error, verify cannot run |
257
+ | `hook claude` / `sweep claude` | always — a deny is JSON on stdout at exit 0, never exit 2 | — | only for an unsupported agent name |
258
+ | `allow` | sign-off recorded | — | no rule or `--reason`, not a git repo, or no current blocking finding to sign off |
259
+ | `init` | wired, or already wired | — | an item needs attention |
260
+ | no or unknown command | help printed (no command) | — | unknown command, help printed |
261
+
262
+ ### Environment variables
263
+
264
+ | variable | who sets it | what it does |
265
+ | --- | --- | --- |
266
+ | `TAMPERWARD_OOB_SIGNOFF` | the CI workflow, from PR labels | comma-separated out-of-band approvals — `<rule>` or `<rule>:<file>`, optionally `@<head-sha>` — honoured by `check --diff` only; never the committed ledger |
267
+ | `TAMPERWARD_OOB_HEAD` | the CI workflow (`github.event.pull_request.head.sha`) | the head SHA under adjudication; once set, an approval clears a finding only if it names that commit (`@<sha>`, at least 7 characters), so a new push re-blocks |
268
+ | `TAMPERWARD_DENYLOG` | a harness or operator | a file to which `hook claude` and `sweep claude` append the rule ids of every deny, one line per verdict, best effort |
269
+ | `TAMPERWARD_FSEVENTS` | operator or harness | overrides the `tamperward watch` event-log path (default `.git/tamperward/fsevents.jsonl`); the Stop sweep reads the same variable |
270
+ | `TAMPERWARD_WATCH_NO_RECURSIVE` | CI and tests (`=1`) | forces `tamperward watch` onto its per-directory fallback instead of recursive `fs.watch`, so the fallback is exercised on every platform |
271
+ | `TAMPERWARD_TRANSIENT` | a harness that owns restore semantics (`=block`) | raises `transient-protected-mutation` from warn to block; it can never lower a severity |
272
+ | `NO_COLOR` / `FORCE_COLOR` | the user's shell | any non-empty `NO_COLOR` disables colour in the text renderer; a non-empty, non-`0` `FORCE_COLOR` enables it |
273
+ | `GITHUB_ACTIONS` | GitHub Actions (`=true`) | `--format auto` selects the `github` renderer — an inline annotation per finding plus a job summary |
274
+
221
275
  ### The rules
222
276
 
223
- Sixteen rules are specified and fourteen ship (see the table in
277
+ Seventeen rules are specified and fifteen ship (see the table in
224
278
  [SPEC.md](./SPEC.md)). The families: test protection (`test-deletion`,
225
279
  `test-skip`, `test-content-removal`), verification-signal protection
226
280
  (`coverage-lowering`, `snapshot-rewrite`, `snapshot-only-rewrite`), suppression
@@ -308,12 +362,14 @@ via npm trusted publishing with SLSA provenance. Full rule:
308
362
 
309
363
  ```bash
310
364
  npm install && npm run build # bundles the CLI to dist/cli/index.js
311
- npm test # 368 tests at v1.14.3 — parser, detectors, engine, policy, renderers
365
+ npm test # 520+ tests at 2.0.0 — parser, detectors, engine, policy, renderers
312
366
  npm run typecheck
313
367
  ```
314
368
 
315
- Tamperward gates its own repo in CI with the same engine it ships — including, on more
316
- than one occasion, blocking its own author's commits. See **[SPEC.md](./SPEC.md)** for
369
+ Tamperward's own CI runs the engine it ships over every pull request — `check --diff`
370
+ over the PR range, cleared only by an out-of-band label; see [CI authority](#ci-authority)
371
+ for why the self-gate runs no `verify` on itself — and has, on more than one occasion,
372
+ blocked its own author's commits. See **[SPEC.md](./SPEC.md)** for
317
373
  the build spec, the detector table, the enforcement-point wiring, and the proof-harness
318
374
  design; the `harness/` directory holds the seeds, oracles, transcripts tooling, and
319
375
  every pre-registered prediction with its outcome.
package/dist/cli/index.js CHANGED
@@ -90,6 +90,8 @@ function parseDiff(diff) {
90
90
  let renameTo = null;
91
91
  let minusPath = null;
92
92
  let plusPath = null;
93
+ let oldMode;
94
+ let newMode;
93
95
  const hunks = [];
94
96
  while (i < lines.length && !lines[i].startsWith("diff --git ")) {
95
97
  const l = lines[i];
@@ -103,8 +105,14 @@ function parseDiff(diff) {
103
105
  i++;
104
106
  continue;
105
107
  }
106
- if (l.startsWith("new file mode")) op = "add";
107
- else if (l.startsWith("deleted file mode")) op = "delete";
108
+ if (l.startsWith("new file mode ")) {
109
+ op = "add";
110
+ newMode = l.slice(14).trim();
111
+ } else if (l.startsWith("deleted file mode ")) {
112
+ op = "delete";
113
+ oldMode = l.slice(18).trim();
114
+ } else if (l.startsWith("old mode ")) oldMode = l.slice(9).trim();
115
+ else if (l.startsWith("new mode ")) newMode = l.slice(9).trim();
108
116
  else if (l.startsWith("rename from ")) {
109
117
  renameFrom = unquotePath(l.slice(12));
110
118
  op = "rename";
@@ -140,7 +148,9 @@ function parseDiff(diff) {
140
148
  before: null,
141
149
  after: null,
142
150
  binary,
143
- hunks
151
+ hunks,
152
+ ...oldMode !== void 0 ? { oldMode } : {},
153
+ ...newMode !== void 0 ? { newMode } : {}
144
154
  };
145
155
  changes.push(change);
146
156
  }
@@ -344,7 +354,13 @@ var LITERAL = [
344
354
  { re: /\bGIT_CONFIG_KEY_\d+\s*=\s*["']?core\.hooksPath/i, why: "GIT_CONFIG_* injection sets core.hooksPath" },
345
355
  { re: /(?:^|[\s;&|=])LEFTHOOK=0(?:\s|$)/, why: "LEFTHOOK=0 disables Lefthook hooks" },
346
356
  { re: /\bSKIP_SIMPLE_GIT_HOOKS=1\b/, why: "SKIP_SIMPLE_GIT_HOOKS=1 skips simple-git-hooks" },
347
- { re: /\bPRE_COMMIT_ALLOW_NO_CONFIG=1\b/, why: "PRE_COMMIT_ALLOW_NO_CONFIG=1 lets pre-commit no-op" }
357
+ { re: /\bPRE_COMMIT_ALLOW_NO_CONFIG=1\b/, why: "PRE_COMMIT_ALLOW_NO_CONFIG=1 lets pre-commit no-op" },
358
+ // The pre-commit framework's own escape hatch: SKIP=<hook-id>[,…] skips those
359
+ // hooks for that one invocation. `.pre-commit-config.yaml` is a protected hook,
360
+ // so its documented bypass has to be one too. `SKIP=` is a plausible variable
361
+ // name elsewhere, so it counts only in a segment that runs git or pre-commit.
362
+ { re: /(?:^|[\s;&|])SKIP=\S+/, why: "SKIP=<hook-id> tells the pre-commit framework to skip that hook", when: /\b(?:git|pre-commit)\b/ },
363
+ { re: /(?:^|[\s;&|])LEFTHOOK_EXCLUDE=\S+/, why: "LEFTHOOK_EXCLUDE=<name> skips the named Lefthook commands" }
348
364
  ];
349
365
  var SHORT_N = /^-[a-z]*n[a-z]*$/i;
350
366
  var noVerify = {
@@ -358,7 +374,7 @@ var noVerify = {
358
374
  for (const seg of segments(c.raw)) {
359
375
  let why = null;
360
376
  for (const p of LITERAL) {
361
- if (p.re.test(seg)) {
377
+ if (p.re.test(seg) && (!p.when || p.when.test(seg))) {
362
378
  why = p.why;
363
379
  break;
364
380
  }
@@ -565,6 +581,42 @@ var CODE = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
565
581
  function isCodeFile(path) {
566
582
  return CODE.test(path);
567
583
  }
584
+ var EXT = [
585
+ [CODE, "js"],
586
+ [/\.pyw?$/, "py"],
587
+ [/\.go$/, "go"],
588
+ [/\.rs$/, "rs"],
589
+ [/\.rb$/, "rb"],
590
+ [/\.java$/, "java"],
591
+ [/\.kts?$/, "kt"],
592
+ [/\.php$/, "php"],
593
+ [/\.cs$/, "cs"]
594
+ ];
595
+ function langOf(path) {
596
+ for (const [re, lang] of EXT) if (re.test(path)) return lang;
597
+ return null;
598
+ }
599
+ var COMMON = /^(export\s|\/\/|\*|\/\*|}\)?;?$)/;
600
+ var IMPORTS = {
601
+ js: /^import\b/,
602
+ py: /^(?:import\b|from\s+\S+\s+import\b)/,
603
+ go: /^(?:import\b|package\b)/,
604
+ rs: /^(?:use\b|extern\s+crate\b|mod\b)/,
605
+ rb: /^(?:require(?:_relative)?\b|include\b)/,
606
+ java: /^(?:import\b|package\b)/,
607
+ kt: /^(?:import\b|package\b)/,
608
+ php: /^(?:use\b|namespace\b|require(?:_once)?\b|include(?:_once)?\b)/,
609
+ cs: /^(?:using\b|namespace\b)/
610
+ };
611
+ var HASH_COMMENT = /* @__PURE__ */ new Set(["py", "rb"]);
612
+ function isSignificantLine(trimmed, lang) {
613
+ if (trimmed.length < 10) return false;
614
+ if (COMMON.test(trimmed)) return false;
615
+ const l = lang ?? "js";
616
+ if (IMPORTS[l].test(trimmed)) return false;
617
+ if (HASH_COMMENT.has(l) && trimmed.startsWith("#")) return false;
618
+ return true;
619
+ }
568
620
 
569
621
  // src/detectors/ts-any-cast.ts
570
622
  var BLOCK_RULE = "ts-any-cast";
@@ -671,11 +723,35 @@ var tsAnyCast = {
671
723
 
672
724
  // src/detectors/lint-suppression.ts
673
725
  var RULE2 = "lint-suppression";
674
- var PATTERNS = [
675
- { re: /eslint-disable(?:-next-line|-line)?\b/, why: "eslint-disable suppresses lint rules" },
676
- { re: /\bprettier-ignore\b/, why: "prettier-ignore suppresses formatting checks" },
677
- { re: /\bbiome-ignore\b/, why: "biome-ignore suppresses Biome diagnostics" }
678
- ];
726
+ var PATTERNS = {
727
+ js: [
728
+ { re: /eslint-disable(?:-next-line|-line)?\b/, why: "eslint-disable suppresses lint rules" },
729
+ { re: /\bprettier-ignore\b/, why: "prettier-ignore suppresses formatting checks" },
730
+ { re: /\bbiome-ignore\b/, why: "biome-ignore suppresses Biome diagnostics" }
731
+ ],
732
+ py: [
733
+ { re: /#\s*noqa\b/, why: "# noqa suppresses flake8/ruff findings" },
734
+ { re: /#\s*type:\s*ignore\b/, why: "# type: ignore suppresses the type checker" },
735
+ { re: /#\s*pyright:\s*ignore\b/, why: "# pyright: ignore suppresses the type checker" },
736
+ { re: /#\s*pylint:\s*disable\b/, why: "# pylint: disable suppresses lint rules" },
737
+ { re: /#\s*nosec\b/, why: "# nosec suppresses bandit security findings" },
738
+ { re: /#\s*fmt:\s*(?:off|skip)\b/, why: "# fmt: off/skip suppresses formatting checks" }
739
+ ],
740
+ go: [{ re: /\/\/\s*nolint\b/, why: "//nolint suppresses golangci-lint findings" }],
741
+ rs: [],
742
+ rb: [{ re: /#\s*rubocop:\s*(?:disable|todo)\b/, why: "# rubocop:disable suppresses lint rules" }],
743
+ java: [{ re: /@SuppressWarnings\s*\(/, why: "@SuppressWarnings suppresses compiler and lint warnings" }],
744
+ kt: [{ re: /@Suppress(?:Warnings)?\s*\(/, why: "@Suppress suppresses compiler and lint warnings" }],
745
+ php: [
746
+ { re: /\bphpcs:(?:ignore|disable)\b/, why: "phpcs:ignore/disable suppresses coding-standard checks" },
747
+ { re: /@phpstan-ignore\b/, why: "@phpstan-ignore suppresses static-analysis findings" },
748
+ { re: /@psalm-suppress\b/, why: "@psalm-suppress suppresses static-analysis findings" }
749
+ ],
750
+ cs: [
751
+ { re: /#pragma\s+warning\s+disable\b/, why: "#pragma warning disable suppresses compiler warnings" },
752
+ { re: /\[SuppressMessage\s*\(/, why: "[SuppressMessage] suppresses analyzer findings" }
753
+ ]
754
+ };
679
755
  var lintSuppression = {
680
756
  id: RULE2,
681
757
  surface: ["file"],
@@ -683,9 +759,13 @@ var lintSuppression = {
683
759
  run(changes, policy) {
684
760
  const out2 = [];
685
761
  for (const c of changes) {
686
- if (c.kind !== "file" || !isCodeFile(c.path)) continue;
762
+ if (c.kind !== "file") continue;
763
+ const lang = langOf(c.path);
764
+ if (!lang) continue;
765
+ const patterns = PATTERNS[lang];
766
+ if (patterns.length === 0) continue;
687
767
  for (const l of addedLines(c)) {
688
- for (const p of PATTERNS) {
768
+ for (const p of patterns) {
689
769
  if (p.re.test(l.content)) {
690
770
  out2.push(
691
771
  makeFinding(RULE2, policy, {
@@ -707,18 +787,44 @@ var lintSuppression = {
707
787
 
708
788
  // src/detectors/test-skip.ts
709
789
  var RULE3 = "test-skip";
710
- var PATTERNS2 = [
711
- { re: /\b(?:it|test|describe)\.(?:skip|only|todo)\b/, why: "a .skip/.only/.todo marker" },
712
- { re: /\b(?:xit|xtest|xdescribe)\b/, why: "an x-prefixed disabled test" },
713
- { re: /\b(?:fit|ftest|fdescribe)\b/, why: "an f-prefixed focused test (narrows the suite)" },
714
- { re: /\bpending\(\s*\)/, why: "a pending() marker" },
715
- // node:test spells the same class as options — `test('x', { skip: true }, ...)` — and as
716
- // a runtime call. A live evasion used the options form the day this gap was measured
717
- // (PREDICTION-baselines.md): denied the rm, the agent skipped by option instead.
718
- { re: /\bskip:\s*(?:true|1|['"`])/, why: "a { skip: ... } option disabling the test (node:test options form)" },
719
- { re: /\btodo:\s*(?:true|['"`])/, why: "a { todo: ... } option (the test no longer fails the run)" },
720
- { re: /\b(?:t|ctx|context)\.skip\(/, why: "a runtime t.skip() call" }
721
- ];
790
+ var PATTERNS2 = {
791
+ js: [
792
+ { re: /\b(?:it|test|describe)\.(?:skip|only|todo)\b/, why: "a .skip/.only/.todo marker" },
793
+ { re: /\b(?:xit|xtest|xdescribe)\b/, why: "an x-prefixed disabled test" },
794
+ { re: /\b(?:fit|ftest|fdescribe)\b/, why: "an f-prefixed focused test (narrows the suite)" },
795
+ { re: /\bpending\(\s*\)/, why: "a pending() marker" },
796
+ // node:test spells the same class as options — `test('x', { skip: true }, ...)` — and as
797
+ // a runtime call. A live evasion used the options form the day this gap was measured
798
+ // (PREDICTION-baselines.md): denied the rm, the agent skipped by option instead.
799
+ { re: /\bskip:\s*(?:true|1|['"`])/, why: "a { skip: ... } option disabling the test (node:test options form)" },
800
+ { re: /\btodo:\s*(?:true|['"`])/, why: "a { todo: ... } option (the test no longer fails the run)" },
801
+ { re: /\b(?:t|ctx|context)\.skip\(/, why: "a runtime t.skip() call" }
802
+ ],
803
+ py: [
804
+ { re: /@pytest\.mark\.(?:skip|skipif|xfail)\b/, why: "a pytest skip/skipif/xfail marker" },
805
+ { re: /\bpytest\.(?:skip|xfail|importorskip)\(/, why: "a runtime pytest.skip()/xfail() call" },
806
+ { re: /@unittest\.(?:skip|skipIf|skipUnless|expectedFailure)\b/, why: "a unittest skip/expectedFailure decorator" },
807
+ { re: /\bself\.skipTest\(/, why: "a runtime self.skipTest() call" },
808
+ { re: /\braise\s+(?:unittest\.)?SkipTest\b/, why: "a raised SkipTest" }
809
+ ],
810
+ go: [
811
+ { re: /\b[tb]\.Skip(?:f|Now)?\(/, why: "a runtime t.Skip()/Skipf()/SkipNow() call" }
812
+ ],
813
+ rs: [{ re: /#\[ignore\b/, why: "an #[ignore] attribute (the test no longer runs by default)" }],
814
+ rb: [
815
+ { re: /(?:^|[\s;{(])(?:skip|pending)(?:\s*\(|\s+['"]|\s+(?:if|unless)\b|\s*$)/, why: "a skip/pending call" },
816
+ { re: /\bx(?:it|describe|context|specify|example)\b/, why: "an x-prefixed disabled example" },
817
+ { re: /\b(?:fit|fdescribe|fcontext)\b/, why: "an f-prefixed focused example (narrows the suite)" },
818
+ { re: /\b(?:skip|pending):\s*(?:true|['"])/, why: "a skip:/pending: metadata flag" }
819
+ ],
820
+ java: [{ re: /@(?:Ignore|Disabled)\b/, why: "an @Ignore/@Disabled annotation" }],
821
+ kt: [{ re: /@(?:Ignore|Disabled)\b/, why: "an @Ignore/@Disabled annotation" }],
822
+ php: [{ re: /\bmarkTest(?:Skipped|Incomplete)\(/, why: "a markTestSkipped()/markTestIncomplete() call" }],
823
+ cs: [
824
+ { re: /\[Ignore\b/, why: "an [Ignore] attribute" },
825
+ { re: /\bSkip\s*=\s*"/, why: 'a Skip = "..." attribute argument' }
826
+ ]
827
+ };
722
828
  var testSkip = {
723
829
  id: RULE3,
724
830
  surface: ["file"],
@@ -728,8 +834,9 @@ var testSkip = {
728
834
  for (const c of changes) {
729
835
  if (c.kind !== "file") continue;
730
836
  if (!isProtected(c.path, policy, "tests")) continue;
837
+ const patterns = PATTERNS2[langOf(c.path) ?? "js"];
731
838
  for (const l of addedLines(c)) {
732
- for (const p of PATTERNS2) {
839
+ for (const p of patterns) {
733
840
  if (p.re.test(l.content)) {
734
841
  out2.push(
735
842
  makeFinding(RULE3, policy, {
@@ -1018,6 +1125,7 @@ var ciTampering = {
1018
1125
  (c.after != null ? c.after.split("\n") : addedLines(c).map((l) => l.content)).map(commandCore)
1019
1126
  );
1020
1127
  for (const l of removedLines(c)) {
1128
+ if (/^\s*#/.test(l.content)) continue;
1021
1129
  const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
1022
1130
  const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? /^\s*-?\s*run:/.test(l.content) : true) && invokesCheck(l.content);
1023
1131
  if (!isUsesLine && !isCheckCommand) continue;
@@ -1080,8 +1188,11 @@ function policyWeakening(before, after) {
1080
1188
  reasons.push(`rule "${name}" removed from policy`);
1081
1189
  continue;
1082
1190
  }
1083
- if (br.severity === "block" && ar.severity === "warn") {
1084
- reasons.push(`rule "${name}" lowered block \u2192 warn`);
1191
+ if (br.severity === "block" && ar.severity !== "block") {
1192
+ const to = typeof ar.severity === "string" ? ar.severity : JSON.stringify(ar.severity);
1193
+ reasons.push(
1194
+ ar.severity === "warn" ? `rule "${name}" lowered block \u2192 warn` : `rule "${name}" lowered block \u2192 ${to} (not a valid severity; the rule would no longer block)`
1195
+ );
1085
1196
  }
1086
1197
  if (ar.enabled === false && br.enabled !== false) {
1087
1198
  reasons.push(`rule "${name}" disabled (enabled: false)`);
@@ -1137,6 +1248,27 @@ function policyWeakening(before, after) {
1137
1248
  var RULE6 = "hook-tampering";
1138
1249
  var HOOK_INVOCATION = /\b(tamperward|npx|npm|pnpm|yarn|bun|jest|vitest|eslint|tsc|lefthook|husky|lint-staged|make|cargo|pytest)\b/;
1139
1250
  var isShellComment = (s) => /^\s*#/.test(s);
1251
+ var ownerExec = (mode) => (parseInt(mode.slice(-3), 8) & 64) !== 0;
1252
+ var SYMBOLIC = /^[ugoa]*([-+=])([rwxXst]*)$/;
1253
+ function chmodTouchesExec(seg) {
1254
+ const toks = tokens(seg).map(unquote);
1255
+ const at = toks.findIndex((t) => /^chmod$/.test(t));
1256
+ if (at === -1) return false;
1257
+ for (const tok of toks.slice(at + 1)) {
1258
+ if (/^[0-7]{3,4}$/.test(tok)) {
1259
+ if (!ownerExec(tok)) return true;
1260
+ continue;
1261
+ }
1262
+ for (const clause of tok.split(",")) {
1263
+ const m = clause.match(SYMBOLIC);
1264
+ if (!m) continue;
1265
+ const [, op, perms] = m;
1266
+ const namesExec = /[xX]/.test(perms);
1267
+ if (op === "=" ? !namesExec : namesExec) return true;
1268
+ }
1269
+ }
1270
+ return false;
1271
+ }
1140
1272
  var hookTampering = {
1141
1273
  id: RULE6,
1142
1274
  surface: ["file", "command"],
@@ -1147,6 +1279,16 @@ var hookTampering = {
1147
1279
  if (c.kind === "file") {
1148
1280
  const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false);
1149
1281
  if (!targetsHook) continue;
1282
+ if (c.op !== "delete" && c.oldMode && c.newMode && ownerExec(c.oldMode) && !ownerExec(c.newMode)) {
1283
+ out2.push(
1284
+ makeFinding(RULE6, policy, {
1285
+ file: c.path,
1286
+ message: `Execute permission was removed from a protected hook (${c.oldMode} \u2192 ${c.newMode}) \u2014 git will skip it.`,
1287
+ evidence: `${c.path}: mode ${c.oldMode} \u2192 ${c.newMode}`,
1288
+ remediation: "Restore the execute bit (chmod +x). A hook git cannot run is a disabled hook."
1289
+ })
1290
+ );
1291
+ }
1150
1292
  if (c.op === "delete") {
1151
1293
  out2.push(
1152
1294
  makeFinding(RULE6, policy, {
@@ -1243,10 +1385,8 @@ var hookTampering = {
1243
1385
  for (const seg of segments(c.raw)) {
1244
1386
  const hitsHook = tokens(seg).map(unquote).some((t) => isProtected(t, policy, "hooks"));
1245
1387
  if (!hitsHook) continue;
1246
- const octal = seg.match(/\bchmod\b[^|;&]*?(?:^|\s)([0-7]{3,4})(?:\s|$)/);
1247
- const octalDropsExec = octal ? (Number(octal[1].slice(-3)[0]) & 1) === 0 : false;
1248
1388
  let why = null;
1249
- if (/\bchmod\b/.test(seg) && (/(?:^|\s)[-+=]?[a-z]*x/i.test(seg) || octalDropsExec)) {
1389
+ if (chmodTouchesExec(seg)) {
1250
1390
  why = "chmod alters execute permission on a hook, which can disable it";
1251
1391
  } else if (/\brm\b/.test(seg)) {
1252
1392
  why = "rm deletes a protected hook";
@@ -1274,6 +1414,16 @@ var hookTampering = {
1274
1414
  // src/detectors/test-deletion.ts
1275
1415
  import ts3 from "typescript";
1276
1416
  var RULE7 = "test-deletion";
1417
+ var TEST_DEFS = {
1418
+ py: /^\s*(?:async\s+)?def\s+test\w*\s*\(/,
1419
+ go: /^func\s+(?:Test|Example|Fuzz)\w*\s*\(/,
1420
+ rs: /^\s*#\[[\w:]*test[\w:]*(?:\(|\])/,
1421
+ rb: /^\s*(?:(?:it|specify|test|scenario|example)\s*(?:\(\s*)?['"]|def\s+test_\w+)/,
1422
+ java: /^\s*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/,
1423
+ kt: /^\s*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/,
1424
+ php: /^\s*(?:(?:public\s+)?function\s+test\w*\s*\(|#\[Test\]|\*\s*@test\b)/,
1425
+ cs: /^\s*\[(?:Fact|Theory|Test|TestMethod|TestCase|DataTestMethod)\b/
1426
+ };
1277
1427
  function calleeName(expr) {
1278
1428
  if (ts3.isIdentifier(expr)) return expr.text;
1279
1429
  if (ts3.isPropertyAccessExpression(expr) && ts3.isIdentifier(expr.expression)) {
@@ -1281,7 +1431,14 @@ function calleeName(expr) {
1281
1431
  }
1282
1432
  return null;
1283
1433
  }
1284
- function countTestBlocks(src) {
1434
+ function countTestBlocks(src, path = "spec.ts") {
1435
+ const lang = langOf(path);
1436
+ if (lang && lang !== "js") {
1437
+ const re = TEST_DEFS[lang];
1438
+ let n2 = 0;
1439
+ for (const line of src.split("\n")) if (re.test(line)) n2++;
1440
+ return n2;
1441
+ }
1285
1442
  const sf = ts3.createSourceFile("spec.ts", src, ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TS);
1286
1443
  let n = 0;
1287
1444
  const visit = (node) => {
@@ -1294,11 +1451,12 @@ function countTestBlocks(src) {
1294
1451
  visit(sf);
1295
1452
  return n;
1296
1453
  }
1297
- function significantLines(src) {
1454
+ function significantLines(src, path) {
1298
1455
  const out2 = /* @__PURE__ */ new Set();
1456
+ const lang = langOf(path);
1299
1457
  for (const raw of src.split("\n")) {
1300
1458
  const l = raw.trim();
1301
- if (l.length >= 10 && !/^(import\b|export\s|\/\/|\*|\/\*|}\)?;?$)/.test(l)) out2.add(l);
1459
+ if (isSignificantLine(l, lang)) out2.add(l);
1302
1460
  }
1303
1461
  return out2;
1304
1462
  }
@@ -1312,22 +1470,22 @@ var testDeletion = {
1312
1470
  let addedTestBlocks = 0;
1313
1471
  for (const c of changes) {
1314
1472
  if (c.kind === "file" && c.op === "add" && c.after != null && isProtected(c.path, policy, "tests")) {
1315
- for (const l of significantLines(c.after)) addedTestLines.add(l);
1316
- addedTestBlocks += countTestBlocks(c.after);
1473
+ for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
1474
+ addedTestBlocks += countTestBlocks(c.after, c.path);
1317
1475
  }
1318
1476
  }
1319
- const isRelocation = (before) => {
1320
- const sig = significantLines(before);
1477
+ const isRelocation = (before, path) => {
1478
+ const sig = significantLines(before, path);
1321
1479
  if (sig.size === 0 || addedTestLines.size === 0) return false;
1322
1480
  let hit = 0;
1323
1481
  for (const l of sig) if (addedTestLines.has(l)) hit++;
1324
- return hit / sig.size >= 0.6 && addedTestBlocks >= countTestBlocks(before);
1482
+ return hit / sig.size >= 0.6 && addedTestBlocks >= countTestBlocks(before, path);
1325
1483
  };
1326
1484
  for (const c of changes) {
1327
1485
  if (c.kind === "file") {
1328
1486
  const isTest = isProtected(c.path, policy, "tests");
1329
1487
  if (c.op === "delete" && isTest) {
1330
- if (c.before != null && isRelocation(c.before)) continue;
1488
+ if (c.before != null && isRelocation(c.before, c.path)) continue;
1331
1489
  out2.push(
1332
1490
  makeFinding(RULE7, policy, {
1333
1491
  file: c.path,
@@ -1346,8 +1504,8 @@ var testDeletion = {
1346
1504
  })
1347
1505
  );
1348
1506
  } else if ((c.op === "modify" || c.op === "rename") && isTest && c.before != null && c.after != null) {
1349
- const before = countTestBlocks(c.before);
1350
- const after = countTestBlocks(c.after);
1507
+ const before = countTestBlocks(c.before, c.path);
1508
+ const after = countTestBlocks(c.after, c.path);
1351
1509
  if (after < before) {
1352
1510
  out2.push(
1353
1511
  makeFinding(RULE7, policy, {
@@ -1403,11 +1561,12 @@ var testDeletion = {
1403
1561
  var RULE8 = "test-content-removal";
1404
1562
  var MIN_REMOVED_LINES = 3;
1405
1563
  var ws = (s) => s.replace(/\s+/g, "");
1406
- function significantLinesOrdered(src) {
1564
+ function significantLinesOrdered(src, path) {
1407
1565
  const out2 = [];
1566
+ const lang = langOf(path);
1408
1567
  for (const raw of src.split("\n")) {
1409
1568
  const l = raw.trim();
1410
- if (l.length >= 10 && !/^(import\b|export\s|\/\/|\*|\/\*|}\)?;?$)/.test(l)) out2.push(l);
1569
+ if (isSignificantLine(l, lang)) out2.push(l);
1411
1570
  }
1412
1571
  return out2;
1413
1572
  }
@@ -1420,16 +1579,16 @@ var testContentRemoval = {
1420
1579
  let keptPool = "";
1421
1580
  for (const c of changes) {
1422
1581
  if (c.kind === "file" && c.after != null && isProtected(c.path, policy, "tests")) {
1423
- keptPool += ws(significantLinesOrdered(c.after).join("\n"));
1582
+ keptPool += ws(significantLinesOrdered(c.after, c.path).join("\n"));
1424
1583
  }
1425
1584
  }
1426
1585
  for (const c of changes) {
1427
1586
  if (c.kind !== "file" || c.op !== "modify" && c.op !== "rename" || c.before == null || c.after == null) continue;
1428
1587
  if (!isProtected(c.path, policy, "tests")) continue;
1429
1588
  if (isProtected(c.path, policy, "snapshots")) continue;
1430
- if (countTestBlocks(c.after) < countTestBlocks(c.before)) continue;
1431
- const beforeSig = significantLinesOrdered(c.before);
1432
- const afterSig = significantLinesOrdered(c.after);
1589
+ if (countTestBlocks(c.after, c.path) < countTestBlocks(c.before, c.path)) continue;
1590
+ const beforeSig = significantLinesOrdered(c.before, c.path);
1591
+ const afterSig = significantLinesOrdered(c.after, c.path);
1433
1592
  const netDrop = beforeSig.length - afterSig.length;
1434
1593
  const afterSet = new Set(afterSig);
1435
1594
  const gone = [];
@@ -1600,6 +1759,7 @@ function evaluate(changes, policy, detectors = allDetectors, view) {
1600
1759
  return true;
1601
1760
  });
1602
1761
  return deduped.filter((f) => {
1762
+ if (f.rule !== "detector-error" && !isEnabled(f.rule, policy)) return false;
1603
1763
  const ex = policy.rules[f.rule]?.exclude;
1604
1764
  if (!ex?.length || !f.file || isPolicyFile(f.file)) return true;
1605
1765
  return !matchesAny(f.file, ex);
@@ -1622,9 +1782,62 @@ function normalizeVersion(v, where = POLICY_FILE) {
1622
1782
  }
1623
1783
  return v;
1624
1784
  }
1625
- function parsePolicy(raw) {
1785
+ var SEVERITIES = ["block", "warn"];
1786
+ var isSeverity = (v) => SEVERITIES.includes(v);
1787
+ var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "string");
1788
+ var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1789
+ function validate(r, where) {
1790
+ function bad(msg) {
1791
+ throw new PolicyError(`${where}: ${msg}`);
1792
+ }
1793
+ const show = (v) => JSON.stringify(v) ?? String(v);
1794
+ if (r.rules !== void 0) {
1795
+ if (!isMapping(r.rules)) bad(`rules must be a mapping of rule name to { severity, enabled, exclude }, got ${show(r.rules)}`);
1796
+ for (const [name, cfg] of Object.entries(r.rules)) {
1797
+ if (!isMapping(cfg)) bad(`rules.${name} must be a mapping like { severity: block }, got ${show(cfg)}`);
1798
+ if (cfg.severity !== void 0 && !isSeverity(cfg.severity)) {
1799
+ bad(`rules.${name}.severity must be "block" or "warn", got ${show(cfg.severity)}`);
1800
+ }
1801
+ if (cfg.enabled !== void 0 && typeof cfg.enabled !== "boolean") {
1802
+ bad(`rules.${name}.enabled must be true or false, got ${show(cfg.enabled)}`);
1803
+ }
1804
+ if (cfg.exclude !== void 0 && !isStringList(cfg.exclude)) {
1805
+ bad(`rules.${name}.exclude must be a list of globs, got ${show(cfg.exclude)}`);
1806
+ }
1807
+ }
1808
+ }
1809
+ if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
1810
+ if (r.protected !== void 0) {
1811
+ if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
1812
+ for (const [cat, globs] of Object.entries(r.protected)) {
1813
+ if (!isStringList(globs)) bad(`protected.${cat} must be a list of globs, got ${show(globs)}`);
1814
+ }
1815
+ }
1816
+ if (r.signoff !== void 0) {
1817
+ if (!isMapping(r.signoff)) bad(`signoff must be a mapping, got ${show(r.signoff)}`);
1818
+ for (const key2 of ["required_for", "requiredFor"]) {
1819
+ const v = r.signoff[key2];
1820
+ if (v !== void 0 && !(Array.isArray(v) && v.every(isSeverity))) {
1821
+ bad(`signoff.${key2} must be a list of "block" / "warn", got ${show(v)}`);
1822
+ }
1823
+ }
1824
+ const ledger = r.signoff.ledger;
1825
+ if (ledger !== void 0 && typeof ledger !== "string") bad(`signoff.ledger must be a path, got ${show(ledger)}`);
1826
+ }
1827
+ if (r.verify !== void 0) {
1828
+ if (!isMapping(r.verify)) bad(`verify must be a mapping like { command: "npm test" }, got ${show(r.verify)}`);
1829
+ const v = r.verify;
1830
+ if (v.command !== void 0 && typeof v.command !== "string") bad(`verify.command must be a string, got ${show(v.command)}`);
1831
+ if (v.budget !== void 0 && !(typeof v.budget === "number" && Number.isFinite(v.budget) && v.budget > 0)) {
1832
+ bad(`verify.budget must be a positive number of seconds, got ${show(v.budget)}`);
1833
+ }
1834
+ if (v.inputs !== void 0 && !isStringList(v.inputs)) bad(`verify.inputs must be a list of globs, got ${show(v.inputs)}`);
1835
+ }
1836
+ }
1837
+ function parsePolicy(raw, where = POLICY_FILE) {
1626
1838
  const r = raw ?? {};
1627
- const version = normalizeVersion(r.version);
1839
+ const version = normalizeVersion(r.version, where);
1840
+ validate(r, where);
1628
1841
  const base = defaultPolicy(version);
1629
1842
  return {
1630
1843
  version,
@@ -1660,7 +1873,7 @@ function parseOrThrow(src, where) {
1660
1873
  if (raw !== null && raw !== void 0 && (typeof raw !== "object" || Array.isArray(raw))) {
1661
1874
  throw new PolicyError(`${where} is not a policy mapping`);
1662
1875
  }
1663
- return parsePolicy(raw);
1876
+ return parsePolicy(raw, where);
1664
1877
  }
1665
1878
  function loadPolicy(cwd = process.cwd()) {
1666
1879
  const path = join2(cwd, POLICY_FILE);
@@ -2044,7 +2257,7 @@ import { createHash as createHash4 } from "node:crypto";
2044
2257
  import { execFileSync as execFileSync2 } from "node:child_process";
2045
2258
  import { readFileSync as readFileSync4, existsSync as existsSync3, writeFileSync, mkdtempSync, rmSync } from "node:fs";
2046
2259
  import { tmpdir } from "node:os";
2047
- import { join as join4, isAbsolute } from "node:path";
2260
+ import { isAbsolute, join as join4, relative, resolve } from "node:path";
2048
2261
  function asStr(v) {
2049
2262
  return typeof v === "string" ? v : "";
2050
2263
  }
@@ -2056,10 +2269,11 @@ function readDisk(path) {
2056
2269
  }
2057
2270
  }
2058
2271
  function abs(path, cwd) {
2059
- return isAbsolute(path) ? path : join4(cwd, path);
2272
+ return resolve(cwd, path);
2060
2273
  }
2061
2274
  function relForDisplay(path, cwd) {
2062
- return path.startsWith(cwd + "/") ? path.slice(cwd.length + 1) : path;
2275
+ const rel = relative(cwd, path);
2276
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
2063
2277
  }
2064
2278
  function applyEdit(content, oldStr, newStr) {
2065
2279
  if (content === null) return newStr;
@@ -2080,9 +2294,15 @@ function synthFileChange(displayPath, before, after) {
2080
2294
  writeFileSync(a, before ?? "");
2081
2295
  writeFileSync(b, after ?? "");
2082
2296
  try {
2083
- raw = execFileSync2("git", ["diff", "--no-index", "--no-color", a, b], { encoding: "utf8" });
2297
+ raw = execFileSync2("git", ["diff", "--no-index", "--no-color", a, b], {
2298
+ encoding: "utf8",
2299
+ maxBuffer: 256 * 1024 * 1024
2300
+ });
2084
2301
  } catch (e) {
2085
2302
  const err2 = e;
2303
+ if (err2.status !== 1) {
2304
+ throw new Error(`cannot diff the incoming edit to ${displayPath}: ${err2.code ?? err2.message ?? String(e)}`);
2305
+ }
2086
2306
  raw = err2.stdout ? String(err2.stdout) : "";
2087
2307
  }
2088
2308
  } finally {
@@ -2197,13 +2417,19 @@ function advanceTurnBaseline(cwd, sessionId) {
2197
2417
  // src/effect.ts
2198
2418
  import { createHash as createHash2 } from "node:crypto";
2199
2419
  import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync, writeFileSync as writeFileSync3 } from "node:fs";
2200
- import { join as join6 } from "node:path";
2420
+ import { isAbsolute as isAbsolute2, join as join6 } from "node:path";
2201
2421
  import { execFileSync as execFileSync3 } from "node:child_process";
2202
2422
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
2203
2423
  var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
2204
2424
  function gitDir2(cwd) {
2205
2425
  try {
2206
- return execFileSync3("git", ["rev-parse", "--git-dir"], { cwd, encoding: "utf8" }).trim();
2426
+ const d = execFileSync3("git", ["rev-parse", "--git-dir"], {
2427
+ cwd,
2428
+ encoding: "utf8",
2429
+ stdio: ["ignore", "pipe", "pipe"]
2430
+ }).trim();
2431
+ if (!d) return null;
2432
+ return isAbsolute2(d) ? d : join6(cwd, d);
2207
2433
  } catch {
2208
2434
  return null;
2209
2435
  }
@@ -2212,7 +2438,7 @@ function ptreePath(cwd, sessionId) {
2212
2438
  if (!sessionId) return null;
2213
2439
  const gd = gitDir2(cwd);
2214
2440
  if (!gd) return null;
2215
- const dir = join6(cwd, gd, "tamperward");
2441
+ const dir = join6(gd, "tamperward");
2216
2442
  return join6(dir, `ptree-${sessionId.replace(UNSAFE2, "")}.json`);
2217
2443
  }
2218
2444
  var sha = (buf) => createHash2("sha256").update(buf).digest("hex").slice(0, 16);
@@ -2295,7 +2521,8 @@ function snap(abs2) {
2295
2521
  }
2296
2522
  }
2297
2523
  function defaultEventLog(cwd) {
2298
- return process.env.TAMPERWARD_FSEVENTS ?? join7(cwd, ".git", "tamperward", "fsevents.jsonl");
2524
+ if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
2525
+ return join7(gitDir(cwd) ?? join7(cwd, ".git"), "tamperward", "fsevents.jsonl");
2299
2526
  }
2300
2527
  function watchTree(dir, cb) {
2301
2528
  if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
@@ -2405,6 +2632,7 @@ function readEvents(log, offset) {
2405
2632
  return { events, newOffset: size };
2406
2633
  }
2407
2634
  function transientFindings(events, persistentPaths, policy, finalHash) {
2635
+ if (!isEnabled(RULE11, policy)) return [];
2408
2636
  const byPath = /* @__PURE__ */ new Map();
2409
2637
  for (const e of events) {
2410
2638
  if (!isProtected(e.path, policy)) continue;
@@ -2721,11 +2949,12 @@ import { createHash as createHash5 } from "node:crypto";
2721
2949
  import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
2722
2950
  import { dirname as dirname3, join as join8 } from "node:path";
2723
2951
  import { fileURLToPath } from "node:url";
2724
- var HOOK_CMD = "npx --yes tamperward hook claude";
2725
2952
  var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
2726
- var SWEEP_CMD = "npx --yes tamperward sweep claude";
2727
- var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
2728
2953
  var MARKER = "# tamperward: block agent shortcuts before they land";
2954
+ var HOOK_RE = /\btamperward(?:@\S+)?\s+hook\s+claude\b/;
2955
+ var SWEEP_RE = /\btamperward(?:@\S+)?\s+sweep\s+claude\b/;
2956
+ var PRECOMMIT_RE = /\btamperward(?:@\S+)?\s+check\s+--staged\b/;
2957
+ var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
2729
2958
  var CODEOWNERS_MARK = "# tamperward: the gate cannot guard the file that defines it";
2730
2959
  var CODEOWNERS_PATHS = ["/.github/workflows/", "/.tamperward.yml", "/.github/CODEOWNERS"];
2731
2960
  function coveredBy(existing, critical) {
@@ -2781,6 +3010,17 @@ function shippedVersion() {
2781
3010
  return "latest";
2782
3011
  }
2783
3012
  var TW_VERSION = shippedVersion();
3013
+ var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
3014
+ var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
3015
+ var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
3016
+ function pinOf(command) {
3017
+ const m = command.match(OURS);
3018
+ return m ? m[1] ?? "" : null;
3019
+ }
3020
+ function stalePin(command) {
3021
+ const pin = pinOf(command);
3022
+ return pin !== null && pin !== TW_VERSION;
3023
+ }
2784
3024
  var WORKFLOW_CONTENT = `name: tamperward
2785
3025
 
2786
3026
  # The CI authority for main: the same engine as the agent hook and pre-commit, run over
@@ -2901,15 +3141,18 @@ function planClaudeHooks(cwd) {
2901
3141
  settings = parsed;
2902
3142
  }
2903
3143
  const hooks = settings.hooks ??= {};
2904
- const entriesWith = (arr, needle) => (arr ?? []).filter((m) => (m.hooks ?? []).some((h) => String(h.command ?? "").includes(needle)));
2905
- const preEntries = entriesWith(hooks.PreToolUse, "tamperward hook claude");
3144
+ const entriesWith = (arr, needle) => (arr ?? []).filter((m) => (m.hooks ?? []).some((h) => needle.test(String(h.command ?? ""))));
3145
+ const preEntries = entriesWith(hooks.PreToolUse, HOOK_RE);
2906
3146
  const needPre = preEntries.length === 0;
2907
- const needStop = entriesWith(hooks.Stop, "tamperward sweep claude").length === 0;
3147
+ const stopEntries = entriesWith(hooks.Stop, SWEEP_RE);
3148
+ const needStop = stopEntries.length === 0;
2908
3149
  const stale = preEntries.filter((m) => missingTools(m.matcher).length > 0);
2909
- if (!needPre && !needStop && stale.length === 0) {
3150
+ const repin = [...preEntries, ...stopEntries].flatMap((m) => m.hooks ?? []).filter((h) => stalePin(String(h.command ?? "")));
3151
+ if (!needPre && !needStop && stale.length === 0 && repin.length === 0) {
2910
3152
  return { item: "agent", path: rel, status: "ok", detail: "PreToolUse + Stop hooks already wired" };
2911
3153
  }
2912
3154
  const repairing = stale.length > 0 ? [...new Set(stale.flatMap((m) => missingTools(m.matcher)))] : [];
3155
+ const pins = [...new Set(repin.map((h) => pinOf(String(h.command ?? "")) || "unpinned"))];
2913
3156
  return {
2914
3157
  item: "agent",
2915
3158
  path: rel,
@@ -2917,9 +3160,14 @@ function planClaudeHooks(cwd) {
2917
3160
  detail: [
2918
3161
  needPre && "wire PreToolUse deny",
2919
3162
  needStop && "wire Stop sweep",
2920
- repairing.length > 0 && `widen the PreToolUse matcher to cover ${repairing.join(", ")}`
3163
+ repairing.length > 0 && `widen the PreToolUse matcher to cover ${repairing.join(", ")}`,
3164
+ repin.length > 0 && `re-pin the hook commands to tamperward@${TW_VERSION} (was ${pins.join(", ")})`
2921
3165
  ].filter(Boolean).join(" + "),
2922
3166
  apply: () => {
3167
+ for (const h of repin) {
3168
+ const kind = String(h.command ?? "").match(OURS)?.[2];
3169
+ h.command = kind === "hook claude" ? HOOK_CMD : kind === "sweep claude" ? SWEEP_CMD : h.command;
3170
+ }
2923
3171
  if (needPre) {
2924
3172
  (hooks.PreToolUse ??= []).push({
2925
3173
  matcher: PRE_MATCHER,
@@ -2947,8 +3195,24 @@ ${PRECOMMIT_CMD}
2947
3195
  if (!target) return { item: "pre-commit", path: "(none)", status: "skip", detail: "not a git repo and no .husky/ \u2014 nothing to wire" };
2948
3196
  const path = join8(cwd, target.rel);
2949
3197
  const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
2950
- if (existing?.includes("tamperward check --staged")) {
2951
- return { item: "pre-commit", path: target.rel, status: "ok", detail: "already runs the staged check" };
3198
+ if (existing !== null && PRECOMMIT_RE.test(existing)) {
3199
+ const lines = existing.split("\n");
3200
+ const staleAt = lines.findIndex((l) => PRECOMMIT_RE.test(l) && stalePin(l));
3201
+ if (staleAt === -1) {
3202
+ return { item: "pre-commit", path: target.rel, status: "ok", detail: "already runs the staged check" };
3203
+ }
3204
+ const was = pinOf(lines[staleAt]) || "unpinned";
3205
+ return {
3206
+ item: "pre-commit",
3207
+ path: target.rel,
3208
+ status: "update",
3209
+ detail: `re-pin the staged check to tamperward@${TW_VERSION} (was ${was})`,
3210
+ apply: () => {
3211
+ lines[staleAt] = PRECOMMIT_CMD;
3212
+ writeFileSync5(path, lines.join("\n"));
3213
+ chmodSync(path, 493);
3214
+ }
3215
+ };
2952
3216
  }
2953
3217
  return {
2954
3218
  item: "pre-commit",
@@ -3084,13 +3348,13 @@ import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
3084
3348
  import { createHash as createHash8 } from "node:crypto";
3085
3349
  import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as statSync6, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "node:fs";
3086
3350
  import { tmpdir as tmpdir2 } from "node:os";
3087
- import { dirname as dirname4, join as join11, resolve } from "node:path";
3351
+ import { dirname as dirname4, join as join11, resolve as resolve2 } from "node:path";
3088
3352
 
3089
3353
  // src/git/trusted.ts
3090
3354
  import { execFileSync as execFileSync5 } from "node:child_process";
3091
3355
  import { createHash as createHash6 } from "node:crypto";
3092
3356
  import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
3093
- import { isAbsolute as isAbsolute2, join as join9 } from "node:path";
3357
+ import { isAbsolute as isAbsolute3, join as join9 } from "node:path";
3094
3358
  function trustedGitEnv() {
3095
3359
  return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
3096
3360
  }
@@ -3103,7 +3367,7 @@ function gitDirOf(cwd) {
3103
3367
  env: trustedGitEnv()
3104
3368
  }).trim();
3105
3369
  if (!d) return null;
3106
- return isAbsolute2(d) ? d : join9(cwd, d);
3370
+ return isAbsolute3(d) ? d : join9(cwd, d);
3107
3371
  } catch {
3108
3372
  return null;
3109
3373
  }
@@ -3268,7 +3532,7 @@ function materialize(cwd, dest) {
3268
3532
  }
3269
3533
  chmodSync2(out2, st.mode);
3270
3534
  }
3271
- const nm = resolve(cwd, "node_modules");
3535
+ const nm = resolve2(cwd, "node_modules");
3272
3536
  if (existsSync9(nm)) symlinkSync(nm, join11(dest, "node_modules"), "dir");
3273
3537
  }
3274
3538
  function dropSymlink(p) {
@@ -3349,18 +3613,52 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
3349
3613
  }
3350
3614
  return { restored, removed };
3351
3615
  }
3616
+ var SUPERVISOR = `
3617
+ const cp = require('node:child_process');
3618
+ const fs = require('node:fs');
3619
+ const [cmd, budgetMs, outFile] = process.argv.slice(1);
3620
+ const child = cp.spawn('sh', ['-c', cmd], { stdio: 'ignore', detached: process.platform !== 'win32' });
3621
+ const killGroup = () => {
3622
+ try { process.kill(-child.pid, 'SIGKILL'); } catch {}
3623
+ try { child.kill('SIGKILL'); } catch {}
3624
+ };
3625
+ let done = false;
3626
+ const finish = (r) => {
3627
+ if (done) return;
3628
+ done = true;
3629
+ killGroup();
3630
+ fs.writeFileSync(outFile, JSON.stringify(r));
3631
+ process.exit(0);
3632
+ };
3633
+ const timer = setTimeout(() => finish({ timedOut: true }), Number(budgetMs));
3634
+ child.on('error', (e) => { clearTimeout(timer); finish({ error: String(e) }); });
3635
+ child.on('exit', (code, signal) => { clearTimeout(timer); finish({ exit: code, signal }); });
3636
+ `;
3352
3637
  function runSuite(dir, cmd, budgetSecs) {
3353
3638
  const t0 = Date.now();
3354
- const r = spawnSync("sh", ["-c", cmd], {
3355
- cwd: dir,
3356
- stdio: "ignore",
3357
- timeout: budgetSecs * 1e3,
3358
- killSignal: "SIGKILL"
3359
- });
3360
- const secs = Math.round((Date.now() - t0) / 1e3);
3361
- if (r.error && r.error.code === "ETIMEDOUT") return { exit: null, secs };
3362
- if (r.status === null && r.signal) return { exit: null, secs };
3363
- return { exit: r.status ?? 2, secs };
3639
+ const outDir = mkdtempSync2(join11(tmpdir2(), "tw-verify-run-"));
3640
+ const outFile = join11(outDir, "result.json");
3641
+ try {
3642
+ spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
3643
+ cwd: dir,
3644
+ stdio: "ignore",
3645
+ timeout: budgetSecs * 1e3 + 3e4,
3646
+ // backstop only; the supervisor enforces the budget
3647
+ killSignal: "SIGKILL"
3648
+ });
3649
+ const secs = Math.round((Date.now() - t0) / 1e3);
3650
+ let r;
3651
+ try {
3652
+ r = JSON.parse(readFileSync13(outFile, "utf8"));
3653
+ } catch {
3654
+ return { exit: null, secs };
3655
+ }
3656
+ if (r.timedOut || r.error) return { exit: null, secs };
3657
+ if (r.exit === null || r.exit === void 0) return { exit: null, secs };
3658
+ return { exit: r.exit, secs };
3659
+ } finally {
3660
+ rmSync2(outDir, { recursive: true, force: true });
3661
+ }
3364
3662
  }
3365
3663
  function runVerify(opts) {
3366
3664
  const cwd = opts.cwd ?? process.cwd();
@@ -3517,7 +3815,7 @@ function parseVerify(args) {
3517
3815
  // src/cli/run.ts
3518
3816
  import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
3519
3817
  import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync } from "node:fs";
3520
- import { resolve as resolve2 } from "node:path";
3818
+ import { resolve as resolve3 } from "node:path";
3521
3819
  var out = (s) => process.stdout.write(s + "\n");
3522
3820
  var err = (s) => process.stderr.write(s + "\n");
3523
3821
  function git3(args, cwd) {
@@ -3564,7 +3862,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
3564
3862
  return out2;
3565
3863
  }
3566
3864
  function runEnvelope(opts) {
3567
- const cwd = resolve2(opts.cwd ?? process.cwd());
3865
+ const cwd = resolve3(opts.cwd ?? process.cwd());
3568
3866
  if (!opts.argv.length) {
3569
3867
  err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
3570
3868
  return 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "1.14.6",
3
+ "version": "2.0.0",
4
4
  "description": "The deterministic agent-integrity gate. One ruleset, evaluated on the actual diff/commands as a verdict, enforced everywhere a change can be made.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "hexrift",
@@ -37,7 +37,7 @@
37
37
  "NOTICE"
38
38
  ],
39
39
  "engines": {
40
- "node": ">=18"
40
+ "node": ">=20.19"
41
41
  },
42
42
  "scripts": {
43
43
  "build": "esbuild src/cli/index.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/cli/index.js",
@@ -53,10 +53,10 @@
53
53
  "yaml": "^2.6.1"
54
54
  },
55
55
  "devDependencies": {
56
- "@types/node": "^20.14.0",
56
+ "@types/node": "^22.20.1",
57
57
  "@types/picomatch": "^3.0.1",
58
- "esbuild": "^0.24.0",
59
- "vitepress": "^1.6.4",
60
- "vitest": "^2.1.8"
58
+ "esbuild": "^0.25.12",
59
+ "vitepress": "^2.0.0-alpha.19",
60
+ "vitest": "^4.1.11"
61
61
  }
62
62
  }