tamperward 1.14.3 → 1.14.6

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 +27 -6
  2. package/dist/cli/index.js +501 -134
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -173,9 +173,18 @@ Full assumptions and residual risks: [SPEC.md](./SPEC.md),
173
173
  npx tamperward init
174
174
  ```
175
175
 
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.
176
+ One idempotent command wires the policy, the agent hooks, the pre-commit hook, a
177
+ CI workflow that runs both the diff-time check and pristine verification, and a
178
+ `CODEOWNERS` requirement on the paths that decide whether the gate runs at all.
179
+ It never overwrites anything you wrote; `--dry-run` prints the plan.
180
+
181
+ **`init` is not sufficient on its own, and it will tell you so.** In your
182
+ branch-protection or ruleset settings you must require the check *and* enable
183
+ "Require review from Code Owners". A `pull_request` workflow runs from the pull
184
+ request's own head and a required check is matched by job name, so without that
185
+ a PR can keep the job name, replace the gate with `true`, and present a green
186
+ required check over a change the gate would have blocked. That is reproduced on
187
+ this project's own CI, not a theoretical concern.
179
188
 
180
189
  A real deployment needs a verify command configured — the generated CI verify step
181
190
  **fails closed (exit 2) without one** rather than passing quietly. In
@@ -185,11 +194,20 @@ A real deployment needs a verify command configured — the generated CI verify
185
194
  verify:
186
195
  command: npm test
187
196
  budget: 300
197
+ inputs: ['scripts/**'] # what the command DELEGATES to
188
198
  ```
189
199
 
190
200
  That block is itself a guarded surface: changing the command, lowering the budget,
191
- or removing it is flagged as policy weakening — a verifier an agent can point at
192
- `true` is no verification at all.
201
+ narrowing `inputs`, or removing it is flagged as policy weakening — a verifier an
202
+ agent can point at `true` is no verification at all.
203
+
204
+ `inputs` names the files the command *executes*, so the pristine run gets the
205
+ base's copy of them too. A command token that names a file present at the base is
206
+ picked up automatically (`node runner.js`), so most repositories need nothing
207
+ here. Delegation is what needs the list: `npm test` names no file, and the base's
208
+ restored `"test": "sh scripts/test.sh"` will happily call a script nothing
209
+ restored. It bounds the class rather than closing it — see
210
+ [the threat model](./docs/THREAT-MODEL-pristine-run.md).
193
211
 
194
212
  The four primitives:
195
213
 
@@ -263,6 +281,9 @@ and its totals.
263
281
  | 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 |
264
282
  | 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 |
265
283
  | 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 |
284
+ | 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 |
285
+ | 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 |
286
+ | 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 |
266
287
  | 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 |
267
288
  | v1.14.2, threat model | 1.14.1 removed agent-added files only inside the protected classes, whose `config` list is JS/TS-only; an added `pytest.ini`, `setup.cfg`, `tox.ini` or `pyproject.toml` still reached the pristine run. `verify` now owns a verification surface covering runner-consulted configuration |
268
289
  | v1.14.1, article audit | `tamperward verify` kept agent-added protected files in the pristine run on the premise that they "only add strictness". An added `conftest.py` could deselect the restored base tests by node id, so a masked failure reported VERIFIED and the envelope printed GREEN MEANS GREEN over an unfixed bug. Added protected files are now removed; PoC and mutation-checked regression committed |
@@ -287,7 +308,7 @@ via npm trusted publishing with SLSA provenance. Full rule:
287
308
 
288
309
  ```bash
289
310
  npm install && npm run build # bundles the CLI to dist/cli/index.js
290
- npm test # 367 tests at v1.14.3 — parser, detectors, engine, policy, renderers
311
+ npm test # 368 tests at v1.14.3 — parser, detectors, engine, policy, renderers
291
312
  npm run typecheck
292
313
  ```
293
314
 
package/dist/cli/index.js CHANGED
@@ -510,7 +510,14 @@ function defaultPolicy(version = 1) {
510
510
  "**/.tamperward.yml",
511
511
  ".claude/settings.json",
512
512
  ".claude/settings.local.json",
513
- "**/.pre-commit-config.yaml"
513
+ "**/.pre-commit-config.yaml",
514
+ // CODEOWNERS is enforcement wiring, not documentation: it is the only
515
+ // thing that can require a human on a change to the workflow that
516
+ // decides whether the gate runs at all. GitHub reads it from any of
517
+ // these three locations. (H2.)
518
+ ".github/CODEOWNERS",
519
+ "CODEOWNERS",
520
+ "docs/CODEOWNERS"
514
521
  ]
515
522
  },
516
523
  rules: {
@@ -1114,6 +1121,13 @@ function policyWeakening(before, after) {
1114
1121
  if (ab < bb) {
1115
1122
  reasons.push(`verify.budget lowered ${bb}s \u2192 ${ab}s \u2014 starving the budget turns "cannot verify" into a pressure point`);
1116
1123
  }
1124
+ const stillInput = new Set(av.inputs ?? []);
1125
+ const droppedInputs = (bv.inputs ?? []).filter((g) => !stillInput.has(g));
1126
+ if (droppedInputs.length) {
1127
+ reasons.push(
1128
+ `verify.inputs narrowed (removed ${droppedInputs.join(", ")}) \u2014 the pristine run would execute the candidate's copy of those files`
1129
+ );
1130
+ }
1117
1131
  }
1118
1132
  }
1119
1133
  return reasons;
@@ -1624,7 +1638,16 @@ function parsePolicy(raw) {
1624
1638
  requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
1625
1639
  ledger: r.signoff?.ledger ?? base.signoff.ledger
1626
1640
  },
1627
- ...r.verify?.command ? { verify: { command: r.verify.command, budget: r.verify.budget ?? 300 } } : {}
1641
+ ...r.verify?.command ? {
1642
+ verify: {
1643
+ command: r.verify.command,
1644
+ budget: r.verify.budget ?? 300,
1645
+ // Rebuilding the block field-by-field means a field added later and
1646
+ // not listed here is SILENTLY DROPPED — which is how `inputs` first
1647
+ // shipped as a no-op that parsed, validated, and restored nothing.
1648
+ ...Array.isArray(r.verify.inputs) ? { inputs: r.verify.inputs.map(String) } : {}
1649
+ }
1650
+ } : {}
1628
1651
  };
1629
1652
  }
