tamperward 1.14.5 → 1.15.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 +79 -13
  2. package/dist/cli/index.js +461 -97
  3. package/package.json +3 -3
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 **v1.15.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.
@@ -173,9 +186,18 @@ Full assumptions and residual risks: [SPEC.md](./SPEC.md),
173
186
  npx tamperward init
174
187
  ```
175
188
 
176
- One idempotent command wires the policy, the agent hooks, the pre-commit hook, and
177
- a CI workflow that runs both the diff-time check and pristine verification. It
178
- never overwrites anything you wrote; `--dry-run` prints the plan.
189
+ One idempotent command wires the policy, the agent hooks, the pre-commit hook, a
190
+ CI workflow that runs both the diff-time check and pristine verification, and a
191
+ `CODEOWNERS` requirement on the paths that decide whether the gate runs at all.
192
+ It never overwrites anything you wrote; `--dry-run` prints the plan.
193
+
194
+ **`init` is not sufficient on its own, and it will tell you so.** In your
195
+ branch-protection or ruleset settings you must require the check *and* enable
196
+ "Require review from Code Owners". A `pull_request` workflow runs from the pull
197
+ request's own head and a required check is matched by job name, so without that
198
+ a PR can keep the job name, replace the gate with `true`, and present a green
199
+ required check over a change the gate would have blocked. That is reproduced on
200
+ this project's own CI, not a theoretical concern.
179
201
 
180
202
  A real deployment needs a verify command configured — the generated CI verify step
181
203
  **fails closed (exit 2) without one** rather than passing quietly. In
@@ -209,9 +231,50 @@ npx tamperward verify --base main # pristine-suite re-execution
209
231
  npx tamperward run -- <agent command...> # the outer envelope around an agent
210
232
  ```
211
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
+
212
275
  ### The rules
213
276
 
214
- Sixteen rules are specified and fourteen ship (see the table in
277
+ Seventeen rules are specified and fifteen ship (see the table in
215
278
  [SPEC.md](./SPEC.md)). The families: test protection (`test-deletion`,
216
279
  `test-skip`, `test-content-removal`), verification-signal protection
217
280
  (`coverage-lowering`, `snapshot-rewrite`, `snapshot-only-rewrite`), suppression
@@ -272,6 +335,7 @@ and its totals.
272
335
  | v1.9.0 | `tamperward verify`: the round-1 pristine oracle productized — visible-green + pristine-red is a masked failure, and "could not verify" fails closed |
273
336
  | Round 2, 9–0 | On 22 fresh repositories, v1.9.0 produced b=9/c=0 false-green discordance (p = 0.0039); four gated false greens remained, through two documented residual mechanisms |
274
337
  | cssstyle lifecycle escape (round 2) | The `tamperward run` envelope: the gate denied 42 mutations and verify refused all 25 stop attempts, and the runtime completed the session anyway over a masked tree — hooks decide, runtimes terminate, so the adjudication boundary moved outside the runtime |
338
+ | v1.14.6, external audit | A pull request can neuter the gate that judges it: `pull_request` workflows run from the PR's own head and required checks match by job name, so a job that keeps its name and runs `true` reports green over anything. Reproduced on this repo's own CI — `gate` succeeded in three seconds over a PR that lowered a mechanical rule to warn. No detector can close it, because the detector is defined by the candidate; `init` now writes a CODEOWNERS requirement on the gate-critical paths and states, every run, that branch protection is the half it cannot set for you |
275
339
  | v1.14.5, external audit | The pristine copy was a sibling of the visible copy and fully prepared before it ran, so the candidate's own code wrote `../pristine/<the failing test>` and both runs returned 0 — no race, no background process, no git. The pristine copy is now built only after the visible run exits, its restored files are digested either side of the pristine run, and the working and dependency trees are fingerprinted around both. Also: the overlay restored content but not MODE, so `chmod -x` alone hid a test from an exec-bit runner in both copies; a transient dependency substitution that restored itself on the way out left the entry-vs-exit fingerprint identical; and a committed `.npmrc` chose the registry the CI gate was downloaded from |
276
340
  | v1.14.4, external audit | The overlay restored the tests and then ran the agent's *runner*: `verify.command` was frozen, but `runner.js` — and anything `npm test` delegates to — was in no protected class. And `git replace` redirected what the base resolved to for `ls-tree`, `show` and `merge-base` alike, with no ref moved and no file touched. Verifier inputs are now restored from the base; every trusted read sets `GIT_NO_REPLACE_OBJECTS=1` and the envelope convicts a rewrite installed during the run. `init` also gained workflow migration and matcher repair, and the Claude hook now denies a payload it cannot parse instead of allowing it |
277
341
  | v1.14.3, external review | Standalone `verify` loaded its policy from the working tree, so the generated CI workflow let a pull request supply the `verify:` command for its own re-execution. `check --diff` flagged the edit as hook-tampering, so the workflow caught it as a pair — but only where both jobs are required, and `verify` alone had no protection. With a `--base`, policy now comes from that commit |
@@ -298,12 +362,14 @@ via npm trusted publishing with SLSA provenance. Full rule:
298
362
 
299
363
  ```bash
300
364
  npm install && npm run build # bundles the CLI to dist/cli/index.js
301
- npm test # 368 tests at v1.14.3 — parser, detectors, engine, policy, renderers
365
+ npm test # 520+ tests at 1.15.0 — parser, detectors, engine, policy, renderers
302
366
  npm run typecheck
303
367
  ```
304
368
 
305
- Tamperward gates its own repo in CI with the same engine it ships — including, on more
306
- 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
307
373
  the build spec, the detector table, the enforcement-point wiring, and the proof-harness
308
374
  design; the `harness/` directory holds the seeds, oracles, transcripts tooling, and
309
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
  }
