tamperward 1.14.3 → 1.14.5

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 +14 -3
  2. package/dist/cli/index.js +434 -133
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -185,11 +185,20 @@ A real deployment needs a verify command configured — the generated CI verify
185
185
  verify:
186
186
  command: npm test
187
187
  budget: 300
188
+ inputs: ['scripts/**'] # what the command DELEGATES to
188
189
  ```
189
190
 
190
191
  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.
192
+ narrowing `inputs`, or removing it is flagged as policy weakening — a verifier an
193
+ agent can point at `true` is no verification at all.
194
+
195
+ `inputs` names the files the command *executes*, so the pristine run gets the
196
+ base's copy of them too. A command token that names a file present at the base is
197
+ picked up automatically (`node runner.js`), so most repositories need nothing
198
+ here. Delegation is what needs the list: `npm test` names no file, and the base's
199
+ restored `"test": "sh scripts/test.sh"` will happily call a script nothing
200
+ restored. It bounds the class rather than closing it — see
201
+ [the threat model](./docs/THREAT-MODEL-pristine-run.md).
193
202
 
194
203
  The four primitives:
195
204
 
@@ -263,6 +272,8 @@ and its totals.
263
272
  | 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
273
  | 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
274
  | 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 |
275
+ | 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
+ | 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
277
  | 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
278
  | 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
279
  | 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 +298,7 @@ via npm trusted publishing with SLSA provenance. Full rule:
287
298
 
288
299
  ```bash
289
300
  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
301
+ npm test # 368 tests at v1.14.3 — parser, detectors, engine, policy, renderers
291
302
  npm run typecheck
292
303
  ```
293
304
 
package/dist/cli/index.js CHANGED
@@ -1114,6 +1114,13 @@ function policyWeakening(before, after) {
1114
1114
  if (ab < bb) {
1115
1115
  reasons.push(`verify.budget lowered ${bb}s \u2192 ${ab}s \u2014 starving the budget turns "cannot verify" into a pressure point`);
1116
1116
  }
1117
+ const stillInput = new Set(av.inputs ?? []);
1118
+ const droppedInputs = (bv.inputs ?? []).filter((g) => !stillInput.has(g));
1119
+ if (droppedInputs.length) {
1120
+ reasons.push(
1121
+ `verify.inputs narrowed (removed ${droppedInputs.join(", ")}) \u2014 the pristine run would execute the candidate's copy of those files`
1122
+ );
1123
+ }
1117
1124
  }
1118
1125
  }
1119
1126
  return reasons;
@@ -1624,7 +1631,16 @@ function parsePolicy(raw) {
1624
1631
  requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
1625
1632
  ledger: r.signoff?.ledger ?? base.signoff.ledger
1626
1633
  },
1627
- ...r.verify?.command ? { verify: { command: r.verify.command, budget: r.verify.budget ?? 300 } } : {}
1634
+ ...r.verify?.command ? {
1635
+ verify: {
1636
+ command: r.verify.command,
1637
+ budget: r.verify.budget ?? 300,
1638
+ // Rebuilding the block field-by-field means a field added later and
1639
+ // not listed here is SILENTLY DROPPED — which is how `inputs` first
1640
+ // shipped as a no-op that parsed, validated, and restored nothing.
1641
+ ...Array.isArray(r.verify.inputs) ? { inputs: r.verify.inputs.map(String) } : {}
1642
+ }
1643
+ } : {}
1628
1644
  };
1629
1645
  }