1630
1653
  function parseOrThrow(src, where) {
@@ -2418,20 +2441,27 @@ function transientFindings(events, persistentPaths, policy, finalHash) {
2418
2441
  }
2419
2442
 
2420
2443
  // src/cli/hook.ts
2444
+ var HookInputError = class extends Error {
2445
+ };
2421
2446
  function readStdin() {
2422
2447
  try {
2423
2448
  return readFileSync9(0, "utf8");
2424
- } catch {
2425
- return "";
2449
+ } catch (e) {
2450
+ throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
2426
2451
  }
2427
2452
  }
2428
2453
  function parseInput(raw) {
2429
2454
  if (!raw.trim()) return {};
2455
+ let parsed;
2430
2456
  try {
2431
- return JSON.parse(raw);
2432
- } catch {
2433
- return {};
2457
+ parsed = JSON.parse(raw);
2458
+ } catch (e) {
2459
+ throw new HookInputError(`the hook payload is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
2460
+ }
2461
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2462
+ throw new HookInputError(`the hook payload is not a JSON object (got ${Array.isArray(parsed) ? "an array" : typeof parsed})`);
2434
2463
  }
2464
+ return parsed;
2435
2465
  }
2436
2466
  function recordDenylog(blocks) {
2437
2467
  const log = process.env.TAMPERWARD_DENYLOG;
@@ -2610,16 +2640,30 @@ function emit(r) {
2610
2640
  if (r.stdout) process.stdout.write(r.stdout);
2611
2641
  return r.exitCode;
2612
2642
  }
2643
+ function preToolUseFromRaw(raw) {
2644
+ try {
2645
+ return preToolUseVerdict(parseInput(raw));
2646
+ } catch (e) {
2647
+ return failClosed("PreToolUse", errText(e));
2648
+ }
2649
+ }
2650
+ function stopFromRaw(raw) {
2651
+ try {
2652
+ return stopVerdict(parseInput(raw));
2653
+ } catch (e) {
2654
+ return failClosed("Stop", errText(e));
2655
+ }
2656
+ }
2613
2657
  function runHookClaude() {
2614
2658
  try {
2615
- return emit(preToolUseVerdict(parseInput(readStdin())));
2659
+ return emit(preToolUseFromRaw(readStdin()));
2616
2660
  } catch (e) {
2617
2661
  return emit(failClosed("PreToolUse", errText(e)));
2618
2662
  }
2619
2663
  }
2620
2664
  function runSweepClaude() {
2621
2665
  try {
2622
- return emit(stopVerdict(parseInput(readStdin())));
2666
+ return emit(stopFromRaw(readStdin()));
2623
2667
  } catch (e) {
2624
2668
  return emit(failClosed("Stop", errText(e)));
2625
2669
  }
@@ -2672,13 +2716,42 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
2672
2716
  }
2673
2717
 
2674
2718
  // src/cli/init.ts
2719
+ import { execFileSync as execFileSync4 } from "node:child_process";
2720
+ import { createHash as createHash5 } from "node:crypto";
2675
2721
  import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
2676
2722
  import { dirname as dirname3, join as join8 } from "node:path";
2677
2723
  import { fileURLToPath } from "node:url";
2678
2724
  var HOOK_CMD = "npx --yes tamperward hook claude";
2725
+ var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
2679
2726
  var SWEEP_CMD = "npx --yes tamperward sweep claude";
2680
2727
  var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
2681
2728
  var MARKER = "# tamperward: block agent shortcuts before they land";
2729
+ var CODEOWNERS_MARK = "# tamperward: the gate cannot guard the file that defines it";
2730
+ var CODEOWNERS_PATHS = ["/.github/workflows/", "/.tamperward.yml", "/.github/CODEOWNERS"];
2731
+ function coveredBy(existing, critical) {
2732
+ for (const line of existing.split("\n")) {
2733
+ const t = line.trim();
2734
+ if (!t || t.startsWith("#")) continue;
2735
+ const pattern = t.split(/\s+/)[0];
2736
+ if (!t.slice(pattern.length).trim()) continue;
2737
+ if (pattern === "*" || pattern === critical) return true;
2738
+ if (pattern.endsWith("/") && critical.startsWith(pattern)) return true;
2739
+ }
2740
+ return false;
2741
+ }
2742
+ function inferOwner(cwd) {
2743
+ try {
2744
+ const url = execFileSync4("git", ["remote", "get-url", "origin"], {
2745
+ cwd,
2746
+ encoding: "utf8",
2747
+ stdio: ["ignore", "pipe", "ignore"]
2748
+ }).trim();
2749
+ const m = url.match(/github\.com[:/]+([^/]+)\//);
2750
+ return m ? `@${m[1]}` : null;
2751
+ } catch {
2752
+ return null;
2753
+ }
2754
+ }
2682
2755
  var POLICY_CONTENT = `# Tamperward policy. The BASELINE (all rules, standard protected globs) applies even
2683
2756
  # without this file \u2014 everything here is an override, so an empty file changes nothing.
2684
2757
  # Docs: https://github.com/hexrift/tamperward#readme
@@ -2729,6 +2802,15 @@ jobs:
2729
2802
  tamperward:
2730
2803
  runs-on: ubuntu-latest
2731
2804
  timeout-minutes: 10
2805
+ # The gate is fetched from the registry AT GATE TIME, from inside the
2806
+ # checked-out pull request \u2014 and \`npm\` reads \`.npmrc\` from the working
2807
+ # directory. A PR that commits one line, \`registry=https://\u2026\`, chooses where
2808
+ # the authority judging it is downloaded from; the version pin below pins the
2809
+ # STRING, not the SOURCE. An environment variable outranks a project
2810
+ # \`.npmrc\` in npm's config precedence, so this is what makes the pin mean
2811
+ # something.
2812
+ env:
2813
+ NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
2732
2814
  steps:
2733
2815
  - uses: actions/checkout@v5
2734
2816
  with:
@@ -2762,6 +2844,30 @@ jobs:
2762
2844
  - name: Tamperward verify (pristine re-execution)
2763
2845
  run: npx --yes tamperward@${TW_VERSION} verify --require-ancestor --base "\${{ github.event.pull_request.base.sha }}"
2764
2846
  `;
2847
+ var WORKFLOW_MARK = "# tamperward:generated";
2848
+ var WORKFLOW_MARK_RE = /^# tamperward:generated v(\S+) sha256:([0-9a-f]+)\n/m;
2849
+ function bodyHash(body) {
2850
+ return createHash5("sha256").update(body).digest("hex").slice(0, 16);
2851
+ }
2852
+ function workflowFile(body) {
2853
+ return `${WORKFLOW_MARK} v${TW_VERSION} sha256:${bodyHash(body)}
2854
+ ${body}`;
2855
+ }
2856
+ function readWorkflowMark(src) {
2857
+ const m = src.match(WORKFLOW_MARK_RE);
2858
+ if (!m) return null;
2859
+ return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
2860
+ }
2861
+ function toolSet(matcher2) {
2862
+ const m = String(matcher2 ?? "").trim();
2863
+ if (m === "" || m === "*") return null;
2864
+ return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
2865
+ }
2866
+ function missingTools(matcher2) {
2867
+ const have = toolSet(matcher2);
2868
+ if (have === null) return [];
2869
+ return PRE_MATCHER.split("|").filter((t) => !have.has(t));
2870
+ }
2765
2871
  function planPolicy(cwd) {
2766
2872
  const path = join8(cwd, POLICY_FILE);
2767
2873
  if (existsSync7(path)) return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present \u2014 left untouched" };
@@ -2795,24 +2901,34 @@ function planClaudeHooks(cwd) {
2795
2901
  settings = parsed;
2796
2902
  }
2797
2903
  const hooks = settings.hooks ??= {};
2798
- const has = (arr, needle) => (arr ?? []).some((m) => (m.hooks ?? []).some((h) => String(h.command ?? "").includes(needle)));
2799
- const needPre = !has(hooks.PreToolUse, "tamperward hook claude");
2800
- const needStop = !has(hooks.Stop, "tamperward sweep claude");
2801
- if (!needPre && !needStop) return { item: "agent", path: rel, status: "ok", detail: "PreToolUse + Stop hooks already wired" };
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");
2906
+ const needPre = preEntries.length === 0;
2907
+ const needStop = entriesWith(hooks.Stop, "tamperward sweep claude").length === 0;
2908
+ const stale = preEntries.filter((m) => missingTools(m.matcher).length > 0);
2909
+ if (!needPre && !needStop && stale.length === 0) {
2910
+ return { item: "agent", path: rel, status: "ok", detail: "PreToolUse + Stop hooks already wired" };
2911
+ }
2912
+ const repairing = stale.length > 0 ? [...new Set(stale.flatMap((m) => missingTools(m.matcher)))] : [];
2802
2913
  return {
2803
2914
  item: "agent",
2804
2915
  path: rel,
2805
2916
  status: existsSync7(path) ? "update" : "create",
2806
- detail: `wire ${[needPre && "PreToolUse deny", needStop && "Stop sweep"].filter(Boolean).join(" + ")}`,
2917
+ detail: [
2918
+ needPre && "wire PreToolUse deny",
2919
+ needStop && "wire Stop sweep",
2920
+ repairing.length > 0 && `widen the PreToolUse matcher to cover ${repairing.join(", ")}`
2921
+ ].filter(Boolean).join(" + "),
2807
2922
  apply: () => {
2808
2923
  if (needPre) {
2809
2924
  (hooks.PreToolUse ??= []).push({
2810
- // NotebookEdit is modelled by the adapter but was absent here, so
2811
- // that branch was unreachable in the wiring we install. (P2-12.)
2812
- matcher: "Bash|Edit|Write|MultiEdit|NotebookEdit",
2925
+ matcher: PRE_MATCHER,
2813
2926
  hooks: [{ type: "command", command: HOOK_CMD }]
2814
2927
  });
2815
2928
  }
2929
+ for (const m of stale) {
2930
+ m.matcher = [...toolSet(m.matcher) ?? /* @__PURE__ */ new Set(), ...missingTools(m.matcher)].join("|");
2931
+ }
2816
2932
  if (needStop) {
2817
2933
  (hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
2818
2934
  }
@@ -2848,27 +2964,93 @@ ${line}` : existing.replace(/\n?$/, "\n") + line;
2848
2964
  }
2849
2965
  };
2850
2966
  }
2851
- function planWorkflow(cwd) {
2967
+ function planCodeowners(cwd) {
2968
+ const rel = ".github/CODEOWNERS";
2969
+ const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
2970
+ (r) => existsSync7(join8(cwd, r))
2971
+ );
2972
+ const path = join8(cwd, existingRel ?? rel);
2973
+ const existing = existingRel ? readFileSync10(path, "utf8") : null;
2974
+ const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
2975
+ if (missing.length === 0) {
2976
+ return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
2977
+ }
2978
+ const owner = inferOwner(cwd);
2979
+ const block = `
2980
+ ${CODEOWNERS_MARK}
2981
+ # A pull request that rewrites the workflow can keep the job name, replace the
2982
+ # gate with \`true\`, and still report a green required check. Only a human
2983
+ # requirement on these paths prevents that.
2984
+ ` + (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";
2985
+ return {
2986
+ item: "codeowners",
2987
+ path: existingRel ?? rel,
2988
+ status: existing === null ? "create" : "update",
2989
+ 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})`),
2990
+ apply: () => {
2991
+ mkdirSync5(dirname3(path), { recursive: true });
2992
+ writeFileSync5(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
2993
+ }
2994
+ };
2995
+ }
2996
+ function planWorkflow(cwd, force) {
2852
2997
  const rel = ".github/workflows/tamperward.yml";
2853
2998
  const path = join8(cwd, rel);
2854
- if (existsSync7(path)) return { item: "ci", path: rel, status: "ok", detail: "workflow already present \u2014 left untouched" };
2999
+ const write = () => {
3000
+ mkdirSync5(dirname3(path), { recursive: true });
3001
+ writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
3002
+ };
3003
+ if (!existsSync7(path)) {
3004
+ return {
3005
+ item: "ci",
3006
+ path: rel,
3007
+ status: "create",
3008
+ detail: "PR gate with out-of-band label sign-off (re-runs on labeled/unlabeled)",
3009
+ apply: write
3010
+ };
3011
+ }
3012
+ let src;
3013
+ try {
3014
+ src = readFileSync10(path, "utf8");
3015
+ } catch {
3016
+ return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
3017
+ }
3018
+ const current = bodyHash(WORKFLOW_CONTENT);
3019
+ const mark = readWorkflowMark(src);
3020
+ if (mark && bodyHash(mark.body) === current) {
3021
+ return { item: "ci", path: rel, status: "ok", detail: `generated by tamperward and current (v${mark.version})` };
3022
+ }
3023
+ if (mark && bodyHash(mark.body) === mark.hash) {
3024
+ return {
3025
+ item: "ci",
3026
+ path: rel,
3027
+ status: "update",
3028
+ detail: `generated by tamperward v${mark.version} and unmodified \u2014 migrating to the v${TW_VERSION} template`,
3029
+ apply: write
3030
+ };
3031
+ }
3032
+ if (force) {
3033
+ return {
3034
+ item: "ci",
3035
+ path: rel,
3036
+ status: "update",
3037
+ detail: mark ? `edited since tamperward v${mark.version} generated it \u2014 OVERWRITING (--force-workflow)` : "not generated by tamperward \u2014 OVERWRITING (--force-workflow)",
3038
+ apply: write
3039
+ };
3040
+ }
2855
3041
  return {
2856
3042
  item: "ci",
2857
3043
  path: rel,
2858
- status: "create",
2859
- detail: "PR gate with out-of-band label sign-off (re-runs on labeled/unlabeled)",
2860
- apply: () => {
2861
- mkdirSync5(dirname3(path), { recursive: true });
2862
- writeFileSync5(path, WORKFLOW_CONTENT);
2863
- }
3044
+ status: "skip",
3045
+ detail: (mark ? `edited since tamperward v${mark.version} generated it` : "present but not generated by tamperward") + ` \u2014 left untouched. Compare it against the v${TW_VERSION} template (an older one may lack the pristine-verify step) and re-run with --force-workflow to replace it.`
2864
3046
  };
2865
3047
  }
2866
- function planInit(cwd) {
2867
- return [planPolicy(cwd), planClaudeHooks(cwd), planPreCommit(cwd), planWorkflow(cwd)];
3048
+ function planInit(cwd, opts = {}) {
3049
+ return [planPolicy(cwd), planClaudeHooks(cwd), planPreCommit(cwd), planWorkflow(cwd, opts.forceWorkflow ?? false), planCodeowners(cwd)];
2868
3050
  }
2869
3051
  function runInit(opts) {
2870
3052
  const cwd = opts.cwd ?? process.cwd();
2871
- const plan = planInit(cwd);
3053
+ const plan = planInit(cwd, { forceWorkflow: opts.forceWorkflow });
2872
3054
  const w = process.stdout;
2873
3055
  for (const a of plan) {
2874
3056
  const verb = opts.dryRun && (a.status === "create" || a.status === "update") ? `would ${a.status}` : a.status;
@@ -2891,14 +3073,137 @@ tamperward init: ${changed} change(s) planned. Re-run without --dry-run to apply
2891
3073
  tamperward init: ${changed} change(s) applied. Commit them so the gate travels with the repo.
2892
3074
  `
2893
3075
  );
3076
+ w.write(
3077
+ '\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'
3078
+ );
2894
3079
  return 0;
2895
3080
  }