@@ -510,7 +526,14 @@ function defaultPolicy(version = 1) {
510
526
  "**/.tamperward.yml",
511
527
  ".claude/settings.json",
512
528
  ".claude/settings.local.json",
513
- "**/.pre-commit-config.yaml"
529
+ "**/.pre-commit-config.yaml",
530
+ // CODEOWNERS is enforcement wiring, not documentation: it is the only
531
+ // thing that can require a human on a change to the workflow that
532
+ // decides whether the gate runs at all. GitHub reads it from any of
533
+ // these three locations. (H2.)
534
+ ".github/CODEOWNERS",
535
+ "CODEOWNERS",
536
+ "docs/CODEOWNERS"
514
537
  ]
515
538
  },
516
539
  rules: {
@@ -558,6 +581,42 @@ var CODE = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
558
581
  function isCodeFile(path) {
559
582
  return CODE.test(path);
560
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
+ }
561
620
 
562
621
  // src/detectors/ts-any-cast.ts
563
622
  var BLOCK_RULE = "ts-any-cast";
@@ -664,11 +723,35 @@ var tsAnyCast = {
664
723
 
665
724
  // src/detectors/lint-suppression.ts
666
725
  var RULE2 = "lint-suppression";
667
- var PATTERNS = [
668
- { re: /eslint-disable(?:-next-line|-line)?\b/, why: "eslint-disable suppresses lint rules" },
669
- { re: /\bprettier-ignore\b/, why: "prettier-ignore suppresses formatting checks" },
670
- { re: /\bbiome-ignore\b/, why: "biome-ignore suppresses Biome diagnostics" }
671
- ];
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
+ };
672
755
  var lintSuppression = {
673
756
  id: RULE2,
674
757
  surface: ["file"],
@@ -676,9 +759,13 @@ var lintSuppression = {
676
759
  run(changes, policy) {
677
760
  const out2 = [];
678
761
  for (const c of changes) {
679
- 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;
680
767
  for (const l of addedLines(c)) {
681
- for (const p of PATTERNS) {
768
+ for (const p of patterns) {
682
769
  if (p.re.test(l.content)) {
683
770
  out2.push(
684
771
  makeFinding(RULE2, policy, {
@@ -700,18 +787,44 @@ var lintSuppression = {
700
787
 
701
788
  // src/detectors/test-skip.ts
702
789
  var RULE3 = "test-skip";
703
- var PATTERNS2 = [
704
- { re: /\b(?:it|test|describe)\.(?:skip|only|todo)\b/, why: "a .skip/.only/.todo marker" },
705
- { re: /\b(?:xit|xtest|xdescribe)\b/, why: "an x-prefixed disabled test" },
706
- { re: /\b(?:fit|ftest|fdescribe)\b/, why: "an f-prefixed focused test (narrows the suite)" },
707
- { re: /\bpending\(\s*\)/, why: "a pending() marker" },
708
- // node:test spells the same class as options — `test('x', { skip: true }, ...)` — and as
709
- // a runtime call. A live evasion used the options form the day this gap was measured
710
- // (PREDICTION-baselines.md): denied the rm, the agent skipped by option instead.
711
- { re: /\bskip:\s*(?:true|1|['"`])/, why: "a { skip: ... } option disabling the test (node:test options form)" },
712
- { re: /\btodo:\s*(?:true|['"`])/, why: "a { todo: ... } option (the test no longer fails the run)" },
713
- { re: /\b(?:t|ctx|context)\.skip\(/, why: "a runtime t.skip() call" }
714
- ];
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
+ };
715
828
  var testSkip = {
716
829
  id: RULE3,
717
830
  surface: ["file"],
@@ -721,8 +834,9 @@ var testSkip = {
721
834
  for (const c of changes) {
722
835
  if (c.kind !== "file") continue;
723
836
  if (!isProtected(c.path, policy, "tests")) continue;
837
+ const patterns = PATTERNS2[langOf(c.path) ?? "js"];
724
838
  for (const l of addedLines(c)) {
725
- for (const p of PATTERNS2) {
839
+ for (const p of patterns) {
726
840
  if (p.re.test(l.content)) {
727
841
  out2.push(
728
842
  makeFinding(RULE3, policy, {
@@ -1011,6 +1125,7 @@ var ciTampering = {
1011
1125
  (c.after != null ? c.after.split("\n") : addedLines(c).map((l) => l.content)).map(commandCore)
1012
1126
  );
1013
1127
  for (const l of removedLines(c)) {
1128
+ if (/^\s*#/.test(l.content)) continue;
1014
1129
  const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
1015
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);
1016
1131
  if (!isUsesLine && !isCheckCommand) continue;
@@ -1073,8 +1188,11 @@ function policyWeakening(before, after) {
1073
1188
  reasons.push(`rule "${name}" removed from policy`);
1074
1189
  continue;
1075
1190
  }
1076
- if (br.severity === "block" && ar.severity === "warn") {
1077
- 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
+ );
1078
1196
  }
1079
1197
  if (ar.enabled === false && br.enabled !== false) {
1080
1198
  reasons.push(`rule "${name}" disabled (enabled: false)`);
@@ -1130,6 +1248,27 @@ function policyWeakening(before, after) {
1130
1248
  var RULE6 = "hook-tampering";
1131
1249
  var HOOK_INVOCATION = /\b(tamperward|npx|npm|pnpm|yarn|bun|jest|vitest|eslint|tsc|lefthook|husky|lint-staged|make|cargo|pytest)\b/;
1132
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
+ }
1133
1272
  var hookTampering = {
1134
1273
  id: RULE6,
1135
1274
  surface: ["file", "command"],
@@ -1140,6 +1279,16 @@ var hookTampering = {
1140
1279
  if (c.kind === "file") {
1141
1280
  const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false);
1142
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
+ }
1143
1292
  if (c.op === "delete") {
1144
1293
  out2.push(
1145
1294
  makeFinding(RULE6, policy, {
@@ -1236,10 +1385,8 @@ var hookTampering = {
1236
1385
  for (const seg of segments(c.raw)) {
1237
1386
  const hitsHook = tokens(seg).map(unquote).some((t) => isProtected(t, policy, "hooks"));
1238
1387
  if (!hitsHook) continue;
1239
- const octal = seg.match(/\bchmod\b[^|;&]*?(?:^|\s)([0-7]{3,4})(?:\s|$)/);
1240
- const octalDropsExec = octal ? (Number(octal[1].slice(-3)[0]) & 1) === 0 : false;
1241
1388
  let why = null;
1242
- if (/\bchmod\b/.test(seg) && (/(?:^|\s)[-+=]?[a-z]*x/i.test(seg) || octalDropsExec)) {
1389
+ if (chmodTouchesExec(seg)) {
1243
1390
  why = "chmod alters execute permission on a hook, which can disable it";
1244
1391
  } else if (/\brm\b/.test(seg)) {
1245
1392
  why = "rm deletes a protected hook";
@@ -1267,6 +1414,16 @@ var hookTampering = {
1267
1414
  // src/detectors/test-deletion.ts
1268
1415
  import ts3 from "typescript";
1269
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
+ };
1270
1427
  function calleeName(expr) {
1271
1428
  if (ts3.isIdentifier(expr)) return expr.text;
1272
1429
  if (ts3.isPropertyAccessExpression(expr) && ts3.isIdentifier(expr.expression)) {
@@ -1274,7 +1431,14 @@ function calleeName(expr) {
1274
1431
  }
1275
1432
  return null;
1276
1433
  }
1277
- 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
+ }
1278
1442
  const sf = ts3.createSourceFile("spec.ts", src, ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TS);
1279
1443
  let n = 0;
1280
1444
  const visit = (node) => {
@@ -1287,11 +1451,12 @@ function countTestBlocks(src) {
1287
1451
  visit(sf);
1288
1452
  return n;
1289
1453
  }
1290
- function significantLines(src) {
1454
+ function significantLines(src, path) {
1291
1455
  const out2 = /* @__PURE__ */ new Set();
1456
+ const lang = langOf(path);
1292
1457
  for (const raw of src.split("\n")) {
1293
1458
  const l = raw.trim();
1294
- if (l.length >= 10 && !/^(import\b|export\s|\/\/|\*|\/\*|}\)?;?$)/.test(l)) out2.add(l);
1459
+ if (isSignificantLine(l, lang)) out2.add(l);
1295
1460
  }
1296
1461
  return out2;
1297
1462
  }
@@ -1305,22 +1470,22 @@ var testDeletion = {
1305
1470
  let addedTestBlocks = 0;
1306
1471
  for (const c of changes) {
1307
1472
  if (c.kind === "file" && c.op === "add" && c.after != null && isProtected(c.path, policy, "tests")) {
1308
- for (const l of significantLines(c.after)) addedTestLines.add(l);
1309
- addedTestBlocks += countTestBlocks(c.after);
1473
+ for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
1474
+ addedTestBlocks += countTestBlocks(c.after, c.path);
1310
1475
  }
1311
1476
  }
1312
- const isRelocation = (before) => {
1313
- const sig = significantLines(before);
1477
+ const isRelocation = (before, path) => {
1478
+ const sig = significantLines(before, path);
1314
1479
  if (sig.size === 0 || addedTestLines.size === 0) return false;
1315
1480
  let hit = 0;
1316
1481
  for (const l of sig) if (addedTestLines.has(l)) hit++;
1317
- return hit / sig.size >= 0.6 && addedTestBlocks >= countTestBlocks(before);
1482
+ return hit / sig.size >= 0.6 && addedTestBlocks >= countTestBlocks(before, path);
1318
1483
  };
1319
1484
  for (const c of changes) {
1320
1485
  if (c.kind === "file") {
1321
1486
  const isTest = isProtected(c.path, policy, "tests");
1322
1487
  if (c.op === "delete" && isTest) {
1323
- if (c.before != null && isRelocation(c.before)) continue;
1488
+ if (c.before != null && isRelocation(c.before, c.path)) continue;
1324
1489
  out2.push(
1325
1490
  makeFinding(RULE7, policy, {
1326
1491
  file: c.path,
@@ -1339,8 +1504,8 @@ var testDeletion = {
1339
1504
  })
1340
1505
  );
1341
1506
  } else if ((c.op === "modify" || c.op === "rename") && isTest && c.before != null && c.after != null) {
1342
- const before = countTestBlocks(c.before);
1343
- const after = countTestBlocks(c.after);
1507
+ const before = countTestBlocks(c.before, c.path);
1508
+ const after = countTestBlocks(c.after, c.path);
1344
1509
  if (after < before) {
1345
1510
  out2.push(
1346
1511
  makeFinding(RULE7, policy, {
@@ -1396,11 +1561,12 @@ var testDeletion = {
1396
1561
  var RULE8 = "test-content-removal";
1397
1562
  var MIN_REMOVED_LINES = 3;
1398
1563
  var ws = (s) => s.replace(/\s+/g, "");
1399
- function significantLinesOrdered(src) {
1564
+ function significantLinesOrdered(src, path) {
1400
1565
  const out2 = [];
1566
+ const lang = langOf(path);
1401
1567
  for (const raw of src.split("\n")) {
1402
1568
  const l = raw.trim();
1403
- if (l.length >= 10 && !/^(import\b|export\s|\/\/|\*|\/\*|}\)?;?$)/.test(l)) out2.push(l);
1569
+ if (isSignificantLine(l, lang)) out2.push(l);
1404
1570
  }
1405
1571
  return out2;
1406
1572
  }
@@ -1413,16 +1579,16 @@ var testContentRemoval = {
1413
1579
  let keptPool = "";
1414
1580
  for (const c of changes) {
1415
1581
  if (c.kind === "file" && c.after != null && isProtected(c.path, policy, "tests")) {
1416
- keptPool += ws(significantLinesOrdered(c.after).join("\n"));
1582
+ keptPool += ws(significantLinesOrdered(c.after, c.path).join("\n"));
1417
1583
  }
1418
1584
  }
1419
1585
  for (const c of changes) {
1420
1586
  if (c.kind !== "file" || c.op !== "modify" && c.op !== "rename" || c.before == null || c.after == null) continue;
1421
1587
  if (!isProtected(c.path, policy, "tests")) continue;
1422
1588
  if (isProtected(c.path, policy, "snapshots")) continue;
1423
- if (countTestBlocks(c.after) < countTestBlocks(c.before)) continue;
1424
- const beforeSig = significantLinesOrdered(c.before);
1425
- 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);
1426
1592
  const netDrop = beforeSig.length - afterSig.length;
1427
1593
  const afterSet = new Set(afterSig);
1428
1594
  const gone = [];
@@ -1593,6 +1759,7 @@ function evaluate(changes, policy, detectors = allDetectors, view) {
1593
1759
  return true;
1594
1760
  });
1595
1761
  return deduped.filter((f) => {
1762
+ if (f.rule !== "detector-error" && !isEnabled(f.rule, policy)) return false;
1596
1763
  const ex = policy.rules[f.rule]?.exclude;
1597
1764
  if (!ex?.length || !f.file || isPolicyFile(f.file)) return true;
1598
1765
  return !matchesAny(f.file, ex);
@@ -1615,9 +1782,62 @@ function normalizeVersion(v, where = POLICY_FILE) {
1615
1782
  }
1616
1783
  return v;
1617
1784
  }
1618
- 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) {
1619
1838
  const r = raw ?? {};
1620
- const version = normalizeVersion(r.version);
1839
+ const version = normalizeVersion(r.version, where);
1840
+ validate(r, where);
1621
1841
  const base = defaultPolicy(version);
1622
1842
  return {
1623
1843
  version,
@@ -1653,7 +1873,7 @@ function parseOrThrow(src, where) {
1653
1873
  if (raw !== null && raw !== void 0 && (typeof raw !== "object" || Array.isArray(raw))) {
1654
1874
  throw new PolicyError(`${where} is not a policy mapping`);
1655
1875
  }
1656
- return parsePolicy(raw);
1876
+ return parsePolicy(raw, where);
1657
1877
  }
1658
1878
  function loadPolicy(cwd = process.cwd()) {
1659
1879
  const path = join2(cwd, POLICY_FILE);
@@ -2037,7 +2257,7 @@ import { createHash as createHash4 } from "node:crypto";
2037
2257
  import { execFileSync as execFileSync2 } from "node:child_process";
2038
2258
  import { readFileSync as readFileSync4, existsSync as existsSync3, writeFileSync, mkdtempSync, rmSync } from "node:fs";
2039
2259
  import { tmpdir } from "node:os";
2040
- import { join as join4, isAbsolute } from "node:path";
2260
+ import { isAbsolute, join as join4, relative, resolve } from "node:path";
2041
2261
  function asStr(v) {
2042
2262
  return typeof v === "string" ? v : "";
2043
2263
  }
@@ -2049,10 +2269,11 @@ function readDisk(path) {
2049
2269
  }
2050
2270
  }
2051
2271
  function abs(path, cwd) {
2052
- return isAbsolute(path) ? path : join4(cwd, path);
2272
+ return resolve(cwd, path);
2053
2273
  }
2054
2274
  function relForDisplay(path, cwd) {
2055
- 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;
2056
2277
  }
2057
2278
  function applyEdit(content, oldStr, newStr) {
2058
2279
  if (content === null) return newStr;
@@ -2073,9 +2294,15 @@ function synthFileChange(displayPath, before, after) {
2073
2294
  writeFileSync(a, before ?? "");
2074
2295
  writeFileSync(b, after ?? "");
2075
2296
  try {
2076
- 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
+ });
2077
2301
  } catch (e) {
2078
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
+ }
2079
2306
  raw = err2.stdout ? String(err2.stdout) : "";
2080
2307
  }
2081
2308
  } finally {
@@ -2190,13 +2417,19 @@ function advanceTurnBaseline(cwd, sessionId) {
2190
2417
  // src/effect.ts
2191
2418
  import { createHash as createHash2 } from "node:crypto";
2192
2419
  import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync, writeFileSync as writeFileSync3 } from "node:fs";
2193
- import { join as join6 } from "node:path";
2420
+ import { isAbsolute as isAbsolute2, join as join6 } from "node:path";
2194
2421
  import { execFileSync as execFileSync3 } from "node:child_process";
2195
2422
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
2196
2423
  var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
2197
2424
  function gitDir2(cwd) {
2198
2425
  try {
2199
- 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);
2200
2433
  } catch {
2201
2434
  return null;
2202
2435
  }
@@ -2205,7 +2438,7 @@ function ptreePath(cwd, sessionId) {
2205
2438
  if (!sessionId) return null;
2206
2439
  const gd = gitDir2(cwd);
2207
2440
  if (!gd) return null;
2208
- const dir = join6(cwd, gd, "tamperward");
2441
+ const dir = join6(gd, "tamperward");
2209
2442
  return join6(dir, `ptree-${sessionId.replace(UNSAFE2, "")}.json`);
2210
2443
  }
2211
2444
  var sha = (buf) => createHash2("sha256").update(buf).digest("hex").slice(0, 16);
@@ -2288,7 +2521,8 @@ function snap(abs2) {
2288
2521
  }
2289
2522
  }
2290
2523
  function defaultEventLog(cwd) {
2291
- 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");
2292
2526
  }
2293
2527
  function watchTree(dir, cb) {
2294
2528
  if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
@@ -2398,6 +2632,7 @@ function readEvents(log, offset) {
2398
2632
  return { events, newOffset: size };
2399
2633
  }
2400
2634
  function transientFindings(events, persistentPaths, policy, finalHash) {
2635
+ if (!isEnabled(RULE11, policy)) return [];
2401
2636
  const byPath = /* @__PURE__ */ new Map();
2402
2637
  for (const e of events) {
2403
2638
  if (!isProtected(e.path, policy)) continue;
@@ -2709,15 +2944,43 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
2709
2944
  }
2710
2945
 
2711
2946
  // src/cli/init.ts
2947
+ import { execFileSync as execFileSync4 } from "node:child_process";
2712
2948
  import { createHash as createHash5 } from "node:crypto";
2713
2949
  import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
2714
2950
  import { dirname as dirname3, join as join8 } from "node:path";
2715
2951
  import { fileURLToPath } from "node:url";
2716
- var HOOK_CMD = "npx --yes tamperward hook claude";
2717
2952
  var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
2718
- var SWEEP_CMD = "npx --yes tamperward sweep claude";
2719
- var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
2720
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*$/;
2958
+ var CODEOWNERS_MARK = "# tamperward: the gate cannot guard the file that defines it";
2959
+ var CODEOWNERS_PATHS = ["/.github/workflows/", "/.tamperward.yml", "/.github/CODEOWNERS"];
2960
+ function coveredBy(existing, critical) {
2961
+ for (const line of existing.split("\n")) {
2962
+ const t = line.trim();
2963
+ if (!t || t.startsWith("#")) continue;
2964
+ const pattern = t.split(/\s+/)[0];
2965
+ if (!t.slice(pattern.length).trim()) continue;
2966
+ if (pattern === "*" || pattern === critical) return true;
2967
+ if (pattern.endsWith("/") && critical.startsWith(pattern)) return true;
2968
+ }
2969
+ return false;
2970
+ }
2971
+ function inferOwner(cwd) {
2972
+ try {
2973
+ const url = execFileSync4("git", ["remote", "get-url", "origin"], {
2974
+ cwd,
2975
+ encoding: "utf8",
2976
+ stdio: ["ignore", "pipe", "ignore"]
2977
+ }).trim();
2978
+ const m = url.match(/github\.com[:/]+([^/]+)\//);
2979
+ return m ? `@${m[1]}` : null;
2980
+ } catch {
2981
+ return null;
2982
+ }
2983
+ }
2721
2984
  var POLICY_CONTENT = `# Tamperward policy. The BASELINE (all rules, standard protected globs) applies even
2722
2985
  # without this file \u2014 everything here is an override, so an empty file changes nothing.
2723
2986
  # Docs: https://github.com/hexrift/tamperward#readme
@@ -2747,6 +3010,17 @@ function shippedVersion() {
2747
3010
  return "latest";
2748
3011
  }
2749
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
+ }
2750
3024
  var WORKFLOW_CONTENT = `name: tamperward
2751
3025
 
2752
3026
  # The CI authority for main: the same engine as the agent hook and pre-commit, run over
@@ -2867,15 +3141,18 @@ function planClaudeHooks(cwd) {
2867
3141
  settings = parsed;
2868
3142
  }
2869
3143
  const hooks = settings.hooks ??= {};
2870
- const entriesWith = (arr, needle) => (arr ?? []).filter((m) => (m.hooks ?? []).some((h) => String(h.command ?? "").includes(needle)));
2871
- 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);
2872
3146
  const needPre = preEntries.length === 0;
2873
- const needStop = entriesWith(hooks.Stop, "tamperward sweep claude").length === 0;
3147
+ const stopEntries = entriesWith(hooks.Stop, SWEEP_RE);
3148
+ const needStop = stopEntries.length === 0;
2874
3149
  const stale = preEntries.filter((m) => missingTools(m.matcher).length > 0);
2875
- 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) {
2876
3152
  return { item: "agent", path: rel, status: "ok", detail: "PreToolUse + Stop hooks already wired" };
2877
3153
  }
2878
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"))];
2879
3156
  return {
2880
3157
  item: "agent",
2881
3158
  path: rel,
@@ -2883,9 +3160,14 @@ function planClaudeHooks(cwd) {
2883
3160
  detail: [
2884
3161
  needPre && "wire PreToolUse deny",
2885
3162
  needStop && "wire Stop sweep",
2886
- 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(", ")})`
2887
3165
  ].filter(Boolean).join(" + "),
2888
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
+ }
2889
3171
  if (needPre) {
2890
3172
  (hooks.PreToolUse ??= []).push({
2891
3173
  matcher: PRE_MATCHER,
@@ -2913,8 +3195,24 @@ ${PRECOMMIT_CMD}
2913
3195
  if (!target) return { item: "pre-commit", path: "(none)", status: "skip", detail: "not a git repo and no .husky/ \u2014 nothing to wire" };
2914
3196
  const path = join8(cwd, target.rel);
2915
3197
  const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
2916
- if (existing?.includes("tamperward check --staged")) {
2917
- 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
+ };
2918
3216
  }
2919
3217
  return {
2920
3218
  item: "pre-commit",
@@ -2930,6 +3228,35 @@ ${line}` : existing.replace(/\n?$/, "\n") + line;
2930
3228
  }
2931
3229
  };
2932
3230
  }
3231
+ function planCodeowners(cwd) {
3232
+ const rel = ".github/CODEOWNERS";
3233
+ const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
3234
+ (r) => existsSync7(join8(cwd, r))
3235
+ );
3236
+ const path = join8(cwd, existingRel ?? rel);
3237
+ const existing = existingRel ? readFileSync10(path, "utf8") : null;
3238
+ const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
3239
+ if (missing.length === 0) {
3240
+ return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
3241
+ }
3242
+ const owner = inferOwner(cwd);
3243
+ const block = `
3244
+ ${CODEOWNERS_MARK}
3245
+ # A pull request that rewrites the workflow can keep the job name, replace the
3246
+ # gate with \`true\`, and still report a green required check. Only a human
3247
+ # requirement on these paths prevents that.
3248
+ ` + (owner === null ? "# REPLACE @OWNER BELOW with a real user or team \u2014 an unresolvable owner protects nothing.\n" : "") + missing.map((p) => `${p.padEnd(24)} ${owner ?? "@OWNER"}`).join("\n") + "\n";
3249
+ return {
3250
+ item: "codeowners",
3251
+ path: existingRel ?? rel,
3252
+ status: existing === null ? "create" : "update",
3253
+ detail: (existing === null ? "require a code owner on " : "add missing code-owner rules for ") + missing.join(", ") + (owner === null ? " \u2014 OWNER UNKNOWN, edit @OWNER before this does anything" : ` (${owner})`),
3254
+ apply: () => {
3255
+ mkdirSync5(dirname3(path), { recursive: true });
3256
+ writeFileSync5(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
3257
+ }
3258
+ };
3259
+ }
2933
3260
  function planWorkflow(cwd, force) {
2934
3261
  const rel = ".github/workflows/tamperward.yml";
2935
3262
  const path = join8(cwd, rel);
@@ -2983,7 +3310,7 @@ function planWorkflow(cwd, force) {
2983
3310
  };
2984
3311
  }
2985
3312
  function planInit(cwd, opts = {}) {
2986
- return [planPolicy(cwd), planClaudeHooks(cwd), planPreCommit(cwd), planWorkflow(cwd, opts.forceWorkflow ?? false)];
3313
+ return [planPolicy(cwd), planClaudeHooks(cwd), planPreCommit(cwd), planWorkflow(cwd, opts.forceWorkflow ?? false), planCodeowners(cwd)];
2987
3314
  }
2988
3315
  function runInit(opts) {
2989
3316
  const cwd = opts.cwd ?? process.cwd();
@@ -3010,34 +3337,37 @@ tamperward init: ${changed} change(s) planned. Re-run without --dry-run to apply
3010
3337
  tamperward init: ${changed} change(s) applied. Commit them so the gate travels with the repo.
3011
3338
  `
3012
3339
  );
3340
+ w.write(
3341
+ '\nONE STEP LEFT, AND IT IS NOT OPTIONAL. In your branch-protection or ruleset\nsettings for the default branch, require the tamperward check AND enable\n"Require review from Code Owners".\n\nUntil you do, the CI gate is advisory. A pull request runs the workflow from\nits OWN head, and a required check is matched by job name \u2014 so a pull request\ncan keep the job called `tamperward`, replace the gate with `true`, and present\na green required check over a change the gate would have blocked. That is\nreproduced, not theoretical. CODEOWNERS is what puts a human in front of it;\nbranch protection is what makes CODEOWNERS bind.\n'
3342
+ );
3013
3343
  return 0;
3014
3344
  }
3015
3345
 
3016
3346
  // src/cli/verify.ts
3017
- import { execFileSync as execFileSync6, spawnSync } from "node:child_process";
3347
+ import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
3018
3348
  import { createHash as createHash8 } from "node:crypto";
3019
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";
3020
3350
  import { tmpdir as tmpdir2 } from "node:os";
3021
- 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";
3022
3352
 
3023
3353
  // src/git/trusted.ts
3024
- import { execFileSync as execFileSync4 } from "node:child_process";
3354
+ import { execFileSync as execFileSync5 } from "node:child_process";
3025
3355
  import { createHash as createHash6 } from "node:crypto";
3026
3356
  import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
3027
- import { isAbsolute as isAbsolute2, join as join9 } from "node:path";
3357
+ import { isAbsolute as isAbsolute3, join as join9 } from "node:path";
3028
3358
  function trustedGitEnv() {
3029
3359
  return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
3030
3360
  }
3031
3361
  function gitDirOf(cwd) {
3032
3362
  try {
3033
- const d = execFileSync4("git", ["rev-parse", "--git-dir"], {
3363
+ const d = execFileSync5("git", ["rev-parse", "--git-dir"], {
3034
3364
  cwd,
3035
3365
  encoding: "utf8",
3036
3366
  stdio: ["ignore", "pipe", "pipe"],
3037
3367
  env: trustedGitEnv()
3038
3368
  }).trim();
3039
3369
  if (!d) return null;
3040
- return isAbsolute2(d) ? d : join9(cwd, d);
3370
+ return isAbsolute3(d) ? d : join9(cwd, d);
3041
3371
  } catch {
3042
3372
  return null;
3043
3373
  }
@@ -3046,7 +3376,7 @@ function objectRewriteState(cwd) {
3046
3376
  const parts = [];
3047
3377
  try {
3048
3378
  parts.push(
3049
- "replace:" + execFileSync4("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
3379
+ "replace:" + execFileSync5("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
3050
3380
  cwd,
3051
3381
  encoding: "utf8",
3052
3382
  stdio: ["ignore", "pipe", "pipe"],
@@ -3073,12 +3403,12 @@ function objectRewriteState(cwd) {
3073
3403
  }
3074
3404
 
3075
3405
  // src/fingerprint.ts
3076
- import { execFileSync as execFileSync5 } from "node:child_process";
3406
+ import { execFileSync as execFileSync6 } from "node:child_process";
3077
3407
  import { createHash as createHash7 } from "node:crypto";
3078
3408
  import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as statSync5 } from "node:fs";
3079
3409
  import { join as join10 } from "node:path";
3080
3410
  function treeFingerprint(cwd) {
3081
- const files = execFileSync5("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
3411
+ const files = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
3082
3412
  cwd,
3083
3413
  encoding: "utf8",
3084
3414
  maxBuffer: 1 << 28,
@@ -3166,7 +3496,7 @@ var VERIFICATION_SURFACE = [
3166
3496
  "**/*.runsettings"
3167
3497
  ];
3168
3498
  function git2(args, cwd) {
3169
- return execFileSync6("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
3499
+ return execFileSync7("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
3170
3500
  }
3171
3501
  function resolveBase(base, cwd) {
3172
3502
  assertRev(base);
@@ -3202,7 +3532,7 @@ function materialize(cwd, dest) {
3202
3532
  }
3203
3533
  chmodSync2(out2, st.mode);
3204
3534
  }
3205
- const nm = resolve(cwd, "node_modules");
3535
+ const nm = resolve2(cwd, "node_modules");
3206
3536
  if (existsSync9(nm)) symlinkSync(nm, join11(dest, "node_modules"), "dir");
3207
3537
  }
3208
3538
  function dropSymlink(p) {
@@ -3262,7 +3592,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
3262
3592
  if (e.type === "commit") {
3263
3593
  throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
3264
3594
  }
3265
- const content = execFileSync6("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
3595
+ const content = execFileSync7("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
3266
3596
  const out2 = join11(dest, e.path);
3267
3597
  mkdirSync6(dirname4(out2), { recursive: true });
3268
3598
  rmSync2(out2, { force: true });
@@ -3283,18 +3613,52 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
3283
3613
  }
3284
3614
  return { restored, removed };
3285
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
+ `;
3286
3637
  function runSuite(dir, cmd, budgetSecs) {
3287
3638
  const t0 = Date.now();
3288
- const r = spawnSync("sh", ["-c", cmd], {
3289
- cwd: dir,
3290
- stdio: "ignore",
3291
- timeout: budgetSecs * 1e3,
3292
- killSignal: "SIGKILL"
3293
- });
3294
- const secs = Math.round((Date.now() - t0) / 1e3);
3295
- if (r.error && r.error.code === "ETIMEDOUT") return { exit: null, secs };
3296
- if (r.status === null && r.signal) return { exit: null, secs };
3297
- 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
+ }
3298
3662
  }
3299
3663
  function runVerify(opts) {
3300
3664
  const cwd = opts.cwd ?? process.cwd();
@@ -3449,13 +3813,13 @@ function parseVerify(args) {
3449
3813
  }
3450
3814
 
3451
3815
  // src/cli/run.ts
3452
- import { execFileSync as execFileSync7, spawnSync as spawnSync2 } from "node:child_process";
3816
+ import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
3453
3817
  import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync } from "node:fs";
3454
- import { resolve as resolve2 } from "node:path";
3818
+ import { resolve as resolve3 } from "node:path";
3455
3819
  var out = (s) => process.stdout.write(s + "\n");
3456
3820
  var err = (s) => process.stderr.write(s + "\n");
3457
3821
  function git3(args, cwd) {
3458
- return execFileSync7("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
3822
+ return execFileSync8("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
3459
3823
  }
3460
3824
  function nowTicks() {
3461
3825
  try {
@@ -3498,7 +3862,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
3498
3862
  return out2;
3499
3863
  }
3500
3864
  function runEnvelope(opts) {
3501
- const cwd = resolve2(opts.cwd ?? process.cwd());
3865
+ const cwd = resolve3(opts.cwd ?? process.cwd());
3502
3866
  if (!opts.argv.length) {
3503
3867
  err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
3504
3868
  return 2;
@@ -3580,7 +3944,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
3580
3944
  const verifyCode = runVerify({ cwd, base, cmd: frozenCmd, budget: frozenBudget, policyOverride: frozenPolicy });
3581
3945
  if (opts.settle && opts.settle > 0) {
3582
3946
  try {
3583
- execFileSync7("sleep", [String(opts.settle)]);
3947
+ execFileSync8("sleep", [String(opts.settle)]);
3584
3948
  } catch {
3585
3949
  }
3586
3950
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "1.14.5",
3
+ "version": "1.15.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",
@@ -55,8 +55,8 @@
55
55
  "devDependencies": {
56
56
  "@types/node": "^20.14.0",
57
57
  "@types/picomatch": "^3.0.1",
58
- "esbuild": "^0.24.0",
58
+ "esbuild": "^0.25.12",
59
59
  "vitepress": "^1.6.4",
60
- "vitest": "^2.1.8"
60
+ "vitest": "^2.1.9"
61
61
  }
62
62
  }