1630
1646
  function parseOrThrow(src, where) {
@@ -2418,20 +2434,27 @@ function transientFindings(events, persistentPaths, policy, finalHash) {
2418
2434
  }
2419
2435
 
2420
2436
  // src/cli/hook.ts
2437
+ var HookInputError = class extends Error {
2438
+ };
2421
2439
  function readStdin() {
2422
2440
  try {
2423
2441
  return readFileSync9(0, "utf8");
2424
- } catch {
2425
- return "";
2442
+ } catch (e) {
2443
+ throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
2426
2444
  }
2427
2445
  }
2428
2446
  function parseInput(raw) {
2429
2447
  if (!raw.trim()) return {};
2448
+ let parsed;
2430
2449
  try {
2431
- return JSON.parse(raw);
2432
- } catch {
2433
- return {};
2450
+ parsed = JSON.parse(raw);
2451
+ } catch (e) {
2452
+ throw new HookInputError(`the hook payload is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
2453
+ }
2454
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2455
+ throw new HookInputError(`the hook payload is not a JSON object (got ${Array.isArray(parsed) ? "an array" : typeof parsed})`);
2434
2456
  }
2457
+ return parsed;
2435
2458
  }
2436
2459
  function recordDenylog(blocks) {
2437
2460
  const log = process.env.TAMPERWARD_DENYLOG;
@@ -2610,16 +2633,30 @@ function emit(r) {
2610
2633
  if (r.stdout) process.stdout.write(r.stdout);
2611
2634
  return r.exitCode;
2612
2635
  }
2636
+ function preToolUseFromRaw(raw) {
2637
+ try {
2638
+ return preToolUseVerdict(parseInput(raw));
2639
+ } catch (e) {
2640
+ return failClosed("PreToolUse", errText(e));
2641
+ }
2642
+ }
2643
+ function stopFromRaw(raw) {
2644
+ try {
2645
+ return stopVerdict(parseInput(raw));
2646
+ } catch (e) {
2647
+ return failClosed("Stop", errText(e));
2648
+ }
2649
+ }
2613
2650
  function runHookClaude() {
2614
2651
  try {
2615
- return emit(preToolUseVerdict(parseInput(readStdin())));
2652
+ return emit(preToolUseFromRaw(readStdin()));
2616
2653
  } catch (e) {
2617
2654
  return emit(failClosed("PreToolUse", errText(e)));
2618
2655
  }
2619
2656
  }
2620
2657
  function runSweepClaude() {
2621
2658
  try {
2622
- return emit(stopVerdict(parseInput(readStdin())));
2659
+ return emit(stopFromRaw(readStdin()));
2623
2660
  } catch (e) {
2624
2661
  return emit(failClosed("Stop", errText(e)));
2625
2662
  }
@@ -2672,10 +2709,12 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
2672
2709
  }
2673
2710
 
2674
2711
  // src/cli/init.ts
2712
+ import { createHash as createHash5 } from "node:crypto";
2675
2713
  import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
2676
2714
  import { dirname as dirname3, join as join8 } from "node:path";
2677
2715
  import { fileURLToPath } from "node:url";
2678
2716
  var HOOK_CMD = "npx --yes tamperward hook claude";
2717
+ var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
2679
2718
  var SWEEP_CMD = "npx --yes tamperward sweep claude";
2680
2719
  var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
2681
2720
  var MARKER = "# tamperward: block agent shortcuts before they land";
@@ -2729,6 +2768,15 @@ jobs:
2729
2768
  tamperward:
2730
2769
  runs-on: ubuntu-latest
2731
2770
  timeout-minutes: 10
2771
+ # The gate is fetched from the registry AT GATE TIME, from inside the
2772
+ # checked-out pull request \u2014 and \`npm\` reads \`.npmrc\` from the working
2773
+ # directory. A PR that commits one line, \`registry=https://\u2026\`, chooses where
2774
+ # the authority judging it is downloaded from; the version pin below pins the
2775
+ # STRING, not the SOURCE. An environment variable outranks a project
2776
+ # \`.npmrc\` in npm's config precedence, so this is what makes the pin mean
2777
+ # something.
2778
+ env:
2779
+ NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
2732
2780
  steps:
2733
2781
  - uses: actions/checkout@v5
2734
2782
  with:
@@ -2762,6 +2810,30 @@ jobs:
2762
2810
  - name: Tamperward verify (pristine re-execution)
2763
2811
  run: npx --yes tamperward@${TW_VERSION} verify --require-ancestor --base "\${{ github.event.pull_request.base.sha }}"
2764
2812
  `;
2813
+ var WORKFLOW_MARK = "# tamperward:generated";
2814
+ var WORKFLOW_MARK_RE = /^# tamperward:generated v(\S+) sha256:([0-9a-f]+)\n/m;
2815
+ function bodyHash(body) {
2816
+ return createHash5("sha256").update(body).digest("hex").slice(0, 16);
2817
+ }
2818
+ function workflowFile(body) {
2819
+ return `${WORKFLOW_MARK} v${TW_VERSION} sha256:${bodyHash(body)}
2820
+ ${body}`;
2821
+ }
2822
+ function readWorkflowMark(src) {
2823
+ const m = src.match(WORKFLOW_MARK_RE);
2824
+ if (!m) return null;
2825
+ return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
2826
+ }
2827
+ function toolSet(matcher2) {
2828
+ const m = String(matcher2 ?? "").trim();
2829
+ if (m === "" || m === "*") return null;
2830
+ return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
2831
+ }
2832
+ function missingTools(matcher2) {
2833
+ const have = toolSet(matcher2);
2834
+ if (have === null) return [];
2835
+ return PRE_MATCHER.split("|").filter((t) => !have.has(t));
2836
+ }
2765
2837
  function planPolicy(cwd) {
2766
2838
  const path = join8(cwd, POLICY_FILE);
2767
2839
  if (existsSync7(path)) return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present \u2014 left untouched" };
@@ -2795,24 +2867,34 @@ function planClaudeHooks(cwd) {
2795
2867
  settings = parsed;
2796
2868
  }
2797
2869
  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" };
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");
2872
+ const needPre = preEntries.length === 0;
2873
+ const needStop = entriesWith(hooks.Stop, "tamperward sweep claude").length === 0;
2874
+ const stale = preEntries.filter((m) => missingTools(m.matcher).length > 0);
2875
+ if (!needPre && !needStop && stale.length === 0) {
2876
+ return { item: "agent", path: rel, status: "ok", detail: "PreToolUse + Stop hooks already wired" };
2877
+ }
2878
+ const repairing = stale.length > 0 ? [...new Set(stale.flatMap((m) => missingTools(m.matcher)))] : [];
2802
2879
  return {
2803
2880
  item: "agent",
2804
2881
  path: rel,
2805
2882
  status: existsSync7(path) ? "update" : "create",
2806
- detail: `wire ${[needPre && "PreToolUse deny", needStop && "Stop sweep"].filter(Boolean).join(" + ")}`,
2883
+ detail: [
2884
+ needPre && "wire PreToolUse deny",
2885
+ needStop && "wire Stop sweep",
2886
+ repairing.length > 0 && `widen the PreToolUse matcher to cover ${repairing.join(", ")}`
2887
+ ].filter(Boolean).join(" + "),
2807
2888
  apply: () => {
2808
2889
  if (needPre) {
2809
2890
  (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",
2891
+ matcher: PRE_MATCHER,
2813
2892
  hooks: [{ type: "command", command: HOOK_CMD }]
2814
2893
  });
2815
2894
  }
2895
+ for (const m of stale) {
2896
+ m.matcher = [...toolSet(m.matcher) ?? /* @__PURE__ */ new Set(), ...missingTools(m.matcher)].join("|");
2897
+ }
2816
2898
  if (needStop) {
2817
2899
  (hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
2818
2900
  }
@@ -2848,27 +2930,64 @@ ${line}` : existing.replace(/\n?$/, "\n") + line;
2848
2930
  }
2849
2931
  };
2850
2932
  }
2851
- function planWorkflow(cwd) {
2933
+ function planWorkflow(cwd, force) {
2852
2934
  const rel = ".github/workflows/tamperward.yml";
2853
2935
  const path = join8(cwd, rel);
2854
- if (existsSync7(path)) return { item: "ci", path: rel, status: "ok", detail: "workflow already present \u2014 left untouched" };
2936
+ const write = () => {
2937
+ mkdirSync5(dirname3(path), { recursive: true });
2938
+ writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
2939
+ };
2940
+ if (!existsSync7(path)) {
2941
+ return {
2942
+ item: "ci",
2943
+ path: rel,
2944
+ status: "create",
2945
+ detail: "PR gate with out-of-band label sign-off (re-runs on labeled/unlabeled)",
2946
+ apply: write
2947
+ };
2948
+ }
2949
+ let src;
2950
+ try {
2951
+ src = readFileSync10(path, "utf8");
2952
+ } catch {
2953
+ return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
2954
+ }
2955
+ const current = bodyHash(WORKFLOW_CONTENT);
2956
+ const mark = readWorkflowMark(src);
2957
+ if (mark && bodyHash(mark.body) === current) {
2958
+ return { item: "ci", path: rel, status: "ok", detail: `generated by tamperward and current (v${mark.version})` };
2959
+ }
2960
+ if (mark && bodyHash(mark.body) === mark.hash) {
2961
+ return {
2962
+ item: "ci",
2963
+ path: rel,
2964
+ status: "update",
2965
+ detail: `generated by tamperward v${mark.version} and unmodified \u2014 migrating to the v${TW_VERSION} template`,
2966
+ apply: write
2967
+ };
2968
+ }
2969
+ if (force) {
2970
+ return {
2971
+ item: "ci",
2972
+ path: rel,
2973
+ status: "update",
2974
+ detail: mark ? `edited since tamperward v${mark.version} generated it \u2014 OVERWRITING (--force-workflow)` : "not generated by tamperward \u2014 OVERWRITING (--force-workflow)",
2975
+ apply: write
2976
+ };
2977
+ }
2855
2978
  return {
2856
2979
  item: "ci",
2857
2980
  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
- }
2981
+ status: "skip",
2982
+ 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
2983
  };
2865
2984
  }
2866
- function planInit(cwd) {
2867
- return [planPolicy(cwd), planClaudeHooks(cwd), planPreCommit(cwd), planWorkflow(cwd)];
2985
+ function planInit(cwd, opts = {}) {
2986
+ return [planPolicy(cwd), planClaudeHooks(cwd), planPreCommit(cwd), planWorkflow(cwd, opts.forceWorkflow ?? false)];
2868
2987
  }
2869
2988
  function runInit(opts) {
2870
2989
  const cwd = opts.cwd ?? process.cwd();
2871
- const plan = planInit(cwd);
2990
+ const plan = planInit(cwd, { forceWorkflow: opts.forceWorkflow });
2872
2991
  const w = process.stdout;
2873
2992
  for (const a of plan) {
2874
2993
  const verb = opts.dryRun && (a.status === "create" || a.status === "update") ? `would ${a.status}` : a.status;
@@ -2895,10 +3014,130 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
2895
3014
  }
2896
3015
 
2897
3016
  // 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";
3017
+ import { execFileSync as execFileSync6, spawnSync } from "node:child_process";
3018
+ import { createHash as createHash8 } from "node:crypto";
3019
+ 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
3020
  import { tmpdir as tmpdir2 } from "node:os";
2901
- import { dirname as dirname4, join as join9, resolve } from "node:path";
3021
+ import { dirname as dirname4, join as join11, resolve } from "node:path";
3022
+
3023
+ // src/git/trusted.ts
3024
+ import { execFileSync as execFileSync4 } from "node:child_process";
3025
+ import { createHash as createHash6 } from "node:crypto";
3026
+ import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
3027
+ import { isAbsolute as isAbsolute2, join as join9 } from "node:path";
3028
+ function trustedGitEnv() {
3029
+ return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
3030
+ }
3031
+ function gitDirOf(cwd) {
3032
+ try {
3033
+ const d = execFileSync4("git", ["rev-parse", "--git-dir"], {
3034
+ cwd,
3035
+ encoding: "utf8",
3036
+ stdio: ["ignore", "pipe", "pipe"],
3037
+ env: trustedGitEnv()
3038
+ }).trim();
3039
+ if (!d) return null;
3040
+ return isAbsolute2(d) ? d : join9(cwd, d);
3041
+ } catch {
3042
+ return null;
3043
+ }
3044
+ }
3045
+ function objectRewriteState(cwd) {
3046
+ const parts = [];
3047
+ try {
3048
+ parts.push(
3049
+ "replace:" + execFileSync4("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
3050
+ cwd,
3051
+ encoding: "utf8",
3052
+ stdio: ["ignore", "pipe", "pipe"],
3053
+ env: trustedGitEnv()
3054
+ }).trim()
3055
+ );
3056
+ } catch {
3057
+ parts.push("replace:<unreadable>");
3058
+ }
3059
+ const dir = gitDirOf(cwd);
3060
+ for (const rel of ["info/grafts", "shallow"]) {
3061
+ if (dir === null) {
3062
+ parts.push(`${rel}:<no-git-dir>`);
3063
+ continue;
3064
+ }
3065
+ const p = join9(dir, rel);
3066
+ try {
3067
+ parts.push(`${rel}:` + (existsSync8(p) ? readFileSync11(p, "utf8").trim() : ""));
3068
+ } catch {
3069
+ parts.push(`${rel}:<unreadable>`);
3070
+ }
3071
+ }
3072
+ return createHash6("sha256").update(parts.join("\n")).digest("hex");
3073
+ }
3074
+
3075
+ // src/fingerprint.ts
3076
+ import { execFileSync as execFileSync5 } from "node:child_process";
3077
+ import { createHash as createHash7 } from "node:crypto";
3078
+ import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as statSync5 } from "node:fs";
3079
+ import { join as join10 } from "node:path";
3080
+ function treeFingerprint(cwd) {
3081
+ const files = execFileSync5("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
3082
+ cwd,
3083
+ encoding: "utf8",
3084
+ maxBuffer: 1 << 28,
3085
+ env: trustedGitEnv()
3086
+ }).split("\0").filter(Boolean).sort();
3087
+ const h = createHash7("sha256");
3088
+ for (const rel of files) {
3089
+ h.update(rel);
3090
+ h.update("\0");
3091
+ try {
3092
+ h.update(readFileSync12(join10(cwd, rel)));
3093
+ } catch {
3094
+ h.update("<unreadable>");
3095
+ }
3096
+ h.update("\0");
3097
+ }
3098
+ return h.digest("hex");
3099
+ }
3100
+ function depsFingerprint(cwd) {
3101
+ const root = join10(cwd, "node_modules");
3102
+ const h = createHash7("sha256");
3103
+ const walk = (dir) => {
3104
+ let entries;
3105
+ try {
3106
+ entries = readdirSync3(dir, { withFileTypes: true });
3107
+ } catch {
3108
+ return;
3109
+ }
3110
+ for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
3111
+ const p = join10(dir, e.name);
3112
+ if (e.isDirectory()) walk(p);
3113
+ else if (e.isFile()) {
3114
+ h.update(p);
3115
+ try {
3116
+ h.update(readFileSync12(p));
3117
+ h.update(String(statSync5(p).mode));
3118
+ } catch {
3119
+ h.update("<unreadable>");
3120
+ }
3121
+ } else if (e.isSymbolicLink()) {
3122
+ h.update(p);
3123
+ try {
3124
+ h.update(readlinkSync(p));
3125
+ } catch {
3126
+ h.update("<unreadable-link>");
3127
+ }
3128
+ }
3129
+ }
3130
+ };
3131
+ try {
3132
+ if (!statSync5(root).isDirectory()) return "none";
3133
+ } catch {
3134
+ return "none";
3135
+ }
3136
+ walk(root);
3137
+ return h.digest("hex");
3138
+ }
3139
+
3140
+ // src/cli/verify.ts
2902
3141
  var OVERLAY_CLASSES = ["tests", "snapshots", "config"];
2903
3142
  var VERIFICATION_SURFACE = [
2904
3143
  // Python / pytest — read from the rootdir, and conftest at any depth
@@ -2927,7 +3166,7 @@ var VERIFICATION_SURFACE = [
2927
3166
  "**/*.runsettings"
2928
3167
  ];
2929
3168
  function git2(args, cwd) {
2930
- return execFileSync4("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28 });
3169
+ return execFileSync6("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
2931
3170
  }
2932
3171
  function resolveBase(base, cwd) {
2933
3172
  assertRev(base);
@@ -2939,32 +3178,32 @@ function resolveBase(base, cwd) {
2939
3178
  }
2940
3179
  }
2941
3180
  function baseIsAncestorOfHead(base, cwd) {
2942
- const r = spawnSync("git", ["merge-base", "--is-ancestor", base, "HEAD"], { cwd });
3181
+ const r = spawnSync("git", ["merge-base", "--is-ancestor", base, "HEAD"], { cwd, env: trustedGitEnv() });
2943
3182
  return r.status === 0;
2944
3183
  }
2945
3184
  function materialize(cwd, dest) {
2946
3185
  const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
2947
3186
  for (const rel of listed) {
2948
- const src = join9(cwd, rel);
3187
+ const src = join11(cwd, rel);
2949
3188
  let st;
2950
3189
  try {
2951
- st = statSync5(src);
3190
+ st = statSync6(src);
2952
3191
  } catch {
2953
3192
  continue;
2954
3193
  }
2955
3194
  if (!st.isFile()) continue;
2956
- const out2 = join9(dest, rel);
3195
+ const out2 = join11(dest, rel);
2957
3196
  mkdirSync6(dirname4(out2), { recursive: true });
2958
3197
  dropSymlink(out2);
2959
3198
  if (lstatSync(src).isSymbolicLink()) {
2960
- writeFileSync6(out2, readFileSync11(src));
3199
+ writeFileSync6(out2, readFileSync13(src));
2961
3200
  } else {
2962
3201
  cpSync(src, out2, { dereference: true });
2963
3202
  }
2964
3203
  chmodSync2(out2, st.mode);
2965
3204
  }
2966
3205
  const nm = resolve(cwd, "node_modules");
2967
- if (existsSync8(nm)) symlinkSync(nm, join9(dest, "node_modules"), "dir");
3206
+ if (existsSync9(nm)) symlinkSync(nm, join11(dest, "node_modules"), "dir");
2968
3207
  }
2969
3208
  function dropSymlink(p) {
2970
3209
  try {
@@ -2972,29 +3211,77 @@ function dropSymlink(p) {
2972
3211
  } catch {
2973
3212
  }
2974
3213
  }
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;
3214
+ function verifierInputs(cmd, atBase, policy) {
3215
+ const baseFiles = new Set(atBase);
3216
+ const picked = /* @__PURE__ */ new Set();
3217
+ for (const raw of cmd.split(/[\s;&|()<>]+/)) {
3218
+ const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
3219
+ if (tok && baseFiles.has(tok)) picked.add(tok);
3220
+ }
3221
+ const globs = policy.verify?.inputs;
3222
+ if (globs && globs.length) {
3223
+ for (const rel of atBase) if (matchesAny(rel, globs)) picked.add(rel);
3224
+ }
3225
+ return picked;
3226
+ }
3227
+ function baseEntries(base, cwd) {
3228
+ return git2(["ls-tree", "-r", "-z", base], cwd).split("\0").filter(Boolean).map((rec) => {
3229
+ const tab = rec.indexOf(" ");
3230
+ const [mode, type] = rec.slice(0, tab).split(" ");
3231
+ return { mode, type, path: rec.slice(tab + 1) };
3232
+ });
3233
+ }
3234
+ function overlayDigest(dest, paths) {
3235
+ const h = createHash8("sha256");
3236
+ for (const rel of [...paths].sort()) {
3237
+ h.update(rel);
3238
+ h.update("\0");
3239
+ const p = join11(dest, rel);
3240
+ try {
3241
+ const st = lstatSync(p);
3242
+ h.update(String(st.mode));
3243
+ h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync13(p));
3244
+ } catch {
3245
+ h.update("<gone>");
3246
+ }
3247
+ h.update("\0");
3248
+ }
3249
+ return h.digest("hex");
3250
+ }
3251
+ function overlayPristine(cwd, base, dest, policy, cmd) {
3252
+ const entries = baseEntries(base, cwd);
3253
+ const atBase = entries.map((e) => e.path);
3254
+ const verifierOwned = verifierInputs(cmd, atBase, policy);
3255
+ const verifierGlobs = policy.verify?.inputs ?? [];
3256
+ const isOverlay = (p) => OVERLAY_CLASSES.some((c) => isProtected(p, policy, c)) || matchesAny(p, VERIFICATION_SURFACE) || verifierOwned.has(p) || verifierGlobs.length > 0 && matchesAny(p, verifierGlobs);
3257
+ const restored = [];
2979
3258
  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);
3259
+ for (const e of entries) {
3260
+ if (!isOverlay(e.path)) continue;
3261
+ baseProtected.add(e.path);
3262
+ if (e.type === "commit") {
3263
+ throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
3264
+ }
3265
+ const content = execFileSync6("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
3266
+ const out2 = join11(dest, e.path);
2985
3267
  mkdirSync6(dirname4(out2), { recursive: true });
2986
- dropSymlink(out2);
2987
- writeFileSync6(out2, content);
2988
- restored++;
3268
+ rmSync2(out2, { force: true });
3269
+ if (e.mode === "120000") {
3270
+ symlinkSync(content.toString("utf8"), out2);
3271
+ } else {
3272
+ writeFileSync6(out2, content);
3273
+ chmodSync2(out2, parseInt(e.mode.slice(-4), 8) & 511);
3274
+ }
3275
+ restored.push(e.path);
2989
3276
  }
2990
3277
  let removed = 0;
2991
3278
  const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
2992
3279
  for (const rel of inCopy) {
2993
3280
  if (!isOverlay(rel) || baseProtected.has(rel)) continue;
2994
- rmSync2(join9(dest, rel), { force: true });
3281
+ rmSync2(join11(dest, rel), { force: true });
2995
3282
  removed++;
2996
3283
  }
2997
- return [restored, removed];
3284
+ return { restored, removed };
2998
3285
  }
2999
3286
  function runSuite(dir, cmd, budgetSecs) {
3000
3287
  const t0 = Date.now();
@@ -3045,25 +3332,63 @@ function runVerify(opts) {
3045
3332
  out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
3046
3333
  return 2;
3047
3334
  }
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;
3335
+ const visRoot = mkdtempSync2(join11(tmpdir2(), "tw-verify-vis-"));
3336
+ const visDir = join11(visRoot, "t");
3337
+ const cleanup = (dirs) => {
3338
+ if (opts.keep) return;
3339
+ for (const d of dirs) rmSync2(d, { recursive: true, force: true });
3340
+ };
3341
+ const treeBefore = treeFingerprint(cwd);
3342
+ const depsBefore = depsFingerprint(cwd);
3053
3343
  try {
3054
3344
  mkdirSync6(visDir);
3055
- mkdirSync6(priDir);
3056
3345
  materialize(cwd, visDir);
3057
- materialize(cwd, priDir);
3058
- [restored, removedAdded] = overlayPristine(cwd, base, priDir, policy);
3059
3346
  } catch (e) {
3060
- if (!opts.keep) rmSync2(work, { recursive: true, force: true });
3347
+ cleanup([visRoot]);
3061
3348
  out2(`verify: could not materialize (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
3062
3349
  return 2;
3063
3350
  }
3064
3351
  const visible = runSuite(visDir, cmd, budget);
3352
+ if (treeFingerprint(cwd) !== treeBefore) {
3353
+ cleanup([visRoot]);
3354
+ out2("verify: the working tree changed while the visible suite was running \u2014 the pristine copy");
3355
+ out2("would be materialised from a tree the candidate just edited. Failing closed, not open.");
3356
+ return 2;
3357
+ }
3358
+ if (depsFingerprint(cwd) !== depsBefore) {
3359
+ cleanup([visRoot]);
3360
+ out2("verify: the installed dependency tree changed while the visible suite was running \u2014 the");
3361
+ out2("pristine run executes through it too. Failing closed, not open.");
3362
+ return 2;
3363
+ }
3364
+ const priRoot = mkdtempSync2(join11(tmpdir2(), "tw-verify-pri-"));
3365
+ const priDir = join11(priRoot, "t");
3366
+ let restored = [];
3367
+ let removedAdded = 0;
3368
+ try {
3369
+ mkdirSync6(priDir);
3370
+ materialize(cwd, priDir);
3371
+ ({ restored, removed: removedAdded } = overlayPristine(cwd, base, priDir, policy, cmd));
3372
+ } catch (e) {
3373
+ cleanup([visRoot, priRoot]);
3374
+ out2(`verify: could not materialize (${e instanceof Error ? e.message : String(e)}) \u2014 failing closed`);
3375
+ return 2;
3376
+ }
3377
+ const overlayBefore = overlayDigest(priDir, restored);
3065
3378
  const pristine = runSuite(priDir, cmd, budget);
3066
- if (!opts.keep) rmSync2(work, { recursive: true, force: true });
3379
+ const overlayMoved = overlayDigest(priDir, restored) !== overlayBefore;
3380
+ const treeMoved = treeFingerprint(cwd) !== treeBefore;
3381
+ const depsMoved = depsFingerprint(cwd) !== depsBefore;
3382
+ cleanup([visRoot, priRoot]);
3383
+ if (overlayMoved || treeMoved || depsMoved) {
3384
+ out2(
3385
+ "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"
3386
+ );
3387
+ out2("the verdict would describe something other than what ran. Failing closed, not open.");
3388
+ out2("(A suite that rewrites its own snapshots or test files in place will trip this; run it in");
3389
+ out2("whatever mode your runner calls CI, so verification observes rather than updates.)");
3390
+ return 2;
3391
+ }
3067
3392
  let verdict2;
3068
3393
  let code;
3069
3394
  if (visible.exit === null || pristine.exit === null) {
@@ -3088,15 +3413,15 @@ function runVerify(opts) {
3088
3413
  budget_secs: budget,
3089
3414
  visible: { exit: visible.exit, secs: visible.secs },
3090
3415
  pristine: { exit: pristine.exit, secs: pristine.secs },
3091
- protected_restored: restored,
3416
+ protected_restored: restored.length,
3092
3417
  added_protected_removed: removedAdded,
3093
- ...opts.keep ? { workdir: work } : {}
3418
+ ...opts.keep ? { visible_dir: visDir, pristine_dir: priDir } : {}
3094
3419
  })
3095
3420
  );
3096
3421
  } else {
3097
3422
  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.`,
3423
+ VERIFIED: `verified: the suite passes, and still passes with all ${restored.length} protected files restored from ${base.slice(0, 10)}.`,
3424
+ 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
3425
  SUITE_RED: `suite red: the visible suite fails (exit ${visible.exit}) \u2014 fix the code first (pristine exit ${pristine.exit}).`,
3101
3426
  BUDGET_EXCEEDED: `budget exceeded (${budget}s): could not verify \u2014 failing closed, not open.`
3102
3427
  };
@@ -3124,72 +3449,17 @@ function parseVerify(args) {
3124
3449
  }
3125
3450
 
3126
3451
  // 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";
3452
+ import { execFileSync as execFileSync7, spawnSync as spawnSync2 } from "node:child_process";
3453
+ import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync } from "node:fs";
3454
+ import { resolve as resolve2 } from "node:path";
3131
3455
  var out = (s) => process.stdout.write(s + "\n");
3132
3456
  var err = (s) => process.stderr.write(s + "\n");
3133
3457
  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");
3458
+ return execFileSync7("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
3189
3459
  }
3190
3460
  function nowTicks() {
3191
3461
  try {
3192
- return Math.floor(parseFloat(readFileSync12("/proc/uptime", "utf8").split(" ")[0]) * 100);
3462
+ return Math.floor(parseFloat(readFileSync14("/proc/uptime", "utf8").split(" ")[0]) * 100);
3193
3463
  } catch {
3194
3464
  return Number.POSITIVE_INFINITY;
3195
3465
  }
@@ -3204,7 +3474,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
3204
3474
  }
3205
3475
  let pids;
3206
3476
  try {
3207
- pids = readdirSync3("/proc").filter((d) => /^\d+$/.test(d));
3477
+ pids = readdirSync4("/proc").filter((d) => /^\d+$/.test(d));
3208
3478
  } catch {
3209
3479
  return out2;
3210
3480
  }
@@ -3219,7 +3489,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
3219
3489
  }
3220
3490
  if (held !== real && !held.startsWith(real + "/")) continue;
3221
3491
  try {
3222
- const stat = readFileSync12(`/proc/${pid}/stat`, "utf8");
3492
+ const stat = readFileSync14(`/proc/${pid}/stat`, "utf8");
3223
3493
  const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
3224
3494
  if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
3225
3495
  } catch {
@@ -3266,11 +3536,30 @@ function runEnvelope(opts) {
3266
3536
  }
3267
3537
  out(`tamperward run \u2014 trusted base ${base.slice(0, 10)}; agent exit is untrusted.`);
3268
3538
  const depsBefore = depsFingerprint(cwd);
3539
+ const rewritesBefore = objectRewriteState(cwd);
3269
3540
  const spawnTicks = nowTicks();
3270
3541
  const agent = spawnSync2(opts.argv[0], opts.argv.slice(1), { cwd, stdio: "inherit" });
3271
3542
  const agentExit = agent.status ?? 1;
3543
+ if (objectRewriteState(cwd) !== rewritesBefore) {
3544
+ err("tamperward run: git object-replacement state changed during this run \u2014 a replace ref, graft");
3545
+ err("or shallow boundary now redirects what the trusted base resolves to. Nothing below could be");
3546
+ err("read against the anchor we started from. That is the finding.");
3547
+ out(`
3548
+ tamperward run \u2014 agent exit ${agentExit}; OBJECT_REWRITE \u2192 ENFORCEMENT_FAILED`);
3549
+ return 1;
3550
+ }
3551
+ if (depsFingerprint(cwd) !== depsBefore) {
3552
+ err("tamperward run: the installed dependency tree changed before adjudication began \u2014 the program");
3553
+ err("that runs the suite is not the one we started with, and both verification runs execute it.");
3554
+ if (!opts.allowDepDrift) {
3555
+ out(`
3556
+ tamperward run \u2014 agent exit ${agentExit}; DEPENDENCY_DRIFT \u2192 ENFORCEMENT_FAILED`);
3557
+ return 1;
3558
+ }
3559
+ err("(--allow-dep-drift: proceeding anyway, on the operator's judgement.)");
3560
+ }
3272
3561
  const head = git3(["rev-parse", "HEAD"], cwd).trim();
3273
- const isAncestor = spawnSync2("git", ["merge-base", "--is-ancestor", base, head], { cwd });
3562
+ const isAncestor = spawnSync2("git", ["merge-base", "--is-ancestor", base, head], { cwd, env: trustedGitEnv() });
3274
3563
  if (isAncestor.status !== 0) {
3275
3564
  err(`tamperward run: HEAD ${head.slice(0, 10)} is not a descendant of the trusted base ${base.slice(0, 10)} \u2014`);
3276
3565
  err("the agent rewrote history out from under the anchor. That is the finding.");
@@ -3291,7 +3580,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
3291
3580
  const verifyCode = runVerify({ cwd, base, cmd: frozenCmd, budget: frozenBudget, policyOverride: frozenPolicy });
3292
3581
  if (opts.settle && opts.settle > 0) {
3293
3582
  try {
3294
- execFileSync5("sleep", [String(opts.settle)]);
3583
+ execFileSync7("sleep", [String(opts.settle)]);
3295
3584
  } catch {
3296
3585
  }
3297
3586
  }
@@ -3305,16 +3594,21 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
3305
3594
  err("tamperward run: the installed dependency tree changed during this run \u2014 the program that");
3306
3595
  err("runs the suite is not the one we started with. Both verification runs execute it.");
3307
3596
  }
3597
+ const rewrote = objectRewriteState(cwd) !== rewritesBefore;
3598
+ if (rewrote) {
3599
+ err("tamperward run: git object-replacement state changed WHILE the tree was being adjudicated \u2014");
3600
+ err("the checks above read the trusted base through an object layer that has since moved.");
3601
+ }
3308
3602
  const survivors = survivorsHoldingTree(cwd, spawnTicks);
3309
3603
  if (survivors.length) {
3310
3604
  err(`tamperward run: ${survivors.length} process(es) started during this run still hold the`);
3311
3605
  err(`working tree (pid ${survivors.join(", ")}). A verdict cannot outlive the tree it describes.`);
3312
3606
  }
3313
- const notQuiescent = mutatedDuringAdjudication || survivors.length > 0 || depsDrifted;
3607
+ const notQuiescent = mutatedDuringAdjudication || survivors.length > 0 || depsDrifted || rewrote;
3314
3608
  const cannot = diffCode === 2 || workCode === 2 || verifyCode === 2;
3315
3609
  const blocked = diffCode === 1 || workCode === 1 || verifyCode === 1 || notQuiescent;
3316
3610
  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";
3611
+ 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
3612
  out(`
3319
3613
  tamperward run \u2014 agent exit ${agentExit}; checks diff=${diffCode} worktree=${workCode} verify=${verifyCode} \u2192 ${verdict2}`);
3320
3614
  return enforcement !== 0 ? enforcement : agentExit;
@@ -3368,6 +3662,7 @@ function parseInit(args) {
3368
3662
  const a = args[i];
3369
3663
  if (a === "--cwd") o.cwd = args[++i];
3370
3664
  else if (a === "--dry-run") o.dryRun = true;
3665
+ else if (a === "--force-workflow") o.forceWorkflow = true;
3371
3666
  }
3372
3667
  return o;
3373
3668
  }
@@ -3431,9 +3726,15 @@ Formats:
3431
3726
  adjudicate (fails closed)
3432
3727
  tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
3433
3728
  tamperward init [--dry-run] wire the policy file plus every
3434
- enforcement point: Claude Code hooks,
3729
+ [--force-workflow] enforcement point: Claude Code hooks,
3435
3730
  pre-commit, CI. Idempotent; never
3436
- overwrites your files.
3731
+ overwrites your files. Re-running it
3732
+ MIGRATES a CI workflow it generated and
3733
+ you have not edited, and widens a
3734
+ PreToolUse matcher that no longer covers
3735
+ every tool the gate must see.
3736
+ --force-workflow replaces a workflow it
3737
+ did not write, or one you have edited.
3437
3738
 
3438
3739
  Exit code: check \u2192 1 if any blocking finding. hook/sweep \u2192 always 0; a deny is
3439
3740
  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.5",
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",