2896
3081
 
2897
3082
  // src/cli/verify.ts
2898
- import { execFileSync as execFileSync4, spawnSync } from "node:child_process";
2899
- import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync11, rmSync as rmSync2, statSync as statSync5, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "node:fs";
3083
+ import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
3084
+ import { createHash as createHash8 } from "node:crypto";
3085
+ 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";
2900
3086
  import { tmpdir as tmpdir2 } from "node:os";
2901
- import { dirname as dirname4, join as join9, resolve } from "node:path";
3087
+ import { dirname as dirname4, join as join11, resolve } from "node:path";
3088
+
3089
+ // src/git/trusted.ts
3090
+ import { execFileSync as execFileSync5 } from "node:child_process";
3091
+ import { createHash as createHash6 } from "node:crypto";
3092
+ import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
3093
+ import { isAbsolute as isAbsolute2, join as join9 } from "node:path";
3094
+ function trustedGitEnv() {
3095
+ return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
3096
+ }
3097
+ function gitDirOf(cwd) {
3098
+ try {
3099
+ const d = execFileSync5("git", ["rev-parse", "--git-dir"], {
3100
+ cwd,
3101
+ encoding: "utf8",
3102
+ stdio: ["ignore", "pipe", "pipe"],
3103
+ env: trustedGitEnv()
3104
+ }).trim();
3105
+ if (!d) return null;
3106
+ return isAbsolute2(d) ? d : join9(cwd, d);
3107
+ } catch {
3108
+ return null;
3109
+ }
3110
+ }
3111
+ function objectRewriteState(cwd) {
3112
+ const parts = [];
3113
+ try {
3114
+ parts.push(
3115
+ "replace:" + execFileSync5("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
3116
+ cwd,
3117
+ encoding: "utf8",
3118
+ stdio: ["ignore", "pipe", "pipe"],
3119
+ env: trustedGitEnv()
3120
+ }).trim()
3121
+ );
3122
+ } catch {
3123
+ parts.push("replace:<unreadable>");
3124
+ }
3125
+ const dir = gitDirOf(cwd);
3126
+ for (const rel of ["info/grafts", "shallow"]) {
3127
+ if (dir === null) {
3128
+ parts.push(`${rel}:<no-git-dir>`);
3129
+ continue;
3130
+ }
3131
+ const p = join9(dir, rel);
3132
+ try {
3133
+ parts.push(`${rel}:` + (existsSync8(p) ? readFileSync11(p, "utf8").trim() : ""));
3134
+ } catch {
3135
+ parts.push(`${rel}:<unreadable>`);
3136
+ }
3137
+ }
3138
+ return createHash6("sha256").update(parts.join("\n")).digest("hex");
3139
+ }
3140
+
3141
+ // src/fingerprint.ts
3142
+ import { execFileSync as execFileSync6 } from "node:child_process";
3143
+ import { createHash as createHash7 } from "node:crypto";
3144
+ import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as statSync5 } from "node:fs";
3145
+ import { join as join10 } from "node:path";
3146
+ function treeFingerprint(cwd) {
3147
+ const files = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
3148
+ cwd,
3149
+ encoding: "utf8",
3150
+ maxBuffer: 1 << 28,
3151
+ env: trustedGitEnv()
3152
+ }).split("\0").filter(Boolean).sort();
3153
+ const h = createHash7("sha256");
3154
+ for (const rel of files) {
3155
+ h.update(rel);
3156
+ h.update("\0");
3157
+ try {
3158
+ h.update(readFileSync12(join10(cwd, rel)));
3159
+ } catch {
3160
+ h.update("<unreadable>");
3161
+ }
3162
+ h.update("\0");
3163
+ }
3164
+ return h.digest("hex");
3165
+ }
3166
+ function depsFingerprint(cwd) {
3167
+ const root = join10(cwd, "node_modules");
3168
+ const h = createHash7("sha256");
3169
+ const walk = (dir) => {
3170
+ let entries;
3171
+ try {
3172
+ entries = readdirSync3(dir, { withFileTypes: true });
3173
+ } catch {
3174
+ return;
3175
+ }
3176
+ for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
3177
+ const p = join10(dir, e.name);
3178
+ if (e.isDirectory()) walk(p);
3179
+ else if (e.isFile()) {
3180
+ h.update(p);
3181
+ try {
3182
+ h.update(readFileSync12(p));
3183
+ h.update(String(statSync5(p).mode));
3184
+ } catch {
3185
+ h.update("<unreadable>");
3186
+ }
3187
+ } else if (e.isSymbolicLink()) {
3188
+ h.update(p);
3189
+ try {
3190
+ h.update(readlinkSync(p));
3191
+ } catch {
3192
+ h.update("<unreadable-link>");
3193
+ }
3194
+ }
3195
+ }
3196
+ };
3197
+ try {
3198
+ if (!statSync5(root).isDirectory()) return "none";
3199
+ } catch {
3200
+ return "none";
3201
+ }
3202
+ walk(root);
3203
+ return h.digest("hex");
3204
+ }
3205
+
3206
+ // src/cli/verify.ts
2902
3207
  var OVERLAY_CLASSES = ["tests", "snapshots", "config"];
2903
3208
  var VERIFICATION_SURFACE = [
2904
3209
  // Python / pytest — read from the rootdir, and conftest at any depth
@@ -2927,7 +3232,7 @@ var VERIFICATION_SURFACE = [
2927
3232
  "**/*.runsettings"
2928
3233
  ];
2929
3234
  function git2(args, cwd) {
2930
- return execFileSync4("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28 });
3235
+ return execFileSync7("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
2931
3236
  }
2932
3237
  function resolveBase(base, cwd) {
2933
3238
  assertRev(base);
@@ -2939,32 +3244,32 @@ function resolveBase(base, cwd) {
2939
3244
  }
2940
3245
  }
2941
3246
  function baseIsAncestorOfHead(base, cwd) {
2942
- const r = spawnSync("git", ["merge-base", "--is-ancestor", base, "HEAD"], { cwd });
3247
+ const r = spawnSync("git", ["merge-base", "--is-ancestor", base, "HEAD"], { cwd, env: trustedGitEnv() });
2943
3248
  return r.status === 0;
2944
3249
  }
2945
3250
  function materialize(cwd, dest) {
2946
3251
  const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
2947
3252
  for (const rel of listed) {
2948
- const src = join9(cwd, rel);
3253
+ const src = join11(cwd, rel);
2949
3254
  let st;
2950
3255
  try {
2951
- st = statSync5(src);
3256
+ st = statSync6(src);
2952
3257
  } catch {
2953
3258
  continue;
2954
3259
  }
2955
3260
  if (!st.isFile()) continue;
2956
- const out2 = join9(dest, rel);
3261
+ const out2 = join11(dest, rel);
2957
3262
  mkdirSync6(dirname4(out2), { recursive: true });
2958
3263
  dropSymlink(out2);
2959
3264
  if (lstatSync(src).isSymbolicLink()) {
2960
- writeFileSync6(out2, readFileSync11(src));
3265
+ writeFileSync6(out2, readFileSync13(src));
2961
3266
  } else {
2962
3267
  cpSync(src, out2, { dereference: true });
2963
3268
  }
2964
3269
  chmodSync2(out2, st.mode);
2965
3270
  }
2966
3271
  const nm = resolve(cwd, "node_modules");
2967
- if (existsSync8(nm)) symlinkSync(nm, join9(dest, "node_modules"), "dir");
3272
+ if (existsSync9(nm)) symlinkSync(nm, join11(dest, "node_modules"), "dir");
2968
3273
  }
2969
3274
  function dropSymlink(p) {
2970
3275
  try {
@@ -2972,29 +3277,77 @@ function dropSymlink(p) {
2972
3277
  } catch {
2973
3278
  }
2974
3279
  }
2975
- function overlayPristine(cwd, base, dest, policy) {
2976
- const atBase = git2(["ls-tree", "-r", "--name-only", "-z", base], cwd).split("\0").filter(Boolean);
2977
- const isOverlay = (p) => OVERLAY_CLASSES.some((c) => isProtected(p, policy, c)) || matchesAny(p, VERIFICATION_SURFACE);
2978
- let restored = 0;
3280
+ function verifierInputs(cmd, atBase, policy) {
3281
+ const baseFiles = new Set(atBase);
3282
+ const picked = /* @__PURE__ */ new Set();
3283
+ for (const raw of cmd.split(/[\s;&|()<>]+/)) {
3284
+ const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
3285
+ if (tok && baseFiles.has(tok)) picked.add(tok);
3286
+ }
3287
+ const globs = policy.verify?.inputs;
3288
+ if (globs && globs.length) {
3289
+ for (const rel of atBase) if (matchesAny(rel, globs)) picked.add(rel);
3290
+ }
3291
+ return picked;
3292
+ }
3293
+ function baseEntries(base, cwd) {
3294
+ return git2(["ls-tree", "-r", "-z", base], cwd).split("\0").filter(Boolean).map((rec) => {
3295
+ const tab = rec.indexOf(" ");
3296
+ const [mode, type] = rec.slice(0, tab).split(" ");
3297
+ return { mode, type, path: rec.slice(tab + 1) };
3298
+ });
3299
+ }
3300
+ function overlayDigest(dest, paths) {
3301
+ const h = createHash8("sha256");
3302
+ for (const rel of [...paths].sort()) {
3303
+ h.update(rel);
3304
+ h.update("\0");
3305
+ const p = join11(dest, rel);
3306
+ try {
3307
+ const st = lstatSync(p);
3308
+ h.update(String(st.mode));
3309
+ h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync13(p));
3310
+ } catch {
3311
+ h.update("<gone>");
3312
+ }
3313
+ h.update("\0");
3314
+ }
3315
+ return h.digest("hex");
3316
+ }
3317
+ function overlayPristine(cwd, base, dest, policy, cmd) {
3318
+ const entries = baseEntries(base, cwd);
3319
+ const atBase = entries.map((e) => e.path);
3320
+ const verifierOwned = verifierInputs(cmd, atBase, policy);
3321
+ const verifierGlobs = policy.verify?.inputs ?? [];
3322
+ const isOverlay = (p) => OVERLAY_CLASSES.some((c) => isProtected(p, policy, c)) || matchesAny(p, VERIFICATION_SURFACE) || verifierOwned.has(p) || verifierGlobs.length > 0 && matchesAny(p, verifierGlobs);
3323
+ const restored = [];
2979
3324
  const baseProtected = /* @__PURE__ */ new Set();
2980
- for (const rel of atBase) {
2981
- if (!isOverlay(rel)) continue;
2982
- baseProtected.add(rel);
2983
- const content = execFileSync4("git", ["show", `${base}:${rel}`], { cwd, maxBuffer: 1 << 28 });
2984
- const out2 = join9(dest, rel);
3325
+ for (const e of entries) {
3326
+ if (!isOverlay(e.path)) continue;
3327
+ baseProtected.add(e.path);
3328
+ if (e.type === "commit") {
3329
+ throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
3330
+ }
3331
+ const content = execFileSync7("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
3332
+ const out2 = join11(dest, e.path);
2985
3333
  mkdirSync6(dirname4(out2), { recursive: true });
2986
- dropSymlink(out2);
2987
- writeFileSync6(out2, content);
2988
- restored++;
3334
+ rmSync2(out2, { force: true });
3335
+ if (e.mode === "120000") {
3336
+ symlinkSync(content.toString("utf8"), out2);
3337
+ } else {
3338
+ writeFileSync6(out2, content);
3339
+ chmodSync2(out2, parseInt(e.mode.slice(-4), 8) & 511);
3340
+ }
3341
+ restored.push(e.path);
2989
3342
  }
2990
3343
  let removed = 0;
2991
3344
  const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
2992
3345
  for (const rel of inCopy) {
2993
3346
  if (!isOverlay(rel) || baseProtected.has(rel)) continue;
2994
- rmSync2(join9(dest, rel), { force: true });
3347
+ rmSync2(join11(dest, rel), { force: true });
2995
3348
  removed++;
2996
3349
  }
2997
- return [restored, removed];
3350
+ return { restored, removed };
2998
3351
  }
2999
3352
  function runSuite(dir, cmd, budgetSecs) {
3000
3353
  const t0 = Date.now();
@@ -3045,25 +3398,63 @@ function runVerify(opts) {
3045
3398
  out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
3046
3399
  return 2;
3047
3400
  }
3048
- const work = mkdtempSync2(join9(tmpdir2(), "tw-verify-"));
3049
- const visDir = join9(work, "visible");
3050
- const priDir = join9(work, "pristine");
3051
- let restored = 0;
3052
- let removedAdded = 0;
3401
+ const visRoot = mkdtempSync2(join11(tmpdir2(), "tw-verify-vis-"));
3402
+ const visDir = join11(visRoot, "t");
3403
+ const cleanup = (dirs) => {
3404
+ if (opts.keep) return;
3405
+ for (const d of dirs) rmSync2(d, { recursive: true, force: true });
3406
+ };
3407
+ const treeBefore = treeFingerprint(cwd);
3408
+ const depsBefore = depsFingerprint(cwd);
3053
3409
  try {
3054
3410
  mkdirSync6(visDir);
3055
- mkdirSync6(priDir);
3056
3411
  materialize(cwd, visDir);
3057
- materialize(cwd, priDir);
3058
- [restored, removedAdded] = overlayPristine(cwd, base, priDir, policy);
3059
3412
  } catch (e) {
3060
- if (!opts.keep) rmSync2(work, { recursive: true, force: true });
3413
+ cleanup([visRoot]);
3061
3414
  out2(`verify: could not materialize (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
3062
3415
  return 2;
3063
3416
  }
3064
3417
  const visible = runSuite(visDir, cmd, budget);
3418
+ if (treeFingerprint(cwd) !== treeBefore) {
3419
+ cleanup([visRoot]);
3420
+ out2("verify: the working tree changed while the visible suite was running \u2014 the pristine copy");
3421
+ out2("would be materialised from a tree the candidate just edited. Failing closed, not open.");
3422
+ return 2;
3423
+ }
3424
+ if (depsFingerprint(cwd) !== depsBefore) {
3425
+ cleanup([visRoot]);
3426
+ out2("verify: the installed dependency tree changed while the visible suite was running \u2014 the");
3427
+ out2("pristine run executes through it too. Failing closed, not open.");
3428
+ return 2;
3429
+ }
3430
+ const priRoot = mkdtempSync2(join11(tmpdir2(), "tw-verify-pri-"));
3431
+ const priDir = join11(priRoot, "t");
3432
+ let restored = [];
3433
+ let removedAdded = 0;
3434
+ try {
3435
+ mkdirSync6(priDir);
3436
+ materialize(cwd, priDir);
3437
+ ({ restored, removed: removedAdded } = overlayPristine(cwd, base, priDir, policy, cmd));
3438
+ } catch (e) {
3439
+ cleanup([visRoot, priRoot]);
3440
+ out2(`verify: could not materialize (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
3441
+ return 2;
3442
+ }
3443
+ const overlayBefore = overlayDigest(priDir, restored);
3065
3444
  const pristine = runSuite(priDir, cmd, budget);
3066
- if (!opts.keep) rmSync2(work, { recursive: true, force: true });
3445
+ const overlayMoved = overlayDigest(priDir, restored) !== overlayBefore;
3446
+ const treeMoved = treeFingerprint(cwd) !== treeBefore;
3447
+ const depsMoved = depsFingerprint(cwd) !== depsBefore;
3448
+ cleanup([visRoot, priRoot]);
3449
+ if (overlayMoved || treeMoved || depsMoved) {
3450
+ out2(
3451
+ "verify: " + (overlayMoved ? "a restored file in the pristine copy changed while the pristine suite was running" : treeMoved ? "the working tree changed while the pristine suite was running" : "the installed dependency tree changed while the pristine suite was running") + " \u2014"
3452
+ );
3453
+ out2("the verdict would describe something other than what ran. Failing closed, not open.");
3454
+ out2("(A suite that rewrites its own snapshots or test files in place will trip this; run it in");
3455
+ out2("whatever mode your runner calls CI, so verification observes rather than updates.)");
3456
+ return 2;
3457
+ }
3067
3458
  let verdict2;
3068
3459
  let code;
3069
3460
  if (visible.exit === null || pristine.exit === null) {
@@ -3088,15 +3479,15 @@ function runVerify(opts) {
3088
3479
  budget_secs: budget,
3089
3480
  visible: { exit: visible.exit, secs: visible.secs },
3090
3481
  pristine: { exit: pristine.exit, secs: pristine.secs },
3091
- protected_restored: restored,
3482
+ protected_restored: restored.length,
3092
3483
  added_protected_removed: removedAdded,
3093
- ...opts.keep ? { workdir: work } : {}
3484
+ ...opts.keep ? { visible_dir: visDir, pristine_dir: priDir } : {}
3094
3485
  })
3095
3486
  );
3096
3487
  } else {
3097
3488
  const lines = {
3098
- VERIFIED: `verified: the suite passes, and still passes with all ${restored} protected files restored from ${base.slice(0, 10)}.`,
3099
- MASKED_FAILURE: `MASKED FAILURE: the visible suite passes, but with the ${restored} protected files restored from ${base.slice(0, 10)} it FAILS (exit ${pristine.exit}). Something weakened the checks; the code does not pass the original suite.`,
3489
+ VERIFIED: `verified: the suite passes, and still passes with all ${restored.length} protected files restored from ${base.slice(0, 10)}.`,
3490
+ MASKED_FAILURE: `MASKED FAILURE: the visible suite passes, but with the ${restored.length} protected files restored from ${base.slice(0, 10)} it FAILS (exit ${pristine.exit}). Something weakened the checks; the code does not pass the original suite.`,
3100
3491
  SUITE_RED: `suite red: the visible suite fails (exit ${visible.exit}) \u2014 fix the code first (pristine exit ${pristine.exit}).`,
3101
3492
  BUDGET_EXCEEDED: `budget exceeded (${budget}s): could not verify \u2014 failing closed, not open.`
3102
3493
  };
@@ -3124,72 +3515,17 @@ function parseVerify(args) {
3124
3515
  }
3125
3516
 
3126
3517
  // src/cli/run.ts
3127
- import { execFileSync as execFileSync5, spawnSync as spawnSync2 } from "node:child_process";
3128
- import { createHash as createHash5 } from "node:crypto";
3129
- import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, realpathSync, statSync as statSync6 } from "node:fs";
3130
- import { join as join10, resolve as resolve2 } from "node:path";
3518
+ import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
3519
+ import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync } from "node:fs";
3520
+ import { resolve as resolve2 } from "node:path";
3131
3521
  var out = (s) => process.stdout.write(s + "\n");
3132
3522
  var err = (s) => process.stderr.write(s + "\n");
3133
3523
  function git3(args, cwd) {
3134
- return execFileSync5("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28 });
3135
- }
3136
- function treeFingerprint(cwd) {
3137
- const files = git3(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean).sort();
3138
- const h = createHash5("sha256");
3139
- for (const rel of files) {
3140
- h.update(rel);
3141
- h.update("\0");
3142
- try {
3143
- h.update(readFileSync12(join10(cwd, rel)));
3144
- } catch {
3145
- h.update("<unreadable>");
3146
- }
3147
- h.update("\0");
3148
- }
3149
- return h.digest("hex");
3150
- }
3151
- function depsFingerprint(cwd) {
3152
- const root = join10(cwd, "node_modules");
3153
- const h = createHash5("sha256");
3154
- const walk = (dir) => {
3155
- let entries;
3156
- try {
3157
- entries = readdirSync3(dir, { withFileTypes: true });
3158
- } catch {
3159
- return;
3160
- }
3161
- for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
3162
- const p = join10(dir, e.name);
3163
- if (e.isDirectory()) walk(p);
3164
- else if (e.isFile()) {
3165
- h.update(p);
3166
- try {
3167
- h.update(readFileSync12(p));
3168
- h.update(String(statSync6(p).mode));
3169
- } catch {
3170
- h.update("<unreadable>");
3171
- }
3172
- } else if (e.isSymbolicLink()) {
3173
- h.update(p);
3174
- try {
3175
- h.update(readlinkSync(p));
3176
- } catch {
3177
- h.update("<unreadable-link>");
3178
- }
3179
- }
3180
- }
3181
- };
3182
- try {
3183
- if (!statSync6(root).isDirectory()) return "none";
3184
- } catch {
3185
- return "none";
3186
- }
3187
- walk(root);
3188
- return h.digest("hex");
3524
+ return execFileSync8("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
3189
3525
  }
3190
3526
  function nowTicks() {
3191
3527
  try {
3192
- return Math.floor(parseFloat(readFileSync12("/proc/uptime", "utf8").split(" ")[0]) * 100);
3528
+ return Math.floor(parseFloat(readFileSync14("/proc/uptime", "utf8").split(" ")[0]) * 100);
3193
3529
  } catch {
3194
3530
  return Number.POSITIVE_INFINITY;
3195
3531
  }
@@ -3204,7 +3540,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
3204
3540
  }
3205
3541
  let pids;
3206
3542
  try {
3207
- pids = readdirSync3("/proc").filter((d) => /^\d+$/.test(d));
3543
+ pids = readdirSync4("/proc").filter((d) => /^\d+$/.test(d));
3208
3544
  } catch {
3209
3545
  return out2;
3210
3546
  }
@@ -3219,7 +3555,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
3219
3555
  }
3220
3556
  if (held !== real && !held.startsWith(real + "/")) continue;
3221
3557
  try {
3222
- const stat = readFileSync12(`/proc/${pid}/stat`, "utf8");
3558
+ const stat = readFileSync14(`/proc/${pid}/stat`, "utf8");
3223
3559
  const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
3224
3560
  if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
3225
3561
  } catch {
@@ -3266,11 +3602,30 @@ function runEnvelope(opts) {
3266
3602
  }
3267
3603
  out(`tamperward run \u2014 trusted base ${base.slice(0, 10)}; agent exit is untrusted.`);
3268
3604
  const depsBefore = depsFingerprint(cwd);
3605
+ const rewritesBefore = objectRewriteState(cwd);
3269
3606
  const spawnTicks = nowTicks();
3270
3607
  const agent = spawnSync2(opts.argv[0], opts.argv.slice(1), { cwd, stdio: "inherit" });
3271
3608
  const agentExit = agent.status ?? 1;
3609
+ if (objectRewriteState(cwd) !== rewritesBefore) {
3610
+ err("tamperward run: git object-replacement state changed during this run \u2014 a replace ref, graft");
3611
+ err("or shallow boundary now redirects what the trusted base resolves to. Nothing below could be");
3612
+ err("read against the anchor we started from. That is the finding.");
3613
+ out(`
3614
+ tamperward run \u2014 agent exit ${agentExit}; OBJECT_REWRITE \u2192 ENFORCEMENT_FAILED`);
3615
+ return 1;
3616
+ }
3617
+ if (depsFingerprint(cwd) !== depsBefore) {
3618
+ err("tamperward run: the installed dependency tree changed before adjudication began \u2014 the program");
3619
+ err("that runs the suite is not the one we started with, and both verification runs execute it.");
3620
+ if (!opts.allowDepDrift) {
3621
+ out(`
3622
+ tamperward run \u2014 agent exit ${agentExit}; DEPENDENCY_DRIFT \u2192 ENFORCEMENT_FAILED`);
3623
+ return 1;
3624
+ }
3625
+ err("(--allow-dep-drift: proceeding anyway, on the operator's judgement.)");
3626
+ }
3272
3627
  const head = git3(["rev-parse", "HEAD"], cwd).trim();
3273
- const isAncestor = spawnSync2("git", ["merge-base", "--is-ancestor", base, head], { cwd });
3628
+ const isAncestor = spawnSync2("git", ["merge-base", "--is-ancestor", base, head], { cwd, env: trustedGitEnv() });
3274
3629
  if (isAncestor.status !== 0) {
3275
3630
  err(`tamperward run: HEAD ${head.slice(0, 10)} is not a descendant of the trusted base ${base.slice(0, 10)} \u2014`);
3276
3631
  err("the agent rewrote history out from under the anchor. That is the finding.");
@@ -3291,7 +3646,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
3291
3646
  const verifyCode = runVerify({ cwd, base, cmd: frozenCmd, budget: frozenBudget, policyOverride: frozenPolicy });
3292
3647
  if (opts.settle && opts.settle > 0) {
3293
3648
  try {
3294
- execFileSync5("sleep", [String(opts.settle)]);
3649
+ execFileSync8("sleep", [String(opts.settle)]);
3295
3650
  } catch {
3296
3651
  }
3297
3652
  }
@@ -3305,16 +3660,21 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
3305
3660
  err("tamperward run: the installed dependency tree changed during this run \u2014 the program that");
3306
3661
  err("runs the suite is not the one we started with. Both verification runs execute it.");
3307
3662
  }
3663
+ const rewrote = objectRewriteState(cwd) !== rewritesBefore;
3664
+ if (rewrote) {
3665
+ err("tamperward run: git object-replacement state changed WHILE the tree was being adjudicated \u2014");
3666
+ err("the checks above read the trusted base through an object layer that has since moved.");
3667
+ }
3308
3668
  const survivors = survivorsHoldingTree(cwd, spawnTicks);
3309
3669
  if (survivors.length) {
3310
3670
  err(`tamperward run: ${survivors.length} process(es) started during this run still hold the`);
3311
3671
  err(`working tree (pid ${survivors.join(", ")}). A verdict cannot outlive the tree it describes.`);
3312
3672
  }
3313
- const notQuiescent = mutatedDuringAdjudication || survivors.length > 0 || depsDrifted;
3673
+ const notQuiescent = mutatedDuringAdjudication || survivors.length > 0 || depsDrifted || rewrote;
3314
3674
  const cannot = diffCode === 2 || workCode === 2 || verifyCode === 2;
3315
3675
  const blocked = diffCode === 1 || workCode === 1 || verifyCode === 1 || notQuiescent;
3316
3676
  const enforcement = cannot ? 2 : blocked ? 1 : 0;
3317
- const verdict2 = enforcement === 2 ? "CANNOT_ADJUDICATE (fail closed)" : depsDrifted ? "DEPENDENCY_DRIFT \u2014 the suite runner changed under the envelope" : notQuiescent ? "NOT_QUIESCENT \u2014 the tree moved, or something still holds it" : enforcement === 1 ? "ENFORCEMENT_FAILED \u2014 the tree the runtime released does not stand" : agentExit !== 0 ? `agent exited ${agentExit} (enforcement clean)` : "GREEN MEANS GREEN";
3677
+ const verdict2 = enforcement === 2 ? "CANNOT_ADJUDICATE (fail closed)" : rewrote ? "OBJECT_REWRITE \u2014 the base the checks read is not the base we anchored to" : depsDrifted ? "DEPENDENCY_DRIFT \u2014 the suite runner changed under the envelope" : notQuiescent ? "NOT_QUIESCENT \u2014 the tree moved, or something still holds it" : enforcement === 1 ? "ENFORCEMENT_FAILED \u2014 the tree the runtime released does not stand" : agentExit !== 0 ? `agent exited ${agentExit} (enforcement clean)` : "GREEN MEANS GREEN";
3318
3678
  out(`
3319
3679
  tamperward run \u2014 agent exit ${agentExit}; checks diff=${diffCode} worktree=${workCode} verify=${verifyCode} \u2192 ${verdict2}`);
3320
3680
  return enforcement !== 0 ? enforcement : agentExit;
@@ -3368,6 +3728,7 @@ function parseInit(args) {
3368
3728
  const a = args[i];
3369
3729
  if (a === "--cwd") o.cwd = args[++i];
3370
3730
  else if (a === "--dry-run") o.dryRun = true;
3731
+ else if (a === "--force-workflow") o.forceWorkflow = true;
3371
3732
  }
3372
3733
  return o;
3373
3734
  }
@@ -3431,9 +3792,15 @@ Formats:
3431
3792
  adjudicate (fails closed)
3432
3793
  tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
3433
3794
  tamperward init [--dry-run] wire the policy file plus every
3434
- enforcement point: Claude Code hooks,
3795
+ [--force-workflow] enforcement point: Claude Code hooks,
3435
3796
  pre-commit, CI. Idempotent; never
3436
- overwrites your files.
3797
+ overwrites your files. Re-running it
3798
+ MIGRATES a CI workflow it generated and
3799
+ you have not edited, and widens a
3800
+ PreToolUse matcher that no longer covers
3801
+ every tool the gate must see.
3802
+ --force-workflow replaces a workflow it
3803
+ did not write, or one you have edited.
3437
3804
 
3438
3805
  Exit code: check \u2192 1 if any blocking finding. hook/sweep \u2192 always 0; a deny is
3439
3806
  emitted as JSON on stdout (exit 2 makes Claude Code ignore the JSON).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "1.14.3",
3
+ "version": "1.14.6",
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",