tamperward 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli/index.js +1368 -338
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -415,7 +415,41 @@ function makeFinding(rule, policy, input) {
415
415
 
416
416
  // src/detectors/command.ts
417
417
  function segments(raw) {
418
- return raw.split(/(?:&&|\|\||[;&|\n])+/).map((s) => s.trim()).filter(Boolean);
418
+ const out2 = [];
419
+ let single = false;
420
+ let double = false;
421
+ let last = 0;
422
+ for (let i = 0; i < raw.length; i++) {
423
+ const ch = raw[i];
424
+ if (ch === "\\" && !single) {
425
+ i++;
426
+ continue;
427
+ }
428
+ if (ch === "'" && !double) {
429
+ single = !single;
430
+ continue;
431
+ }
432
+ if (ch === '"' && !single) {
433
+ double = !double;
434
+ continue;
435
+ }
436
+ if (single || double) continue;
437
+ const prev = raw[i - 1];
438
+ const next = raw[i + 1];
439
+ let len = 0;
440
+ if (ch === "&" && next === "&") len = 2;
441
+ else if (ch === "|" && next === "|") len = 2;
442
+ else if (ch === "|" && next === "&") len = 2;
443
+ else if (ch === ";" || ch === "\n") len = 1;
444
+ else if (ch === "|" && prev !== ">") len = 1;
445
+ else if (ch === "&" && prev !== ">" && next !== ">") len = 1;
446
+ if (!len) continue;
447
+ out2.push(raw.slice(last, i));
448
+ i += len - 1;
449
+ last = i + 1;
450
+ }
451
+ out2.push(raw.slice(last));
452
+ return out2.map((s) => s.trim()).filter(Boolean);
419
453
  }
420
454
  function tokens(seg) {
421
455
  return seg.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
@@ -693,6 +727,9 @@ function defaultPolicy(version = 1) {
693
727
  hooks: [
694
728
  ".husky/**",
695
729
  "**/lefthook.*",
730
+ // lefthook merges `lefthook-local.yml` OVER `lefthook.yml`: a `skip: true`
731
+ // there switches the gate off without touching the file init pointed at.
732
+ "**/lefthook-local.*",
696
733
  ".tamperward.yml",
697
734
  "**/.tamperward.yml",
698
735
  ".claude/settings.json",
@@ -1605,9 +1642,9 @@ function simpleNarrowings(path, before, after) {
1605
1642
  for (const e of listEntries(after, key2)) if (!had.has(e) && !BENIGN_EXEMPTION.test(e)) out2.push(`${what} ${JSON.stringify(e)}`);
1606
1643
  }
1607
1644
  if (CODECOV.test(path)) {
1608
- const off = (s) => new Set(s.split("\n").map((l) => l.match(/^\s*(project|patch):\s*off\b/)?.[1]).filter((x) => !!x));
1609
- const had = off(before);
1610
- for (const s of off(after)) if (!had.has(s)) out2.push(`codecov status.${s} switched off`);
1645
+ const off2 = (s) => new Set(s.split("\n").map((l) => l.match(/^\s*(project|patch):\s*off\b/)?.[1]).filter((x) => !!x));
1646
+ const had = off2(before);
1647
+ for (const s of off2(after)) if (!had.has(s)) out2.push(`codecov status.${s} switched off`);
1611
1648
  }
1612
1649
  return out2;
1613
1650
  }
@@ -2121,8 +2158,8 @@ function parseTriggers(src) {
2121
2158
  const head = lines[i].replace(/^(?:on|"on"|'on'|true):\s*/, "");
2122
2159
  const inline = uncommented(head);
2123
2160
  if (inline) {
2124
- const list = flowList(inline);
2125
- for (const e of list ?? [inline]) t.events.add(e);
2161
+ const list2 = flowList(inline);
2162
+ for (const e of list2 ?? [inline]) t.events.add(e);
2126
2163
  return t;
2127
2164
  }
2128
2165
  let event = null;
@@ -2153,8 +2190,8 @@ function parseTriggers(src) {
2153
2190
  if (event && key2) {
2154
2191
  listKey = key2;
2155
2192
  listInd = ind;
2156
- const list = flowList(kv[2]);
2157
- t[listKey].set(event, list ?? []);
2193
+ const list2 = flowList(kv[2]);
2194
+ t[listKey].set(event, list2 ?? []);
2158
2195
  } else listKey = null;
2159
2196
  }
2160
2197
  return t;
@@ -2195,25 +2232,25 @@ function triggerNarrowings(before, after, opts) {
2195
2232
  for (const e of CODE_EVENTS) {
2196
2233
  if (before.events.has(e) && !after.events.has(e)) out2.push(`the ${e} trigger was removed \u2014 the workflow no longer runs on it`);
2197
2234
  }
2198
- for (const [e, list] of after.pathsIgnore) {
2235
+ for (const [e, list2] of after.pathsIgnore) {
2199
2236
  const had = before.pathsIgnore.get(e) ?? [];
2200
- if (list.some((g) => ALL(g) && !had.includes(g))) out2.push(`on.${e}.paths-ignore now ignores every path \u2014 the workflow never runs on ${e}`);
2237
+ if (list2.some((g) => ALL(g) && !had.includes(g))) out2.push(`on.${e}.paths-ignore now ignores every path \u2014 the workflow never runs on ${e}`);
2201
2238
  }
2202
- for (const [e, list] of after.paths) {
2239
+ for (const [e, list2] of after.paths) {
2203
2240
  if (!CODE_EVENTS.includes(e) || !after.events.has(e)) continue;
2204
2241
  const had = before.paths.get(e);
2205
2242
  if (had && !pathsMatchSource(had, opts.sources)) continue;
2206
- if (!pathsMatchSource(list, opts.sources)) out2.push(`on.${e}.paths matches no source file (now [${list.join(", ")}]) \u2014 the workflow never runs on code`);
2243
+ if (!pathsMatchSource(list2, opts.sources)) out2.push(`on.${e}.paths matches no source file (now [${list2.join(", ")}]) \u2014 the workflow never runs on code`);
2207
2244
  }
2208
- for (const [e, list] of after.types) {
2245
+ for (const [e, list2] of after.types) {
2209
2246
  if (!/^pull_request(?:_target)?$/.test(e) || !after.events.has(e)) continue;
2210
2247
  const had = before.types.get(e);
2211
2248
  if (had && !had.some((t) => PR_CODE_TYPES.test(t))) continue;
2212
- if (!list.some((t) => PR_CODE_TYPES.test(t))) out2.push(`on.${e}.types no longer includes opened/synchronize (now [${list.join(", ")}]) \u2014 the workflow does not run on the pull request's code`);
2249
+ if (!list2.some((t) => PR_CODE_TYPES.test(t))) out2.push(`on.${e}.types no longer includes opened/synchronize (now [${list2.join(", ")}]) \u2014 the workflow does not run on the pull request's code`);
2213
2250
  }
2214
- for (const [e, list] of after.branchesIgnore) {
2251
+ for (const [e, list2] of after.branchesIgnore) {
2215
2252
  const had = before.branchesIgnore.get(e) ?? [];
2216
- if (list.some((g) => (ALL(g) || isDefault(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
2253
+ if (list2.some((g) => (ALL(g) || isDefault(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
2217
2254
  }
2218
2255
  for (const [e, was] of before.branches) {
2219
2256
  const now = after.branches.get(e);
@@ -2374,12 +2411,59 @@ var ciTampering = {
2374
2411
  };
2375
2412
 
2376
2413
  // src/detectors/hook-tampering.ts
2377
- import { existsSync, readFileSync as readFileSync2 } from "node:fs";
2378
- import { isAbsolute, join as join2, relative } from "node:path";
2414
+ import { existsSync, readFileSync as readFileSync3 } from "node:fs";
2415
+ import { isAbsolute, join as join3, relative } from "node:path";
2416
+
2417
+ // src/wiring.ts
2418
+ import { readFileSync as readFileSync2 } from "node:fs";
2419
+ import { dirname, join as join2 } from "node:path";
2420
+ import { fileURLToPath } from "node:url";
2421
+ function shippedVersion() {
2422
+ try {
2423
+ const here = dirname(fileURLToPath(import.meta.url));
2424
+ for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
2425
+ try {
2426
+ const pkg = JSON.parse(readFileSync2(join2(here, rel), "utf8"));
2427
+ if (pkg.name === "tamperward" && pkg.version) return pkg.version;
2428
+ } catch {
2429
+ }
2430
+ }
2431
+ } catch {
2432
+ }
2433
+ return "latest";
2434
+ }
2435
+ var TW_VERSION = shippedVersion();
2436
+ var PRE_TOOLS = ["Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"];
2437
+ var PRE_MATCHER = PRE_TOOLS.join("|");
2438
+ var MARKER = "# tamperward: block agent shortcuts before they land";
2439
+ var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
2440
+ var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
2441
+ var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
2442
+ var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
2443
+ var PLAIN_SEMVER = /^\d+\.\d+\.\d+$/;
2444
+ function compareVersions(a, b) {
2445
+ if (!PLAIN_SEMVER.test(a) || !PLAIN_SEMVER.test(b)) return null;
2446
+ const pa = a.split(".").map(Number);
2447
+ const pb = b.split(".").map(Number);
2448
+ for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
2449
+ return 0;
2450
+ }
2451
+ function pinNotBelow(now, floor) {
2452
+ if (!now) return false;
2453
+ const c = compareVersions(now, floor);
2454
+ return c !== null && c >= 0;
2455
+ }
2456
+ var INIT_SCRIPT = /^#!\/bin\/sh\n# tamperward: block agent shortcuts before they land\nnpx --yes tamperward(?:@(\S+))? check --staged\n?$/;
2457
+ function initScriptPin(src) {
2458
+ const m = src.match(INIT_SCRIPT);
2459
+ return m ? m[1] ?? "" : null;
2460
+ }
2461
+ var CLAUDE_SETTINGS = /(?:^|\/)(?:\.claude\/settings(?:\.local)?\.json|managed-settings\.json)$/;
2462
+ var isClaudeSettings = (path) => CLAUDE_SETTINGS.test(path);
2379
2463
 
2380
2464
  // src/detectors/policy-diff.ts
2381
2465
  import { parse } from "yaml";
2382
- var globs2 = (list) => Array.isArray(list) ? list.filter((g) => typeof g === "string").map(normalizeGlob) : [];
2466
+ var globs2 = (list2) => Array.isArray(list2) ? list2.filter((g) => typeof g === "string").map(normalizeGlob) : [];
2383
2467
  function safeParse(src) {
2384
2468
  try {
2385
2469
  const v = parse(src);
@@ -2398,7 +2482,7 @@ function effective(raw) {
2398
2482
  const over = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg : {};
2399
2483
  rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude: globs2(over.exclude) } : {} };
2400
2484
  }
2401
- const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list]) => [cat, globs2(list)])) : void 0;
2485
+ const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list2]) => [cat, globs2(list2)])) : void 0;
2402
2486
  return {
2403
2487
  version,
2404
2488
  rules,
@@ -2519,7 +2603,7 @@ var RUNNER = /^(?:npx|pnpm|yarn|bunx|bun|npm)\s+(?:(?:exec|dlx|x|run)\s+)?(?:(?:
2519
2603
  var TOOL = /^(tamperward|jest|vitest|eslint|tsc|lint-staged|lefthook|husky|pytest|make|cargo|go|pre-commit|mypy|ruff|rubocop|prettier|gradlew?)\b/;
2520
2604
  var SCRIPT = /^(test|tests|lint|typecheck|type-check|coverage|check)\b/;
2521
2605
  var SUBCOMMAND_TOOLS = /* @__PURE__ */ new Set(["tamperward", "make", "cargo", "go", "lefthook", "husky", "pre-commit"]);
2522
- var PREFIX = /^(?:(?:then|do|else|\{|\(|!|exec|command|time|nice|env|sudo|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"(?=\s|$)|'[^']*'(?=\s|$)|[^\s"']\S*|(?=\s)))\s+)*/;
2606
+ var PREFIX = /^(?:(?:then|do|else|\{|\(|exec|command|time|nice|env|sudo|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"(?=\s|$)|'[^']*'(?=\s|$)|[^\s"']\S*|(?=\s)))\s+)*/;
2523
2607
  function checkIdentity(segment) {
2524
2608
  const core = segment.trim().replace(PREFIX, "").replace(/\s+/g, " ");
2525
2609
  const viaRunner = RUNNER.test(core);
@@ -2530,20 +2614,49 @@ function checkIdentity(segment) {
2530
2614
  if (tool === "tamperward") {
2531
2615
  const sub = toks[1];
2532
2616
  if (!sub || sub.startsWith("-")) return null;
2533
- return `tamperward ${sub}`;
2617
+ const cwd = rest.match(/\s--cwd(?:=|\s+)(\S+)/)?.[1];
2618
+ return cwd ? `tamperward ${sub} --cwd ${cwd}` : `tamperward ${sub}`;
2534
2619
  }
2535
2620
  if (SUBCOMMAND_TOOLS.has(tool) && toks[1] && !toks[1].startsWith("-")) return `${tool} ${toks[1]}`;
2536
2621
  return tool;
2537
2622
  }
2623
+ function pinOfSegment(segment) {
2624
+ const m = segment.trim().replace(PREFIX, "").replace(RUNNER, "").match(/^tamperward(?:@(\S+))?\b/);
2625
+ return m ? m[1] ?? "" : null;
2626
+ }
2538
2627
  function split(stmt) {
2539
2628
  const segs = [];
2540
2629
  const ops = [];
2541
- const re = /(&&|\|\||;|\|)/g;
2630
+ let depth = 0;
2631
+ let single = false;
2632
+ let double = false;
2542
2633
  let last = 0;
2543
- for (const m of stmt.matchAll(re)) {
2544
- segs.push(stmt.slice(last, m.index));
2545
- ops.push(m[1]);
2546
- last = (m.index ?? 0) + m[1].length;
2634
+ for (let i = 0; i < stmt.length; i++) {
2635
+ const ch = stmt[i];
2636
+ if (ch === "'" && !double) {
2637
+ single = !single;
2638
+ continue;
2639
+ }
2640
+ if (ch === '"' && !single) {
2641
+ double = !double;
2642
+ continue;
2643
+ }
2644
+ if (single || double) continue;
2645
+ if (ch === "(" || ch === "{" && /(?:^|\s)$/.test(stmt.slice(0, i)) && /^(?:\s|$)/.test(stmt.slice(i + 1))) {
2646
+ depth++;
2647
+ continue;
2648
+ }
2649
+ if (ch === ")" || ch === "}" && depth > 0 && /(?:^|[\s;])$/.test(stmt.slice(0, i))) {
2650
+ depth = Math.max(0, depth - 1);
2651
+ continue;
2652
+ }
2653
+ if (depth > 0) continue;
2654
+ const op = stmt.startsWith("&&", i) ? "&&" : stmt.startsWith("||", i) ? "||" : ch === ";" ? ";" : ch === "|" ? "|" : null;
2655
+ if (!op) continue;
2656
+ segs.push(stmt.slice(last, i));
2657
+ ops.push(op);
2658
+ i += op.length - 1;
2659
+ last = i + 1;
2547
2660
  }
2548
2661
  segs.push(stmt.slice(last));
2549
2662
  return { segs, ops };
@@ -2592,106 +2705,602 @@ function alwaysFalse(cond) {
2592
2705
  if (/^(?:""|'')$/.test(inner)) return true;
2593
2706
  return false;
2594
2707
  }
2595
- var alwaysTrue = (cond) => /^(?:true|:|\[\s+1\s+-eq\s+1\s+\]|\[\s+-n\s+"?\S"?\s+\])$/.test(cond.trim().replace(/;$/, "").trim());
2708
+ var alwaysTrue = (cond) => {
2709
+ const c = cond.trim().replace(/;$/, "").trim();
2710
+ if (/^(?:true|:|!\s*false|\(\s*exit\s+0*\s*\))$/.test(c)) return true;
2711
+ const m = c.match(/^(?:\[\[\s+(.+?)\s+\]\]|\[\s+(.+?)\s+\]|test\s+(.+))$/);
2712
+ if (!m) return false;
2713
+ const inner = (m[1] ?? m[2] ?? m[3]).trim();
2714
+ if (inner.includes("$")) return false;
2715
+ const parts = inner.match(/^(\S+)\s+(-eq|-ne|-lt|-le|-gt|-ge|=|==|!=)\s+(\S+)$/);
2716
+ if (parts) {
2717
+ const ints = /^-?\d+$/.test(unq(parts[1])) && /^-?\d+$/.test(unq(parts[3]));
2718
+ if (parts[2].startsWith("-") && !ints) return false;
2719
+ return !alwaysFalse(c);
2720
+ }
2721
+ if (/^(?:-n|-z)\s+\S+$/.test(inner)) return !alwaysFalse(c);
2722
+ if (/^\S+$/.test(inner)) return unq(inner) !== "";
2723
+ return false;
2724
+ };
2596
2725
  var EXIT_ZERO = /^(?:exit|return)(?:\s+0+)?$/;
2597
2726
  var EXIT_ANY = /^(?:exit|return)(?:\s+\S+)?$/;
2598
- var FAILS_LOUDLY = /\b(?:exit|return)\s+(?:[1-9]\d*|"?\$\?"?|"?\$\{?[A-Za-z_]\w*\}?"?)(?=[\s;})]|$)|\bfalse\b|\bkill\b|\bexit\s*$|\breturn\s*$/;
2599
2727
  function insertsPassingExit(line) {
2600
2728
  if (isComment(line)) return false;
2601
- return split(stripComment(line)).segs.some((s) => EXIT_ZERO.test(s.trim().replace(PREFIX, "")));
2729
+ return split(stripComment(line)).segs.some((s) => EXIT_ZERO.test(s.trim().replace(/^!\s+/, "").replace(PREFIX, "")));
2602
2730
  }
2603
2731
  function insertsDeadGuard(line) {
2604
2732
  if (isComment(line)) return false;
2605
2733
  const m = stripComment(line).trim().match(/^(?:if|elif)\s+(.+?)\s*(?:;\s*then)?\s*$/);
2606
2734
  return !!m && alwaysFalse(m[1]);
2607
2735
  }
2608
- function invocations(lines) {
2736
+ function joinContinued(lines) {
2609
2737
  const out2 = [];
2610
- const pipefail = lines.some((l) => /\bpipefail\b/.test(stripComment(l)));
2611
- const frames = [{ dead: false }];
2738
+ let cur = null;
2739
+ for (const l of lines) {
2740
+ cur = cur === null ? l : cur + " " + l.trimStart();
2741
+ const m = cur.match(/(\\+)$/);
2742
+ if (m && m[1].length % 2 === 1 && !isComment(cur)) {
2743
+ cur = cur.slice(0, -1);
2744
+ continue;
2745
+ }
2746
+ out2.push(cur);
2747
+ cur = null;
2748
+ }
2749
+ if (cur !== null) out2.push(cur);
2750
+ return out2;
2751
+ }
2752
+ function stripHeredocs(lines) {
2753
+ const out2 = [];
2754
+ for (let i = 0; i < lines.length; i++) {
2755
+ const l = lines[i];
2756
+ out2.push(l);
2757
+ if (isComment(l)) continue;
2758
+ const m = stripComment(l).match(/<<(-?)\s*(?:'([^']+)'|"([^"]+)"|\\?([A-Za-z_]\w*))/);
2759
+ if (!m) continue;
2760
+ const word = m[2] ?? m[3] ?? m[4];
2761
+ const dash = m[1] === "-";
2762
+ for (i++; i < lines.length; i++) {
2763
+ const t = dash ? lines[i].replace(/^\t+/, "") : lines[i];
2764
+ if (t === word) break;
2765
+ }
2766
+ }
2767
+ return out2;
2768
+ }
2769
+ function explodeKeywords(line) {
2770
+ if (isComment(line)) return [line];
2771
+ let s = stripComment(line);
2772
+ s = s.replace(/;;/g, "\n;;");
2773
+ s = s.replace(/;\s*(then|do|fi|done|esac|else|elif)\b/g, "\n$1");
2774
+ s = s.replace(/^(\s*case\s+\S+\s+in)\s+(?=\S)/, "$1\n");
2775
+ s = s.replace(/^(\s*)(then|do|else)\s+(?=\S)/, "$1$2\n");
2776
+ s = s.replace(/\n(then|do|else)\s+(?=\S)/g, "\n$1\n");
2777
+ return s.split("\n");
2778
+ }
2779
+ function closingBrace(line, depth) {
2780
+ let single = false;
2781
+ let double = false;
2782
+ let expansion = 0;
2783
+ for (let i = 0; i < line.length; i++) {
2784
+ const ch = line[i];
2785
+ if (ch === "'" && !double) single = !single;
2786
+ else if (ch === '"' && !single) double = !double;
2787
+ else if (single) continue;
2788
+ else if (ch === "$" && line[i + 1] === "{") {
2789
+ expansion++;
2790
+ i++;
2791
+ } else if (ch === "}" && expansion > 0) expansion--;
2792
+ else if (ch === "{" && !double) depth++;
2793
+ else if (ch === "}" && !double && --depth === 0) return i;
2794
+ }
2795
+ return -1;
2796
+ }
2797
+ function braceDelta(line) {
2798
+ let d = 0;
2799
+ let single = false;
2800
+ let double = false;
2801
+ let expansion = 0;
2802
+ for (let i = 0; i < line.length; i++) {
2803
+ const ch = line[i];
2804
+ if (ch === "'" && !double) single = !single;
2805
+ else if (ch === '"' && !single) double = !double;
2806
+ else if (single) continue;
2807
+ else if (ch === "$" && line[i + 1] === "{") {
2808
+ expansion++;
2809
+ i++;
2810
+ } else if (ch === "}" && expansion > 0) expansion--;
2811
+ else if (ch === "{" && !double) d++;
2812
+ else if (ch === "}" && !double) d--;
2813
+ }
2814
+ return d;
2815
+ }
2816
+ var FN_DEF = /^\s*(?:function\s+)?([A-Za-z_][\w-]*)\s*\(\s*\)\s*(.*)$|^\s*function\s+([A-Za-z_][\w-]*)\s*(\{.*)?$/;
2817
+ function prepare(lines, opts) {
2818
+ const flat = stripHeredocs(joinContinued(lines)).flatMap(explodeKeywords);
2819
+ const top = [];
2820
+ const fns = /* @__PURE__ */ new Map();
2821
+ const shadowed = /* @__PURE__ */ new Set();
2822
+ let errexit = opts.errexit ?? false;
2823
+ let pipefail = false;
2824
+ if (flat[0]?.startsWith("#!")) {
2825
+ const sb = flat[0];
2826
+ if (/\s-[a-zA-Z]*e[a-zA-Z]*(?:\s|$)/.test(sb)) errexit = true;
2827
+ if (/-[a-zA-Z]*o\s+pipefail\b/.test(sb)) pipefail = true;
2828
+ }
2829
+ for (let i = 0; i < flat.length; i++) {
2830
+ const raw = flat[i];
2831
+ if (isComment(raw)) {
2832
+ top.push(raw);
2833
+ continue;
2834
+ }
2835
+ const stmt = stripComment(raw).trim();
2836
+ const al = stmt.match(/^alias\s+([A-Za-z_][\w-]*)=/);
2837
+ if (al) shadowed.add(al[1]);
2838
+ const def = stmt.match(FN_DEF);
2839
+ if (!def) {
2840
+ top.push(raw);
2841
+ continue;
2842
+ }
2843
+ const name = def[1] ?? def[3];
2844
+ shadowed.add(name);
2845
+ let rest = (def[2] ?? def[4] ?? "").trim();
2846
+ if (rest === "" && /^\s*\{/.test(flat[i + 1] ?? "")) {
2847
+ i++;
2848
+ rest = flat[i].trim();
2849
+ }
2850
+ const body = [];
2851
+ if (!rest.startsWith("{")) {
2852
+ fns.set(name, body);
2853
+ continue;
2854
+ }
2855
+ rest = rest.slice(1);
2856
+ const after = (line, close2) => {
2857
+ const t = line.slice(0, close2);
2858
+ if (t.trim()) body.push(t);
2859
+ const remainder = line.slice(close2 + 1).replace(/^\s*;?\s*/, "");
2860
+ if (remainder.trim()) top.push(remainder);
2861
+ };
2862
+ let close = closingBrace(rest, 1);
2863
+ if (close !== -1) {
2864
+ after(rest, close);
2865
+ } else {
2866
+ if (rest.trim()) body.push(rest);
2867
+ let depth = 1 + braceDelta(rest);
2868
+ for (i++; i < flat.length; i++) {
2869
+ const line = isComment(flat[i]) ? "" : stripComment(flat[i]);
2870
+ close = closingBrace(line, depth);
2871
+ if (close !== -1) {
2872
+ after(line, close);
2873
+ break;
2874
+ }
2875
+ depth += braceDelta(line);
2876
+ body.push(flat[i]);
2877
+ }
2878
+ }
2879
+ fns.set(name, body);
2880
+ }
2881
+ return { top, fns, shadowed, pipefail, errexit };
2882
+ }
2883
+ var TRANSPARENT = /^(?:fi|done|esac|\}|\)|;;|then|do|else|exit\s+"?\$\??"?|exit\s+"?\$\{\?\}"?)\s*;?$/;
2884
+ function statusAfterFailure(seg, vars) {
2885
+ const inner = seg.trim().replace(/^[{(]\s*/, "").replace(/\s*[})]\s*;?\s*$/, "");
2886
+ let status = "fail";
2887
+ for (const piece of inner.split(/;|&&|\|\||\|/)) {
2888
+ const w = piece.trim().replace(PREFIX, "").split(/\s+/);
2889
+ switch (w[0]) {
2890
+ case "":
2891
+ break;
2892
+ case "true":
2893
+ case ":":
2894
+ status = "ok";
2895
+ break;
2896
+ case "false":
2897
+ status = "fail";
2898
+ break;
2899
+ case "kill":
2900
+ status = w[1] === "-0" ? "ok" : "exit";
2901
+ break;
2902
+ case "exit":
2903
+ case "return": {
2904
+ const arg = unq(w[1] ?? "");
2905
+ if (arg === "" || arg === "$?" || arg === "${?}") return status === "ok" ? "ok" : "exit";
2906
+ if (/^0+$/.test(arg)) return "ok";
2907
+ if (/^[1-9]\d*$/.test(arg)) return "exit";
2908
+ const v = arg.match(/^\$\{?([A-Za-z_]\w*)\}?$/);
2909
+ const known = v ? vars.get(v[1]) : void 0;
2910
+ return known !== void 0 && /^0+$/.test(known) ? "ok" : "exit";
2911
+ }
2912
+ default:
2913
+ status = "ok";
2914
+ }
2915
+ }
2916
+ return status;
2917
+ }
2918
+ function constStatus(seg) {
2919
+ const s = seg.trim().replace(/^!\s+/, "");
2920
+ if (alwaysTrue(s)) return "ok";
2921
+ if (alwaysFalse(s)) return "fail";
2922
+ return "unknown";
2923
+ }
2924
+ function reaches(segs, ops, i) {
2925
+ let status = "ok";
2926
+ let certain = true;
2927
+ for (let j = 0; j < i; j++) {
2928
+ const runs = j === 0 ? true : ops[j - 1] === "&&" ? status !== "fail" : ops[j - 1] === "||" ? status !== "ok" : true;
2929
+ if (!runs) continue;
2930
+ const s = constStatus(segs[j]);
2931
+ if (s === "unknown" && (ops[j] === "&&" || ops[j] === "||")) certain = false;
2932
+ if (ops[j] === ";" || ops[j] === "|") {
2933
+ status = "ok";
2934
+ certain = certain && true;
2935
+ continue;
2936
+ }
2937
+ status = s;
2938
+ }
2939
+ const gate = i === 0 ? true : ops[i - 1] === "&&" ? status !== "fail" : ops[i - 1] === "||" ? status !== "ok" : true;
2940
+ if (!gate) return "no";
2941
+ if (i === 0 || ops[i - 1] === ";" || ops[i - 1] === "|") return "yes";
2942
+ if (!certain || status === "unknown") return "maybe";
2943
+ return "yes";
2944
+ }
2945
+ function literal(arg, vars) {
2946
+ const a = unq(arg.trim());
2947
+ if (/[`]|\$\(/.test(a)) return null;
2948
+ let unknown = false;
2949
+ const r = a.replace(/\$\{?([A-Za-z_]\w*)\}?/g, (_, n) => {
2950
+ const v = vars.get(n);
2951
+ if (v === void 0) unknown = true;
2952
+ return v ?? "";
2953
+ });
2954
+ return unknown ? null : r;
2955
+ }
2956
+ function cdStaysHome(arg, vars) {
2957
+ const a = arg.trim();
2958
+ if (/rev-parse\s+--show-toplevel/.test(a)) return true;
2959
+ const lit = literal(a, vars);
2960
+ if (lit === null) return null;
2961
+ return lit === "" || lit === "." || lit === "./";
2962
+ }
2963
+ function walk(lines, st, topIdx, depth) {
2964
+ const frames = [{ dead: false, exhausted: false, kind: "base" }];
2612
2965
  const dead = () => frames.some((f) => f.dead);
2613
- for (const raw of lines) {
2966
+ const conditional = () => frames.length > 1;
2967
+ let exits = false;
2968
+ for (let i = 0; i < lines.length; i++) {
2969
+ const raw = lines[i];
2970
+ const idx = topIdx(i);
2614
2971
  if (isComment(raw)) {
2615
2972
  const id = checkIdentity(raw.replace(/^\s*#+\s*/, ""));
2616
- if (id) out2.push({ identity: id, line: raw, state: "comment" });
2973
+ if (id) st.out.push({ identity: id, line: raw, state: "comment", idx, errexit: st.errexit, passthrough: false });
2617
2974
  continue;
2618
2975
  }
2619
- const stmt = stripComment(raw).trim();
2976
+ let stmt = stripComment(raw).trim();
2620
2977
  if (!stmt) continue;
2621
- const ifm = stmt.match(/^(if|elif)\s+(.+?)\s*(?:;\s*then)?\s*$/);
2978
+ const ifm = stmt.match(/^(if|elif)\s+(.+)$/);
2622
2979
  if (ifm) {
2623
- if (ifm[1] === "if") frames.push({ dead: alwaysFalse(ifm[2]) });
2624
- else if (frames.length > 1) frames[frames.length - 1].dead = alwaysFalse(ifm[2]);
2980
+ const cond = ifm[2].replace(/;\s*$/, "");
2981
+ const cid = checkIdentity(cond.replace(/^!\s+/, ""));
2982
+ if (cid) st.out.push({ identity: cid, line: raw, state: dead() ? "unreachable" : "neutered", why: "used as an `if` condition \u2014 its failure selects a branch instead of failing the hook", idx, errexit: st.errexit, passthrough: false });
2983
+ if (ifm[1] === "if") frames.push({ dead: alwaysFalse(cond), exhausted: alwaysTrue(cond), kind: "if" });
2984
+ else if (frames.length > 1) {
2985
+ const f = frames[frames.length - 1];
2986
+ f.dead = f.exhausted || alwaysFalse(cond);
2987
+ f.exhausted = f.exhausted || alwaysTrue(cond);
2988
+ }
2625
2989
  continue;
2626
2990
  }
2627
2991
  if (/^else\b/.test(stmt)) {
2628
- if (frames.length > 1) frames[frames.length - 1].dead = false;
2992
+ if (frames.length > 1) {
2993
+ const f = frames[frames.length - 1];
2994
+ f.dead = f.exhausted;
2995
+ }
2629
2996
  continue;
2630
2997
  }
2631
- if (/^fi\b/.test(stmt)) {
2998
+ if (/^(?:fi|done|esac)\b/.test(stmt)) {
2632
2999
  if (frames.length > 1) frames.pop();
2633
3000
  continue;
2634
3001
  }
3002
+ if (/^(?:then|do)\b/.test(stmt)) continue;
3003
+ const loop = stmt.match(/^(while|until)\s+(.+)$/);
3004
+ if (loop) {
3005
+ const cond = loop[2].replace(/;\s*$/, "");
3006
+ frames.push({ dead: loop[1] === "while" ? alwaysFalse(cond) : alwaysTrue(cond), exhausted: false, kind: "loop" });
3007
+ continue;
3008
+ }
3009
+ const forl = stmt.match(/^for\s+[A-Za-z_]\w*(?:\s+in\b(.*))?$/);
3010
+ if (forl) {
3011
+ const list2 = (forl[1] ?? "$@").replace(/;\s*$/, "").trim();
3012
+ frames.push({ dead: forl[1] !== void 0 && (list2 === "" || /^(?:""|'')$/.test(list2)), exhausted: false, kind: "loop" });
3013
+ continue;
3014
+ }
3015
+ const casem = stmt.match(/^case\s+(\S+)\s+in\b/);
3016
+ if (casem) {
3017
+ frames.push({ dead: false, exhausted: false, kind: "case", word: unq(casem[1]) });
3018
+ continue;
3019
+ }
3020
+ const pat = frames[frames.length - 1].kind === "case" ? stmt.match(/^\(?([^()]+)\)\s*(.*)$/) : null;
3021
+ if (pat) {
3022
+ const f = frames[frames.length - 1];
3023
+ const word = f.word ?? "";
3024
+ const patterns = pat[1].split("|").map((p) => unq(p.trim()));
3025
+ const may = /[$`]/.test(word) || patterns.some((p) => p === "*" || /[$`*?[]/.test(p) || p === word);
3026
+ f.dead = !may;
3027
+ stmt = pat[2].trim();
3028
+ if (!stmt) continue;
3029
+ }
3030
+ if (/^;;$/.test(stmt)) continue;
3031
+ if (/^[{}()]$/.test(stmt)) continue;
3032
+ const bg = /(?<!&)&\s*$/.test(stmt) && !/&&\s*$/.test(stmt);
3033
+ if (bg) stmt = stmt.replace(/\s*&\s*$/, "");
2635
3034
  const { segs, ops } = split(stmt);
2636
- for (let i = 0; i < segs.length; i++) {
2637
- const seg = segs[i].trim().replace(PREFIX, "");
3035
+ for (let s = 0; s < segs.length; s++) {
3036
+ let seg = segs[s].trim();
3037
+ const inverted = /^!\s+/.test(seg);
3038
+ seg = seg.replace(/^!\s+/, "");
3039
+ const execs = /^exec\s+/.test(seg) && !/^exec\s+(?:\d*[<>]|&>)/.test(seg);
3040
+ seg = seg.replace(PREFIX, "");
3041
+ const reach = dead() ? "no" : reaches(segs, ops, s);
3042
+ const first = seg.split(/\s+/)[0] ?? "";
3043
+ if (reach !== "no") {
3044
+ const certain = reach === "yes" && !conditional();
3045
+ const set = seg.match(/^set\s+(.+)$/);
3046
+ if (set) {
3047
+ const toks = set[1].split(/\s+/);
3048
+ for (let t = 0; t < toks.length; t++) {
3049
+ const m = toks[t].match(/^([-+])([a-zA-Z]+)$/);
3050
+ if (!m) continue;
3051
+ const on = m[1] === "-";
3052
+ const flip = (cur) => on ? certain ? true : cur : false;
3053
+ if (m[2].includes("e")) st.errexit = flip(st.errexit);
3054
+ if (m[2].includes("o")) {
3055
+ const name = toks[++t];
3056
+ if (name === "errexit") st.errexit = flip(st.errexit);
3057
+ if (name === "pipefail") st.pipefail = flip(st.pipefail);
3058
+ }
3059
+ }
3060
+ }
3061
+ const trap = seg.match(/^trap\s+(?:'([^']*)'|"([^"]*)"|(\S+))\s+(.+)$/);
3062
+ if (trap) {
3063
+ const action = trap[1] ?? trap[2] ?? trap[3];
3064
+ const sigs = trap[4].split(/\s+/);
3065
+ if (sigs.some((x) => /^(?:EXIT|ERR|0|SIGERR)$/i.test(x)) && /(?:^|[;\s])(?:exit(?:\s+0+)?|true|:)\s*(?:;|$)/.test(action)) st.traps.push(idx);
3066
+ }
3067
+ const asg = seg.match(/^(?:export\s+|readonly\s+)?([A-Za-z_]\w*)=(.*)$/);
3068
+ if (asg) {
3069
+ const v = certain ? literal(asg[2], st.vars) : null;
3070
+ if (v === null) st.vars.delete(asg[1]);
3071
+ else st.vars.set(asg[1], v);
3072
+ }
3073
+ if (first === "cd") {
3074
+ const home = cdStaysHome(seg.slice(2), st.vars);
3075
+ if (home === false) st.cdAway = true;
3076
+ else if (home === true && certain) st.cdAway = false;
3077
+ }
3078
+ }
2638
3079
  const id = checkIdentity(seg);
2639
3080
  if (!id) {
2640
- if (EXIT_ANY.test(seg) && !dead() && (i === 0 || ops[i - 1] === ";")) frames[frames.length - 1].dead = true;
3081
+ if (reach === "yes") {
3082
+ if (EXIT_ANY.test(seg)) {
3083
+ if (!conditional()) {
3084
+ frames[0].dead = true;
3085
+ if (/^exit\b/.test(seg)) exits = true;
3086
+ } else if (/^exit\b/.test(seg) && depth > 0) {
3087
+ }
3088
+ } else if (execs && !conditional()) {
3089
+ frames[0].dead = true;
3090
+ } else if (st.fns.has(first) && reach === "yes") {
3091
+ const start = st.out.length;
3092
+ const body = st.fns.get(first) ?? [];
3093
+ const sub = { ...st, out: st.out };
3094
+ const fnExits = depth < 6 && walk(body, sub, () => idx, depth + 1);
3095
+ st.errexit = sub.errexit;
3096
+ st.pipefail = sub.pipefail;
3097
+ st.cdAway = sub.cdAway;
3098
+ const verdict2 = chainVerdict(segs, ops, s, st, inverted, bg);
3099
+ for (let k = start; k < st.out.length; k++) {
3100
+ const inv = st.out[k];
3101
+ if (inv.state === "live" && verdict2.state !== "live") {
3102
+ inv.state = verdict2.state;
3103
+ inv.why = verdict2.why;
3104
+ }
3105
+ if (inv.state === "live") inv.passthrough = inv.passthrough && verdict2.passthrough;
3106
+ }
3107
+ if (fnExits && !conditional()) {
3108
+ frames[0].dead = true;
3109
+ exits = true;
3110
+ }
3111
+ }
3112
+ } else if (reach === "maybe" && st.fns.has(first)) {
3113
+ const start = st.out.length;
3114
+ const sub = { ...st, out: st.out };
3115
+ if (depth < 6) walk(st.fns.get(first) ?? [], sub, () => idx, depth + 1);
3116
+ const verdict2 = chainVerdict(segs, ops, s, st, inverted, bg);
3117
+ for (let k = start; k < st.out.length; k++) {
3118
+ const inv = st.out[k];
3119
+ if (inv.state === "live" && verdict2.state !== "live") {
3120
+ inv.state = verdict2.state;
3121
+ inv.why = verdict2.why;
3122
+ }
3123
+ }
3124
+ }
2641
3125
  continue;
2642
3126
  }
2643
- let state = dead() ? "unreachable" : "live";
2644
- if (state === "live" && i > 0) {
2645
- if (ops[i - 1] === "&&" && alwaysFalse(segs[i - 1])) state = "unreachable";
2646
- if (ops[i - 1] === "||" && alwaysTrue(segs[i - 1])) state = "unreachable";
2647
- }
2648
- if (state === "live" && i < ops.length) {
2649
- const after = segs.slice(i + 1).join(" ");
2650
- if (ops[i] === "||" && !FAILS_LOUDLY.test(after)) state = "neutered";
2651
- else if (ops[i] === "|" && !pipefail) state = "neutered";
2652
- else if (ops[i] === ";" && segs.slice(i + 1).some((s) => /^(?:true|:)$/.test(s.trim()) || EXIT_ZERO.test(s.trim()))) state = "neutered";
3127
+ let state = "live";
3128
+ let why;
3129
+ let passthrough = false;
3130
+ if (reach === "no") {
3131
+ state = "unreachable";
3132
+ why = dead() ? "behind an always-false guard, an early exit, or a function body that is never called" : "behind a constant condition";
3133
+ } else {
3134
+ const v = chainVerdict(segs, ops, s, st, inverted, bg);
3135
+ state = v.state;
3136
+ why = v.why;
3137
+ passthrough = v.passthrough;
3138
+ if (state === "live" && /^tamperward\b/.test(id) && st.cdAway) {
3139
+ state = "neutered";
3140
+ why = "runs from another directory after a `cd` away from the repository root";
3141
+ }
3142
+ if (state === "live") {
3143
+ const words2 = [first, "tamperward"].filter((w) => w && st.shadowed.has(w));
3144
+ if (words2.length) {
3145
+ state = "neutered";
3146
+ why = `\`${words2[0]}\` is redefined by a function or alias in this script`;
3147
+ }
3148
+ }
2653
3149
  }
2654
- out2.push({ identity: id, line: raw, state });
3150
+ const pin = pinOfSegment(seg);
3151
+ st.out.push({ identity: id, line: raw, state, why, idx, errexit: st.errexit, passthrough, ...pin !== null ? { pin } : {} });
2655
3152
  }
2656
3153
  }
2657
- return out2;
3154
+ return exits;
2658
3155
  }
2659
- function scriptWeakening(before, after) {
3156
+ function chainVerdict(segs, ops, i, st, inverted, bg) {
3157
+ if (inverted) return { state: "neutered", why: "its status is inverted by `!`", passthrough: false };
3158
+ if (bg) return { state: "neutered", why: "run in the background \u2014 the hook does not wait for its status", passthrough: false };
3159
+ let cur = "fail";
3160
+ for (let j = i; j < ops.length; j++) {
3161
+ const op = ops[j];
3162
+ const next = segs[j + 1];
3163
+ if (op === "|") {
3164
+ if (!st.pipefail) return { state: "neutered", why: "piped into another command without `pipefail`", passthrough: false };
3165
+ continue;
3166
+ }
3167
+ if (cur === "exit") break;
3168
+ if (op === "||") {
3169
+ if (cur === "fail") cur = statusAfterFailure(next, st.vars);
3170
+ } else if (op === "&&") {
3171
+ if (cur === "ok") cur = statusAfterFailure(next, st.vars);
3172
+ } else if (op === ";") {
3173
+ const rest = segs.slice(j + 1).map((x) => x.trim());
3174
+ if (st.errexit && cur === "fail") {
3175
+ if (rest.some((x) => /^(?:true|:)$/.test(x) || EXIT_ZERO.test(x))) return { state: "neutered", why: "followed by a passing statement in the same line", passthrough: false };
3176
+ return { state: "live", passthrough: false };
3177
+ }
3178
+ if (rest.every((x) => TRANSPARENT.test(x))) return { state: cur === "fail" ? "live" : "neutered", why: cur === "fail" ? void 0 : "the `||` chain after it ends in success", passthrough: cur === "fail" };
3179
+ return { state: "neutered", why: "followed by other statements whose status replaces it (no `set -e`)", passthrough: false };
3180
+ }
3181
+ }
3182
+ if (cur === "ok") return { state: "neutered", why: "the `||` chain after it ends in success", passthrough: false };
3183
+ if (cur === "exit") return { state: "live", passthrough: false };
3184
+ return { state: "live", passthrough: i === ops.length || ops.slice(i).every((o) => o === "&&" || o === "||") };
3185
+ }
3186
+ function invocations(lines, opts = {}) {
3187
+ const prep = prepare(lines, opts);
3188
+ const st = {
3189
+ errexit: prep.errexit,
3190
+ pipefail: prep.pipefail,
3191
+ cdAway: false,
3192
+ vars: /* @__PURE__ */ new Map(),
3193
+ fns: prep.fns,
3194
+ shadowed: prep.shadowed,
3195
+ traps: [],
3196
+ out: [],
3197
+ lastReal: -1
3198
+ };
3199
+ for (let i = 0; i < prep.top.length; i++) {
3200
+ const t = stripComment(prep.top[i]).trim();
3201
+ if (t && !isComment(prep.top[i]) && !TRANSPARENT.test(t)) st.lastReal = i;
3202
+ }
3203
+ walk(prep.top, st, (i) => i, 0);
3204
+ const seen = new Set(st.out.map((o) => o.line));
3205
+ for (const [, body] of prep.fns) {
3206
+ const sub = { ...st, out: [], traps: [] };
3207
+ walk(body, sub, () => -1, 1);
3208
+ for (const inv of sub.out) if (!seen.has(inv.line) && inv.state !== "comment") st.out.push({ ...inv, state: "unreachable", why: "defined in a function that is never called" });
3209
+ }
3210
+ return st.out.map((inv) => {
3211
+ if (inv.state !== "live") return strip(inv);
3212
+ if (st.traps.some((t) => t < inv.idx) || !inv.errexit && st.traps.some((t) => t > inv.idx)) {
3213
+ return strip({ ...inv, state: "neutered", why: "a `trap` on EXIT/ERR exits 0 whatever the gate returned" });
3214
+ }
3215
+ if (inv.passthrough && !inv.errexit && inv.idx !== -1 && inv.idx < st.lastReal) {
3216
+ return strip({ ...inv, state: "neutered", why: "without `set -e` its failure does not fail the hook \u2014 other statements run after it" });
3217
+ }
3218
+ return strip(inv);
3219
+ });
3220
+ }
3221
+ var strip = (inv) => {
3222
+ const { idx: _i, errexit: _e, passthrough: _p, ...rest } = inv;
3223
+ return rest;
3224
+ };
3225
+ function scriptWeakening(before, after, opts = {}) {
3226
+ const beforeInv = invocations(before, opts);
3227
+ const afterInv = invocations(after, opts);
2660
3228
  const live = (inv) => new Set(inv.filter((i) => i.state === "live").map((i) => i.identity));
2661
- const bl = live(invocations(before));
2662
- const afterInv = invocations(after);
3229
+ const bl = live(beforeInv);
2663
3230
  const al = live(afterInv);
2664
3231
  const out2 = [];
2665
3232
  for (const id of bl) {
2666
3233
  if (al.has(id)) continue;
2667
- const trace = afterInv.find((i) => i.identity === id);
2668
- const how = trace?.state === "neutered" ? "neutralised in place (its failure can no longer fail the hook)" : trace?.state === "unreachable" ? "made unreachable (behind an always-false guard or an early exit)" : trace?.state === "comment" ? "commented out" : "removed";
2669
- const evidence = (trace?.line ?? before.find((l) => checkIdentity(stripComment(l).trim().replace(PREFIX, "")) === id) ?? id).trim();
3234
+ const trace = afterInv.find((i) => i.identity === id) ?? afterInv.find((i) => i.identity.split(" --cwd ")[0] === id.split(" --cwd ")[0]);
3235
+ const how = trace?.state === "neutered" ? `neutralised in place (${trace.why ?? "its failure can no longer fail the hook"})` : trace?.state === "unreachable" ? `made unreachable (${trace.why ?? "behind an always-false guard or an early exit"})` : trace?.state === "comment" ? "commented out" : trace && trace.identity !== id ? `replaced by \`${trace.identity}\`` : "removed";
3236
+ const evidence = (trace?.line ?? before.find((l) => checkIdentity(stripComment(l).trim().replace(/^!\s+/, "").replace(PREFIX, "")) === id) ?? id).trim();
2670
3237
  out2.push({ reason: `the check invocation \`${id}\` was ${how}`, evidence });
2671
3238
  }
3239
+ const floor = beforeInv.filter((i) => i.state === "live" && i.pin && PLAIN_SEMVER.test(i.pin)).map((i) => i.pin).sort((a, b) => compareVersions(a, b) ?? 0)[0];
3240
+ if (floor) {
3241
+ for (const inv of afterInv) {
3242
+ if (inv.state !== "live" || !inv.pin || !PLAIN_SEMVER.test(inv.pin)) continue;
3243
+ if ((compareVersions(inv.pin, floor) ?? 0) < 0) out2.push({ reason: `the gate's pin was lowered from ${floor} to ${inv.pin}`, evidence: inv.line.trim() });
3244
+ }
3245
+ }
2672
3246
  return out2;
2673
3247
  }
3248
+ function shebangProblem(src) {
3249
+ const first = src.split("\n")[0] ?? "";
3250
+ if (!first.startsWith("#!")) return null;
3251
+ const body = first.slice(2).trim();
3252
+ const toks = body.split(/\s+/);
3253
+ let i = 0;
3254
+ if (/(?:^|\/)env$/.test(toks[0] ?? "")) {
3255
+ i = 1;
3256
+ while (i < toks.length && toks[i].startsWith("-")) i++;
3257
+ }
3258
+ const interp = (toks[i] ?? "").replace(/^.*\//, "");
3259
+ if (!/^(?:sh|bash|dash|zsh|ksh|ash|busybox)$/.test(interp)) return `the shebang runs \`${body}\`, not a shell`;
3260
+ if (toks.slice(i + 1).some((t) => /^-[a-zA-Z]*c/.test(t))) return `the shebang passes \`-c\` \u2014 the interpreter runs the argument, not the script`;
3261
+ return null;
3262
+ }
2674
3263
  var isCodeowners = (path) => /(?:^|\/)CODEOWNERS$/.test(path);
2675
3264
  var GATE_CRITICAL = [
2676
- "/.github/workflows/",
3265
+ "/.github/workflows/tamperward.yml",
2677
3266
  "/.tamperward.yml",
2678
3267
  "/.github/CODEOWNERS",
2679
3268
  "/CODEOWNERS",
2680
3269
  "/docs/CODEOWNERS",
2681
- "/.husky/",
3270
+ "/.husky/pre-commit",
2682
3271
  "/.claude/settings.json",
2683
3272
  "/.claude/settings.local.json",
2684
3273
  "/.pre-commit-config.yaml",
2685
3274
  "/lefthook.yml",
2686
3275
  "/lefthook.yaml"
2687
3276
  ];
2688
- function covers(pattern, critical) {
2689
- if (pattern === "*" || pattern === "**") return true;
2690
- let p = pattern.replace(/\/\*\*$/, "/").replace(/\/\*$/, "/");
2691
- if (!p.startsWith("/")) p = "/" + p;
2692
- if (p === critical) return true;
2693
- if (p.endsWith("/") && critical.startsWith(p)) return true;
2694
- return critical.startsWith(p + "/");
3277
+ function codeownersMatcher(pattern) {
3278
+ let p = pattern.trim();
3279
+ if (p === "*" || p === "**") return () => true;
3280
+ let anchored = p.startsWith("/");
3281
+ if (anchored) p = p.slice(1);
3282
+ const dir = p.endsWith("/");
3283
+ p = p.replace(/\/+$/, "");
3284
+ if (p.includes("/")) anchored = true;
3285
+ if (p.startsWith("**/")) {
3286
+ anchored = false;
3287
+ p = p.slice(3);
3288
+ }
3289
+ let re = "";
3290
+ for (let i = 0; i < p.length; i++) {
3291
+ const ch = p[i];
3292
+ if (ch === "*") {
3293
+ if (p[i + 1] === "*") {
3294
+ re += ".*";
3295
+ i++;
3296
+ } else re += "[^/]*";
3297
+ } else if (ch === "?") re += "[^/]";
3298
+ else re += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
3299
+ }
3300
+ const body = (anchored ? "^/" : "(?:^|/)") + re;
3301
+ const exact = new RegExp(body + "$");
3302
+ const under = new RegExp(body + "/");
3303
+ return (file) => !dir && exact.test(file) || under.test(file);
2695
3304
  }
2696
3305
  function ownersOf(content, critical) {
2697
3306
  let owners = null;
@@ -2699,13 +3308,20 @@ function ownersOf(content, critical) {
2699
3308
  const t = line.trim();
2700
3309
  if (!t || t.startsWith("#")) continue;
2701
3310
  const [pattern, ...rest] = t.split(/\s+/);
2702
- if (covers(pattern, critical)) owners = rest.filter((o) => !o.startsWith("#"));
3311
+ let matches;
3312
+ try {
3313
+ matches = codeownersMatcher(pattern)(critical);
3314
+ } catch {
3315
+ matches = false;
3316
+ }
3317
+ if (matches) owners = rest.filter((o) => !o.startsWith("#"));
2703
3318
  }
2704
3319
  return owners;
2705
3320
  }
2706
- function codeownersWeakening(before, after) {
3321
+ function codeownersWeakening(before, after, extra = []) {
2707
3322
  const out2 = [];
2708
- for (const critical of GATE_CRITICAL) {
3323
+ const files = [.../* @__PURE__ */ new Set([...GATE_CRITICAL, ...extra.map((f) => f.startsWith("/") ? f : "/" + f)])];
3324
+ for (const critical of files) {
2709
3325
  const was = ownersOf(before, critical);
2710
3326
  if (!was || was.length === 0) continue;
2711
3327
  const now = ownersOf(after, critical);
@@ -2724,25 +3340,47 @@ function parseDoc(src) {
2724
3340
  }
2725
3341
  }
2726
3342
  var isLefthook = (path) => /(?:^|\/)\.?lefthook(?:-local)?\.(?:ya?ml|toml|json)$/.test(path);
3343
+ var isLefthookLocal = (path) => /(?:^|\/)\.?lefthook-local\.(?:ya?ml|toml|json)$/.test(path);
2727
3344
  var isPreCommitConfig = (path) => /(?:^|\/)\.pre-commit-config\.ya?ml$/.test(path);
2728
3345
  var isPackageJson = (path) => /(?:^|\/)package\.json$/.test(path);
2729
3346
  var GATE = /\btamperward\b/;
2730
3347
  var str = (v) => typeof v === "string" ? v : Array.isArray(v) ? v.map(String).join(" ") : v == null ? "" : JSON.stringify(v);
3348
+ var list = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" ? v.split(/[\s,]+/).filter(Boolean) : [];
3349
+ function runsGate(command) {
3350
+ return invocations([command], { errexit: true }).some((i) => i.state === "live" && /^tamperward check\b/.test(i.identity));
3351
+ }
3352
+ function mergeDocs(base, over) {
3353
+ const out2 = { ...base };
3354
+ for (const [k, v] of Object.entries(over)) {
3355
+ const b = out2[k];
3356
+ out2[k] = isDoc(b) && isDoc(v) ? mergeDocs(b, v) : v;
3357
+ }
3358
+ return out2;
3359
+ }
3360
+ var off = (v) => v === void 0 || v === null || v === false;
2731
3361
  function lefthookEntries(doc) {
2732
3362
  const out2 = /* @__PURE__ */ new Map();
2733
3363
  for (const [section, v] of Object.entries(doc)) {
2734
3364
  if (!isDoc(v)) continue;
3365
+ const sectionExclude = list(v.exclude_tags);
2735
3366
  for (const group of ["commands", "scripts"]) {
2736
3367
  const g = v[group];
2737
3368
  if (!isDoc(g)) continue;
2738
3369
  for (const [name, cfg] of Object.entries(g)) {
2739
- if (!isDoc(cfg) || !GATE.test(str(cfg.run ?? cfg.runner))) continue;
3370
+ if (!isDoc(cfg)) continue;
3371
+ const run = cfg.run ?? cfg.runner;
3372
+ const named = GATE.test(name);
3373
+ if (!GATE.test(str(run)) && !named) continue;
3374
+ const tags = list(cfg.tags);
2740
3375
  out2.set(`${section}.${group}.${name}`, {
3376
+ live: run === void 0 ? named : runsGate(str(run)),
2741
3377
  skip: cfg.skip,
3378
+ only: cfg.only,
2742
3379
  sectionSkip: v.skip,
3380
+ sectionOnly: v.only,
2743
3381
  glob: str(cfg.glob),
2744
3382
  exclude: str(cfg.exclude),
2745
- only: str(cfg.only)
3383
+ excludedByTag: tags.filter((t) => sectionExclude.includes(t))
2746
3384
  });
2747
3385
  }
2748
3386
  }
@@ -2754,33 +3392,46 @@ function lefthookWeakening(before, after) {
2754
3392
  const b = lefthookEntries(before);
2755
3393
  const a = lefthookEntries(after);
2756
3394
  for (const [key2, was] of b) {
3395
+ if (!was.live) continue;
2757
3396
  const now = a.get(key2);
2758
3397
  if (!now) {
2759
3398
  out2.push(`lefthook entry ${key2} (the gate) was removed`);
2760
3399
  continue;
2761
3400
  }
2762
- if (now.skip === true && was.skip !== true) out2.push(`lefthook entry ${key2} (the gate) is now \`skip: true\``);
2763
- if (now.sectionSkip === true && was.sectionSkip !== true) out2.push(`the lefthook hook carrying ${key2} (the gate) is now \`skip: true\``);
3401
+ if (!now.live) out2.push(`lefthook entry ${key2} no longer runs \`tamperward check\` live`);
3402
+ if (!off(now.skip) && off(was.skip)) out2.push(`lefthook entry ${key2} (the gate) is now \`skip: ${str(now.skip)}\``);
3403
+ if (!off(now.sectionSkip) && off(was.sectionSkip)) out2.push(`the lefthook hook carrying ${key2} (the gate) is now \`skip: ${str(now.sectionSkip)}\``);
3404
+ if (!off(now.only) && str(now.only) !== str(was.only)) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`only\` (${str(now.only)})`);
3405
+ if (!off(now.sectionOnly) && str(now.sectionOnly) !== str(was.sectionOnly)) out2.push(`the lefthook hook carrying ${key2} (the gate) gained or changed \`only\` (${str(now.sectionOnly)})`);
2764
3406
  if (now.glob !== was.glob && now.glob) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`glob\` (${now.glob}) \u2014 it no longer runs on every commit`);
2765
3407
  if (now.exclude !== was.exclude && now.exclude) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`exclude\` (${now.exclude})`);
2766
- if (now.only !== was.only && now.only) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`only\` (${now.only})`);
3408
+ if (now.excludedByTag.length && !was.excludedByTag.length) out2.push(`lefthook entry ${key2} (the gate) is tagged ${now.excludedByTag.join(", ")}, which the hook's \`exclude_tags\` switches off`);
2767
3409
  }
2768
3410
  return out2;
2769
3411
  }
2770
- var COMMIT_STAGES = /* @__PURE__ */ new Set(["commit", "pre-commit", "push", "pre-push", "commit-msg", "pre-merge-commit", "prepare-commit-msg"]);
3412
+ var STAGE_ALIAS = { commit: "pre-commit", push: "pre-push", merge_commit: "pre-merge-commit" };
3413
+ var GATE_STAGES = /* @__PURE__ */ new Set(["pre-commit", "pre-push"]);
3414
+ var ALL_STAGES = ["pre-commit", "pre-merge-commit", "pre-push", "prepare-commit-msg", "commit-msg", "post-checkout", "post-commit", "post-merge", "post-rewrite", "manual"];
3415
+ var normStage = (s) => STAGE_ALIAS[s] ?? s;
2771
3416
  function preCommitEntries(doc) {
2772
3417
  const out2 = /* @__PURE__ */ new Map();
2773
3418
  const repos = Array.isArray(doc.repos) ? doc.repos : [];
3419
+ const defaults = Array.isArray(doc.default_stages) ? doc.default_stages.map(String).map(normStage) : ALL_STAGES;
2774
3420
  for (const repo of repos) {
2775
3421
  if (!isDoc(repo)) continue;
2776
3422
  const hooks = Array.isArray(repo.hooks) ? repo.hooks : [];
2777
3423
  for (const h of hooks) {
2778
3424
  if (!isDoc(h)) continue;
2779
3425
  if (!GATE.test(`${str(repo.repo)} ${str(h.id)} ${str(h.entry)} ${str(h.name)}`)) continue;
3426
+ const live = h.entry !== void 0 ? runsGate(str(h.entry)) : GATE.test(`${str(repo.repo)} ${str(h.id)}`);
2780
3427
  out2.set(`${str(repo.repo)}:${str(h.id)}`, {
2781
- stages: Array.isArray(h.stages) ? h.stages.map(String) : null,
3428
+ live,
3429
+ stages: Array.isArray(h.stages) ? h.stages.map(String).map(normStage) : defaults,
3430
+ explicitStages: Array.isArray(h.stages),
2782
3431
  exclude: str(h.exclude),
2783
- files: str(h.files)
3432
+ files: str(h.files),
3433
+ types: [str(h.types), str(h.types_or), str(h.exclude_types)].join(" ").trim(),
3434
+ alwaysRun: h.always_run === true
2784
3435
  });
2785
3436
  }
2786
3437
  }
@@ -2791,28 +3442,45 @@ function preCommitWeakening(before, after) {
2791
3442
  const b = preCommitEntries(before);
2792
3443
  const a = preCommitEntries(after);
2793
3444
  for (const [key2, was] of b) {
3445
+ if (!was.live) continue;
2794
3446
  const now = a.get(key2);
2795
3447
  if (!now) {
2796
3448
  out2.push(`pre-commit hook ${key2} (the gate) was removed`);
2797
3449
  continue;
2798
3450
  }
2799
- const wasStages = was.stages ?? [...COMMIT_STAGES];
2800
- if (now.stages && !now.stages.some((s) => COMMIT_STAGES.has(s)) && wasStages.some((s) => COMMIT_STAGES.has(s))) {
3451
+ if (!now.live) out2.push(`pre-commit hook ${key2} no longer runs \`tamperward check\` live`);
3452
+ const lost = was.stages.filter((s) => GATE_STAGES.has(s) && !now.stages.includes(s));
3453
+ if (lost.length && !now.stages.some((s) => GATE_STAGES.has(s))) {
2801
3454
  out2.push(`pre-commit hook ${key2} (the gate) now runs only at stages [${now.stages.join(", ")}] \u2014 no commit or push triggers it`);
2802
- } else if (now.stages && was.stages) {
2803
- const lost = was.stages.filter((s) => COMMIT_STAGES.has(s) && !now.stages.includes(s));
2804
- if (lost.length) out2.push(`pre-commit hook ${key2} (the gate) no longer runs at stage(s) ${lost.join(", ")}`);
3455
+ } else if (lost.length) {
3456
+ out2.push(`pre-commit hook ${key2} (the gate) no longer runs at stage(s) ${lost.join(", ")}${now.explicitStages ? "" : " (default_stages changed)"}`);
2805
3457
  }
2806
3458
  if (now.exclude !== was.exclude && now.exclude) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`exclude\` (${now.exclude})`);
2807
3459
  if (now.files !== was.files && now.files) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`files\` (${now.files})`);
3460
+ if (now.types !== was.types && now.types && !now.alwaysRun) out2.push(`pre-commit hook ${key2} (the gate) is scoped by file type (${now.types}) without \`always_run\``);
2808
3461
  }
2809
3462
  const topB = str(before.exclude);
2810
3463
  const topA = str(after.exclude);
2811
3464
  if (topA && topA !== topB && b.size > 0) out2.push(`a top-level pre-commit \`exclude\` was added or changed (${topA}) \u2014 it scopes every hook, the gate included`);
2812
3465
  return out2;
2813
3466
  }
2814
- var INSTALLER = /\b(?:husky|lefthook|simple-git-hooks|pre-commit)\b/;
2815
3467
  var INSTALL_SCRIPTS = ["prepare", "postinstall", "install", "preinstall"];
3468
+ function installerOf(script) {
3469
+ for (const seg of script.split(/&&|\|\||;|\n/)) {
3470
+ const raw = seg.trim();
3471
+ if (!raw) continue;
3472
+ if (/^(?:HUSKY|LEFTHOOK)=0\b/.test(raw)) continue;
3473
+ const core = raw.replace(PREFIX, "").replace(RUNNER, "").replace(/\s+/g, " ");
3474
+ const m = core.match(/^(?:node\s+)?(?:\S*\/)?(husky|lefthook|simple-git-hooks|pre-commit)(?:\.js)?(?:\s+(\S+))?/);
3475
+ if (!m) continue;
3476
+ const [, tool, sub] = m;
3477
+ if (tool === "husky" && (sub === void 0 || sub === "install" || sub.startsWith("-"))) return sub === "install" ? "husky install" : "husky";
3478
+ if (tool === "lefthook" && sub === "install") return "lefthook install";
3479
+ if (tool === "pre-commit" && sub === "install") return "pre-commit install";
3480
+ if (tool === "simple-git-hooks" && (sub === void 0 || sub.startsWith("-"))) return "simple-git-hooks";
3481
+ }
3482
+ return null;
3483
+ }
2816
3484
  function packageJsonWeakening(before, after) {
2817
3485
  let b;
2818
3486
  let a;
@@ -2823,13 +3491,14 @@ function packageJsonWeakening(before, after) {
2823
3491
  return [];
2824
3492
  }
2825
3493
  if (!isDoc(b) || !isDoc(a)) return [];
2826
- const scripts = (d) => {
3494
+ const installers = (d) => {
2827
3495
  const sc = d.scripts;
2828
- return isDoc(sc) ? INSTALL_SCRIPTS.map((k) => str(sc[k])).join(" ") : "";
3496
+ if (!isDoc(sc)) return [];
3497
+ return INSTALL_SCRIPTS.map((k) => installerOf(str(sc[k]))).filter((x) => x !== null);
2829
3498
  };
2830
- const was = scripts(b).match(INSTALLER)?.[0];
3499
+ const was = installers(b)[0];
2831
3500
  if (!was) return [];
2832
- if (INSTALLER.test(scripts(a))) return [];
3501
+ if (installers(a).length) return [];
2833
3502
  return [`the install script that wires the git hooks (\`${was}\`) was removed from package.json scripts \u2014 a fresh checkout installs no hooks`];
2834
3503
  }
2835
3504
  var GIT_HOOKS = /* @__PURE__ */ new Set([
@@ -2856,7 +3525,7 @@ var GIT_HOOKS = /* @__PURE__ */ new Set([
2856
3525
  ]);
2857
3526
  function hookIdentity(path) {
2858
3527
  const base = path.split("/").pop() ?? "";
2859
- if (isLefthook(path)) return "lefthook";
3528
+ if (isLefthook(path)) return isLefthookLocal(path) ? "lefthook-local" : "lefthook";
2860
3529
  if (isPreCommitConfig(path)) return "pre-commit-config";
2861
3530
  if (base === "CODEOWNERS") return "codeowners";
2862
3531
  const husky = path.match(/(?:^|\/)\.husky\/([^/]+)$/);
@@ -2864,7 +3533,7 @@ function hookIdentity(path) {
2864
3533
  return null;
2865
3534
  }
2866
3535
  var ownerExec = (mode) => (parseInt(mode.slice(-3).padStart(3, "0"), 8) & 64) !== 0;
2867
- var SYMBOLIC = /^[ugoa]*([-+=])([rwxXst]*)$/;
3536
+ var SYMBOLIC = /^([ugoa]*)((?:[-+=][rwxXstugo]*)+)$/;
2868
3537
  function chmodDropsExec(toks) {
2869
3538
  const at = toks.findIndex((t) => t === "chmod" || t.endsWith("/chmod"));
2870
3539
  if (at === -1) return false;
@@ -2877,31 +3546,113 @@ function chmodDropsExec(toks) {
2877
3546
  for (const clause of tok.split(",")) {
2878
3547
  const m = clause.match(SYMBOLIC);
2879
3548
  if (!m) continue;
2880
- const [, op, perms] = m;
2881
- if (op === "-" && /[xX]/.test(perms)) return true;
2882
- if (op === "=" && !/[xX]/.test(perms)) return true;
3549
+ const [, who, opsText] = m;
3550
+ if (who && !/[ua]/.test(who)) continue;
3551
+ let exec = null;
3552
+ for (const g of opsText.match(/[-+=][rwxXstugo]*/g) ?? []) {
3553
+ const op = g[0];
3554
+ const perms = g.slice(1);
3555
+ if (/[ugo]/.test(perms)) {
3556
+ exec = false;
3557
+ continue;
3558
+ }
3559
+ if (op === "-" && /[xX]/.test(perms)) exec = false;
3560
+ else if (op === "+" && /[xX]/.test(perms)) exec = true;
3561
+ else if (op === "=") exec = /[xX]/.test(perms);
3562
+ }
3563
+ if (exec === false) return true;
2883
3564
  }
2884
3565
  }
2885
3566
  return false;
2886
3567
  }
3568
+ function setfaclDropsExec(toks) {
3569
+ if (!toks.some((t) => /^-[a-zA-Z]*m|^--modify/.test(t))) return false;
3570
+ return toks.some((t) => t.split(",").some((e) => {
3571
+ const m = e.match(/^(?:u|user)::([rwxX-]*)$/);
3572
+ return !!m && !/[xX]/.test(m[1]);
3573
+ }));
3574
+ }
2887
3575
  var WRAPPERS = /^(?:sudo|command|exec|time|nice|env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/;
2888
- var REDIRECT_TARGET = /(?:^|\s)\d*>{1,2}\|?\s*(\S+)/g;
2889
- var hookPath = (t) => t.replace(/^["']|["']$/g, "").replace(/^\.\//, "");
2890
- function shellHookTarget(seg, toks, policy) {
3576
+ var INTERPRETERS = /* @__PURE__ */ new Set(["python", "python2", "python3", "node", "perl", "ruby", "php", "deno", "bun"]);
3577
+ var INLINE_FLAG = /^-(?:c|e|r|E|p|pe|pi|i|ne|ni|le)$|^--eval$|^--print$|^--exec$/;
3578
+ var EDITORS = /* @__PURE__ */ new Set(["ex", "ed", "vim", "vi", "nvim"]);
3579
+ var HOOK_BASENAMES = ["pre-commit", "pre-push", "commit-msg", "settings.json", "settings.local.json", ".tamperward.yml", "lefthook.yml", "lefthook.yaml", "lefthook-local.yml", ".pre-commit-config.yaml", "CODEOWNERS"];
3580
+ function nameGlob(pattern) {
3581
+ const re = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
3582
+ return (n) => re.test(n);
3583
+ }
3584
+ function destinations(cmd, args, isDir) {
3585
+ const positional = [];
3586
+ let target = null;
3587
+ for (let i = 0; i < args.length; i++) {
3588
+ const a = args[i];
3589
+ if (a === "--") {
3590
+ positional.push(...args.slice(i + 1));
3591
+ break;
3592
+ }
3593
+ if (a === "-t" || a === "--target-directory") {
3594
+ target = args[++i] ?? null;
3595
+ continue;
3596
+ }
3597
+ const eq = a.match(/^--target-directory=(.*)$/);
3598
+ if (eq) {
3599
+ target = eq[1];
3600
+ continue;
3601
+ }
3602
+ if (cmd === "install" && /^-[mogS]$/.test(a) || cmd === "install" && /^--(?:mode|owner|group|suffix)$/.test(a) || cmd === "rsync" && /^(?:-e|--rsh|--exclude|--include|--files-from)$/.test(a)) {
3603
+ i++;
3604
+ continue;
3605
+ }
3606
+ if (a.startsWith("-")) continue;
3607
+ positional.push(a);
3608
+ }
3609
+ const base = (p) => p.replace(/\/+$/, "").split("/").pop() ?? p;
3610
+ if (target !== null) return positional.map((p) => `${target.replace(/\/+$/, "")}/${base(p)}`);
3611
+ if (positional.length < 2) return [];
3612
+ const dest = positional[positional.length - 1];
3613
+ const srcs = positional.slice(0, -1);
3614
+ if (dest.endsWith("/") || srcs.length > 1 || isDir(dest)) return [dest, ...srcs.map((s) => `${dest.replace(/\/+$/, "")}/${base(s)}`)];
3615
+ return [dest];
3616
+ }
3617
+ var REDIRECT_TARGET = /(?:^|\s)(?:\d*>{1,2}\|?|&>{1,2})\s*(\S+)/g;
3618
+ function hookTests(policy, ctx) {
3619
+ const rel = (t) => {
3620
+ let p = t.replace(/^["']|["']$/g, "");
3621
+ if (ctx?.cwd && p.startsWith(ctx.cwd + "/")) p = p.slice(ctx.cwd.length + 1);
3622
+ return p.replace(/^\.\/(?=.)/, "");
3623
+ };
3624
+ const hook = (t) => isProtected(rel(t), policy, "hooks");
3625
+ const whole = (t) => /^(?:\.|\.\/|:\/|\*)$/.test(rel(t)) || ctx?.cwd !== void 0 && rel(t) === ctx.cwd;
3626
+ const holds = (t) => !t.startsWith("-") && (whole(t) || containsProtected(rel(t), policy, "hooks", ctx));
3627
+ const hookish = (t) => hook(t) || holds(t);
3628
+ return { rel, hook, whole, holds, hookish };
3629
+ }
3630
+ function shellHookTarget(seg, toks, policy, ctx) {
3631
+ const { rel, hook, whole, holds } = hookTests(policy, ctx);
2891
3632
  for (const m of seg.matchAll(REDIRECT_TARGET)) {
2892
- const t = hookPath(m[1]);
2893
- if (isProtected(t, policy, "hooks")) return t;
3633
+ if (hook(m[1])) return rel(m[1]);
2894
3634
  }
2895
- for (const tok of toks) {
2896
- const t = hookPath(tok.replace(/^of=/, ""));
2897
- if (isProtected(t, policy, "hooks")) return t;
3635
+ const args = toks.map((t) => t.replace(/^(?:of=|--(?:include|exclude|directory)=)/, ""));
3636
+ const fileLike = (p) => {
3637
+ const b = p.replace(/\/+$/, "").split("/").pop() ?? "";
3638
+ return HOOK_BASENAMES.includes(b) || /\.[A-Za-z0-9]+$/.test(b.replace(/^\.+/, "")) || (trackedFiles(ctx)?.includes(p) ?? false);
3639
+ };
3640
+ for (const t of args) if (hook(t) && fileLike(rel(t))) return rel(t);
3641
+ for (const t of args) {
3642
+ if (t.startsWith("-") || !holds(t)) continue;
3643
+ const dir = rel(t).replace(/\/+$/, "");
3644
+ const under = whole(t) ? "" : dir + "/";
3645
+ const listed = trackedFiles(ctx)?.find((f) => f.startsWith(under) && isProtected(f, policy, "hooks"));
3646
+ if (listed) return listed;
3647
+ const guess = under + "pre-commit";
3648
+ return isProtected(guess, policy, "hooks") ? guess : whole(t) ? ".tamperward.yml" : dir;
2898
3649
  }
2899
3650
  return null;
2900
3651
  }
2901
- function shellWritesHook(seg, toks, policy) {
2902
- const hook = (t) => isProtected(t.replace(/^\.\//, ""), policy, "hooks");
3652
+ function shellWritesHook(seg, toks, policy, ctx) {
3653
+ const { rel, hook, whole, holds, hookish } = hookTests(policy, ctx);
2903
3654
  for (const m of seg.matchAll(REDIRECT_TARGET)) {
2904
- if (hook(m[1].replace(/^["']|["']$/g, ""))) return "a redirect empties or rewrites a protected hook";
3655
+ if (hook(m[1])) return "a redirect empties or rewrites a protected hook";
2905
3656
  }
2906
3657
  let at = 0;
2907
3658
  while (at < toks.length && WRAPPERS.test(toks[at])) at++;
@@ -2911,21 +3662,28 @@ function shellWritesHook(seg, toks, policy) {
2911
3662
  const last = positional[positional.length - 1] ?? "";
2912
3663
  const anyHook = args.some(hook);
2913
3664
  const inPlace = args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a));
3665
+ const scripts = args.filter((_, i) => i > 0 && INLINE_FLAG.test(args[i - 1]));
3666
+ const namesHookInText = () => scripts.some((a) => (a.match(/[\w.~/-]+/g) ?? []).some(hook));
2914
3667
  switch (cmd) {
2915
3668
  case "rm":
2916
3669
  case "unlink":
2917
3670
  case "shred":
2918
- return anyHook ? "rm deletes a protected hook" : null;
3671
+ return anyHook || positional.some(holds) ? "rm deletes a protected hook" : null;
2919
3672
  case "truncate":
2920
3673
  return anyHook ? "truncate empties a protected hook" : null;
2921
3674
  case "chmod":
2922
- return anyHook && chmodDropsExec(toks) ? "chmod removes execute permission from a hook \u2014 git will skip it" : null;
3675
+ return (anyHook || positional.some(holds)) && chmodDropsExec(toks) ? "chmod removes execute permission from a hook \u2014 git will skip it" : null;
3676
+ case "setfacl":
3677
+ return anyHook && setfaclDropsExec(args) ? "setfacl removes the owner execute permission from a hook \u2014 git will skip it" : null;
2923
3678
  case "mv":
2924
- return anyHook ? "mv moves a protected hook out of place or overwrites it" : null;
3679
+ return anyHook || positional.some(holds) ? "mv moves a protected hook out of place or overwrites it" : null;
2925
3680
  case "cp":
2926
3681
  case "install":
2927
3682
  case "ln":
2928
- return hook(last) ? `${cmd} replaces a protected hook in place` : null;
3683
+ case "rsync": {
3684
+ const dests = destinations(cmd, args, holds);
3685
+ return dests.some(hook) ? `${cmd} replaces a protected hook in place` : null;
3686
+ }
2929
3687
  case "tee":
2930
3688
  case "sponge":
2931
3689
  return anyHook ? `${cmd} rewrites a protected hook in place` : null;
@@ -2933,20 +3691,45 @@ function shellWritesHook(seg, toks, policy) {
2933
3691
  return args.some((a) => a.startsWith("of=") && hook(a.slice(3))) ? "dd rewrites a protected hook in place" : null;
2934
3692
  case "sed":
2935
3693
  case "perl":
2936
- return anyHook && inPlace ? `an in-place ${cmd} rewrites a protected hook` : null;
3694
+ if (anyHook && inPlace) return `an in-place ${cmd} rewrites a protected hook`;
3695
+ if (cmd === "perl" && args.some((a) => INLINE_FLAG.test(a)) && namesHookInText()) return "an inline perl script names a protected hook";
3696
+ return null;
2937
3697
  case "awk":
2938
3698
  case "gawk":
2939
3699
  return anyHook && args.some((a, i) => a === "-i" && args[i + 1] === "inplace" || a === "--in-place" || a === "-i=inplace") ? "an in-place awk rewrites a protected hook" : null;
3700
+ case "patch":
3701
+ return anyHook ? "patch rewrites a protected hook" : null;
3702
+ case "xargs":
3703
+ return null;
3704
+ // judged by the caller, which sees the segments feeding it
3705
+ case "find": {
3706
+ const mutates = /\s(?:-delete\b|-exec(?:dir)?\s+(?:rm|unlink|chmod|truncate|mv|sed|tee|shred)\b)/.test(seg);
3707
+ if (!mutates) return null;
3708
+ if (/-exec(?:dir)?\s+chmod\b/.test(seg) && !chmodDropsExec(toks)) return null;
3709
+ const dirs = positional.filter((p) => !/^-/.test(p) && !["{}", ";", "+", "rm", "chmod", "unlink", "truncate", "mv", "sed", "tee", "shred", "inplace"].includes(p));
3710
+ const names = args.filter((a, i) => /^-i?(?:name|path|regex)$/.test(args[i - 1] ?? ""));
3711
+ const nameHits = names.length === 0 || names.some((n) => {
3712
+ try {
3713
+ const g = nameGlob(n.replace(/^.*\//, ""));
3714
+ return HOOK_BASENAMES.some(g);
3715
+ } catch {
3716
+ return true;
3717
+ }
3718
+ });
3719
+ const reachesHook = dirs.some((d) => hook(d) || holds(d) || whole(d) && nameHits) || dirs.length === 0 && nameHits;
3720
+ return reachesHook && nameHits ? "find rewrites or removes protected hooks" : null;
3721
+ }
2940
3722
  case "git": {
2941
3723
  let i = 0;
2942
3724
  while (i < args.length && args[i].startsWith("-")) i += args[i] === "-C" || args[i] === "-c" ? 2 : 1;
2943
3725
  const sub = args[i];
2944
3726
  const rest = args.slice(i + 1);
2945
- if (!rest.some(hook)) return null;
3727
+ if (sub === "apply" || sub === "am") return rest.some((a) => hook(a.replace(/^--(?:include|exclude|directory)=/, ""))) ? `git ${sub} applies a patch that names a protected hook` : null;
3728
+ if (!rest.some(hookish)) return null;
2946
3729
  if (sub === "checkout") {
2947
3730
  const dd = rest.indexOf("--");
2948
3731
  const before = dd === -1 ? rest : rest.slice(0, dd);
2949
- const revs = before.filter((p) => !p.startsWith("-") && !hook(p));
3732
+ const revs = before.filter((p) => !p.startsWith("-") && !hookish(p));
2950
3733
  return revs.length > 0 ? "git checkout <rev> -- <hook> puts an older version of a protected hook back" : null;
2951
3734
  }
2952
3735
  if (sub === "restore" && rest.some((a) => /^--source(?:=|$)/.test(a) || a === "-s")) {
@@ -2955,108 +3738,290 @@ function shellWritesHook(seg, toks, policy) {
2955
3738
  return null;
2956
3739
  }
2957
3740
  default:
3741
+ if (INTERPRETERS.has(cmd) && inPlace && anyHook) return `an in-place ${cmd} rewrites a protected hook`;
3742
+ if (INTERPRETERS.has(cmd) && args.some((a) => INLINE_FLAG.test(a)) && namesHookInText()) return `an inline ${cmd} script names a protected hook`;
3743
+ if (EDITORS.has(cmd) && (anyHook || args.some((a) => /^-[a-zA-Z]*[cs]/.test(a) || a === "--cmd") && namesHookInText())) return `${cmd} rewrites a protected hook`;
2958
3744
  return null;
2959
3745
  }
2960
3746
  }
3747
+ function xargsWritesHook(toks, fed, policy, ctx) {
3748
+ const at = toks.findIndex((t) => t === "xargs" || t.endsWith("/xargs"));
3749
+ if (at === -1) return null;
3750
+ const rel = (t) => (ctx?.cwd && t.startsWith(ctx.cwd + "/") ? t.slice(ctx.cwd.length + 1) : t).replace(/^\.\//, "");
3751
+ const fedHook = fed.some((t) => isProtected(rel(t), policy, "hooks") || !t.startsWith("-") && containsProtected(rel(t), policy, "hooks", ctx));
3752
+ if (!fedHook) return null;
3753
+ let i = at + 1;
3754
+ while (i < toks.length && toks[i].startsWith("-")) i += /^-(?:I|n|L|P|d|a|s)$/.test(toks[i]) ? 2 : 1;
3755
+ const cmd = (toks[i] ?? "").replace(/^.*\//, "");
3756
+ const args = toks.slice(i + 1);
3757
+ if (["rm", "unlink", "shred", "truncate", "mv", "tee", "sponge"].includes(cmd)) return `xargs ${cmd} rewrites or removes a protected hook named earlier in the line`;
3758
+ if (cmd === "chmod" && chmodDropsExec(toks.slice(i))) return "xargs chmod removes execute permission from a hook named earlier in the line";
3759
+ if ((cmd === "sed" || cmd === "perl") && args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a))) return `xargs ${cmd} -i rewrites a protected hook named earlier in the line`;
3760
+ return null;
3761
+ }
2961
3762
 
2962
3763
  // src/detectors/hook-tampering.ts
2963
3764
  var RULE6 = "hook-tampering";
2964
3765
  var ownerExec2 = (mode) => (parseInt(mode.slice(-3), 8) & 64) !== 0;
2965
- var CLAUDE_SETTINGS = /(?:^|\/)\.claude\/settings(?:\.local)?\.json$/;
2966
- var isClaudeSettings = (p) => CLAUDE_SETTINGS.test(p);
2967
- function siblingSettings(p) {
2968
- return p.endsWith("settings.local.json") ? p.replace(/settings\.local\.json$/, "settings.json") : p.replace(/settings\.json$/, "settings.local.json");
2969
- }
2970
- var TW_INVOCATION = /\btamperward(?:@\S+)?\s+(?:hook|sweep)\s+claude\b/;
2971
3766
  var EVENTS = ["PreToolUse", "Stop"];
3767
+ var isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
2972
3768
  function parseSettings(src) {
2973
3769
  try {
2974
3770
  const v = JSON.parse(src);
2975
- return v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
3771
+ return isObj(v) ? v : null;
2976
3772
  } catch {
2977
3773
  return null;
2978
3774
  }
2979
3775
  }
2980
- function toolSet(matcher2) {
2981
- const m = typeof matcher2 === "string" ? matcher2.trim() : "";
2982
- if (m === "" || m === "*") return null;
2983
- return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
2984
- }
2985
3776
  function eventEntries(s, event) {
2986
3777
  const hooks = s.hooks;
2987
- if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) return null;
3778
+ if (!isObj(hooks)) return null;
2988
3779
  const arr = hooks[event];
2989
3780
  return Array.isArray(arr) ? arr : null;
2990
3781
  }
2991
3782
  var declares = (s, event) => eventEntries(s, event) !== null;
2992
- function gateWiring(s, event) {
3783
+ var GATE_KEYS = /* @__PURE__ */ new Set(["type", "command", "timeout"]);
3784
+ var CANONICAL_CMD = /^npx --yes tamperward@(\S+) (hook|sweep) claude$/;
3785
+ var MENTIONS_GATE = /\btamperward\b.*\b(?:hook|sweep)\b/;
3786
+ var FORGERY = /hookSpecificOutput|permissionDecision|updatedInput|"decision"|"continue"/;
3787
+ var SENSITIVE_ENV = /^(?:PATH|NODE_OPTIONS|NODE_PATH|HOME|npm_config_.*|NPM_CONFIG_.*)$/;
3788
+ function analyseGateCommand(cmd, event) {
3789
+ const want = event === "PreToolUse" ? "hook" : "sweep";
3790
+ const problems = [];
3791
+ let runs = true;
3792
+ const canonical2 = cmd.match(CANONICAL_CMD);
3793
+ if (canonical2) {
3794
+ if (canonical2[2] !== want) {
3795
+ problems.push(`runs \`${canonical2[2]} claude\` under ${event}`);
3796
+ runs = false;
3797
+ }
3798
+ if (!PLAIN_SEMVER.test(canonical2[1])) {
3799
+ problems.push(`the pin \`${canonical2[1]}\` is not a plain version`);
3800
+ runs = false;
3801
+ }
3802
+ return { runs, problems, pin: canonical2[1] };
3803
+ }
3804
+ const inv = cmd.match(/\btamperward(?:@(\S+))?\s+(hook|sweep)\s+(\S+)/);
3805
+ if (!inv) return { runs: false, problems: ["the command does not invoke the gate"], pin: null };
3806
+ if (inv[2] !== want || inv[3] !== "claude") {
3807
+ problems.push(`runs \`${inv[2]} ${inv[3]}\` under ${event}`);
3808
+ runs = false;
3809
+ }
3810
+ if (inv[1] !== void 0 && !PLAIN_SEMVER.test(inv[1])) {
3811
+ problems.push(`the pin \`${inv[1]}\` is not a plain version`);
3812
+ runs = false;
3813
+ }
3814
+ if (/[|;&<>`]|\$\(/.test(cmd)) {
3815
+ problems.push("text around the invocation (a redirect, pipe or chain)");
3816
+ runs = false;
3817
+ }
3818
+ if (/^\s*[A-Za-z_]\w*=/.test(cmd)) {
3819
+ problems.push("an environment assignment in front of the gate");
3820
+ runs = false;
3821
+ }
3822
+ if (/\b(?:sh|bash|zsh|dash)\s+-[a-zA-Z]*c\b/.test(cmd)) {
3823
+ problems.push("wrapped in `sh -c`");
3824
+ runs = false;
3825
+ }
3826
+ if (/(?:^|\s)(?:-p|--package)(?:=|\s)/.test(cmd)) {
3827
+ problems.push("`-p`/`--package` points npx at another package");
3828
+ runs = false;
3829
+ }
3830
+ const at = cmd.indexOf(inv[0]);
3831
+ const trailing = cmd.slice(at + inv[0].length).trim();
3832
+ if (trailing) {
3833
+ problems.push(`extra text after the invocation (\`${trailing}\`)`);
3834
+ runs = false;
3835
+ }
3836
+ const head = cmd.slice(0, at).trim();
3837
+ if (head !== "npx --yes") problems.push(`the launcher \`${head || "(none)"}\` is not \`npx --yes\``);
3838
+ return { runs, problems, pin: inv[1] ?? "" };
3839
+ }
3840
+ function matcherSelection(m) {
3841
+ if (m === void 0) return { tools: null, problem: null };
3842
+ if (typeof m !== "string") return { tools: /* @__PURE__ */ new Set(), problem: `the matcher ${JSON.stringify(m)} is not a string` };
3843
+ if (m === "" || m === "*") return { tools: null, problem: null };
3844
+ let problem = null;
3845
+ if (/[^\x00-\x7f]/.test(m)) problem = "the matcher contains non-ASCII characters that look like tool names but are not";
3846
+ if (/^[A-Za-z0-9_ |-]+$/.test(m)) return { tools: new Set(m.split("|").map((t) => t.trim()).filter(Boolean)), problem };
3847
+ let re;
3848
+ try {
3849
+ re = new RegExp(m);
3850
+ } catch {
3851
+ return { tools: /* @__PURE__ */ new Set(), problem: `the matcher \`${m}\` is not a valid pattern \u2014 it selects nothing` };
3852
+ }
3853
+ return { tools: new Set(PRE_TOOLS.filter((t) => re.test(t))), problem };
3854
+ }
3855
+ function gateCandidates(s, event) {
2993
3856
  const out2 = [];
2994
- for (const m of eventEntries(s, event) ?? []) {
2995
- if (m === null || typeof m !== "object") continue;
2996
- const matcher2 = m;
2997
- if (!Array.isArray(matcher2.hooks)) continue;
2998
- for (const h of matcher2.hooks) {
2999
- if (h === null || typeof h !== "object") continue;
3000
- const entry = h;
3001
- if (entry.type !== void 0 && entry.type !== "command") continue;
3002
- if (typeof entry.command !== "string" || !TW_INVOCATION.test(entry.command)) continue;
3003
- out2.push({ tools: toolSet(matcher2.matcher), command: entry.command });
3857
+ const mentions = (v) => /\btamperward\b/.test(JSON.stringify(v) ?? "");
3858
+ for (const group of eventEntries(s, event) ?? []) {
3859
+ if (!isObj(group)) {
3860
+ if (mentions(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["the matcher entry is not an object"], pin: null, tools: null });
3861
+ continue;
3862
+ }
3863
+ if (!Array.isArray(group.hooks)) {
3864
+ if (mentions(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["`hooks` is not a list"], pin: null, tools: null });
3865
+ continue;
3866
+ }
3867
+ for (const h of group.hooks) {
3868
+ if (!isObj(h)) {
3869
+ if (mentions(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the hook entry is not an object"], pin: null, tools: null });
3870
+ continue;
3871
+ }
3872
+ const cmd = typeof h.command === "string" ? h.command : null;
3873
+ if (cmd === null) {
3874
+ if (mentions(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the gate is not in a `command` string"], pin: null, tools: null });
3875
+ continue;
3876
+ }
3877
+ if (!MENTIONS_GATE.test(cmd)) continue;
3878
+ const a = analyseGateCommand(cmd, event);
3879
+ const problems = [...a.problems];
3880
+ let runs = a.runs;
3881
+ if (h.type !== "command") {
3882
+ problems.push(h.type === void 0 ? "no `type`" : `\`type\` is ${JSON.stringify(h.type)}`);
3883
+ if (h.type !== void 0) runs = false;
3884
+ }
3885
+ for (const k of Object.keys(h)) {
3886
+ if (GATE_KEYS.has(k)) continue;
3887
+ problems.push(`carries \`${k}\``);
3888
+ runs = false;
3889
+ }
3890
+ if ("timeout" in h) {
3891
+ const t = h.timeout;
3892
+ if (typeof t !== "number" || !Number.isInteger(t) || t < 120) {
3893
+ problems.push(`\`timeout\` ${JSON.stringify(t)} \u2014 the gate is cut off before it answers`);
3894
+ runs = false;
3895
+ }
3896
+ }
3897
+ let tools = null;
3898
+ if (event === "PreToolUse") {
3899
+ const sel = matcherSelection(group.matcher);
3900
+ tools = sel.tools;
3901
+ if (sel.problem) {
3902
+ problems.push(sel.problem);
3903
+ }
3904
+ } else if (group.matcher !== void 0 && !(typeof group.matcher === "string" && (group.matcher === "" || group.matcher === "*"))) {
3905
+ problems.push(`a \`matcher\` (${JSON.stringify(group.matcher)}) on the Stop entry`);
3906
+ runs = false;
3907
+ }
3908
+ out2.push({ key: JSON.stringify({ m: group.matcher, h }), runs, problems, pin: a.pin, tools });
3004
3909
  }
3005
3910
  }
3006
3911
  return out2;
3007
3912
  }
3008
- function coverage(ws2) {
3009
- if (ws2.length === 0) return "none";
3010
- if (ws2.some((w) => w.tools === null)) return null;
3011
- return new Set(ws2.flatMap((w) => [...w.tools]));
3913
+ function otherCommands(s, event) {
3914
+ const out2 = [];
3915
+ for (const group of eventEntries(s, event) ?? []) {
3916
+ if (!isObj(group) || !Array.isArray(group.hooks)) continue;
3917
+ for (const h of group.hooks) {
3918
+ if (!isObj(h) || typeof h.command !== "string" || MENTIONS_GATE.test(h.command)) continue;
3919
+ out2.push(h.command);
3920
+ }
3921
+ }
3922
+ return out2;
3923
+ }
3924
+ function coverage(cs) {
3925
+ const live = cs.filter((c) => c.runs);
3926
+ if (live.length === 0) return "none";
3927
+ if (live.some((c) => c.tools === null)) return null;
3928
+ return new Set(live.flatMap((c) => [...c.tools]));
3929
+ }
3930
+ function duplicateJsonKeys(src) {
3931
+ const dups = [];
3932
+ const stack = [];
3933
+ let expectKey = false;
3934
+ for (let i = 0; i < src.length; i++) {
3935
+ const ch = src[i];
3936
+ if (ch === '"') {
3937
+ let j = i + 1;
3938
+ let s = "";
3939
+ while (j < src.length && src[j] !== '"') {
3940
+ if (src[j] === "\\") {
3941
+ s += src[j + 1] ?? "";
3942
+ j += 2;
3943
+ } else {
3944
+ s += src[j];
3945
+ j++;
3946
+ }
3947
+ }
3948
+ if (expectKey) {
3949
+ const set = stack[stack.length - 1];
3950
+ if (set) {
3951
+ if (set.has(s)) dups.push(s);
3952
+ set.add(s);
3953
+ }
3954
+ expectKey = false;
3955
+ }
3956
+ i = j;
3957
+ continue;
3958
+ }
3959
+ if (ch === "{") {
3960
+ stack.push(/* @__PURE__ */ new Set());
3961
+ expectKey = true;
3962
+ } else if (ch === "[") {
3963
+ stack.push(null);
3964
+ expectKey = false;
3965
+ } else if (ch === "}" || ch === "]") {
3966
+ stack.pop();
3967
+ expectKey = false;
3968
+ } else if (ch === ",") expectKey = stack[stack.length - 1] !== null;
3969
+ }
3970
+ return [...new Set(dups)];
3012
3971
  }
3013
- var hooksDisabled = (s) => s.disableAllHooks === true;
3014
- function settingsWeakening(before, after, sibling) {
3972
+ function settingsWeakening(before, after, local) {
3015
3973
  const reasons = [];
3016
- if (hooksDisabled(after) && !(before !== null && hooksDisabled(before))) {
3017
- reasons.push("`disableAllHooks: true` switches every hook off, the gate included");
3974
+ const dis = after.disableAllHooks;
3975
+ if (dis !== void 0 && dis !== false && !(before !== null && before.disableAllHooks === dis)) {
3976
+ reasons.push(`\`disableAllHooks: ${JSON.stringify(dis)}\` switches every hook off, the gate included`);
3977
+ }
3978
+ if (isObj(after.env)) {
3979
+ const was = before !== null && isObj(before.env) ? before.env : {};
3980
+ for (const [k, v] of Object.entries(after.env)) {
3981
+ if (SENSITIVE_ENV.test(k) && JSON.stringify(was[k]) !== JSON.stringify(v)) {
3982
+ reasons.push(`a top-level \`env\` sets ${k} \u2014 the gate command resolves through it`);
3983
+ }
3984
+ }
3018
3985
  }
3019
3986
  for (const event of EVENTS) {
3020
- const was = before ? coverage(gateWiring(before, event)) : "none";
3021
- const now = coverage(gateWiring(after, event));
3987
+ const bc = before !== null ? gateCandidates(before, event) : [];
3988
+ const ac = gateCandidates(after, event);
3989
+ const was = coverage(bc);
3990
+ const now = coverage(ac);
3022
3991
  if (was !== "none") {
3023
3992
  if (now === "none") {
3024
3993
  reasons.push(`the tamperward ${event} hook entry was removed`);
3025
3994
  } else if (event === "PreToolUse") {
3026
3995
  if (was === null && now !== null) {
3027
- reasons.push(`the PreToolUse matcher for the gate was narrowed from every tool to ${[...now].join("|")}`);
3996
+ reasons.push(`the PreToolUse matcher for the gate was narrowed from every tool to ${[...now].join("|") || "(nothing)"}`);
3028
3997
  } else if (was !== null && now !== null) {
3029
3998
  const lost = [...was].filter((t) => !now.has(t));
3030
3999
  if (lost.length) reasons.push(`the PreToolUse matcher for the gate no longer covers ${lost.join(", ")}`);
3031
4000
  }
3032
4001
  }
3033
- continue;
4002
+ } else if (now === "none" && declares(after, event) && !(before !== null && declares(before, event)) && (eventEntries(after, event) ?? []).length === 0) {
4003
+ reasons.push(`${event} hooks declared empty in ${local ? "an override file" : "the settings file"}`);
4004
+ }
4005
+ const had = new Set(bc.map((c) => c.key));
4006
+ const fresh = ac.filter((c) => !had.has(c.key));
4007
+ for (const c of fresh) {
4008
+ if (c.problems.length) reasons.push(`the ${event} gate entry does not match the shape \`tamperward init\` writes: ${c.problems.join("; ")}`);
4009
+ }
4010
+ const pins = bc.map((c) => c.pin).filter((p) => !!p && PLAIN_SEMVER.test(p)).sort((a, b) => compareVersions(b, a) ?? 0);
4011
+ const floor = pins[0] ?? (before === null && PLAIN_SEMVER.test(TW_VERSION) ? TW_VERSION : null);
4012
+ if (floor) {
4013
+ for (const c of fresh) {
4014
+ if (c.pin && PLAIN_SEMVER.test(c.pin) && !pinNotBelow(c.pin, floor)) reasons.push(`the ${event} gate is pinned to tamperward@${c.pin}, below ${floor}`);
4015
+ }
3034
4016
  }
3035
- if (now !== "none" || !declares(after, event)) continue;
3036
- if (before !== null && declares(before, event)) continue;
3037
- const empty2 = (eventEntries(after, event) ?? []).length === 0;
3038
- const shadowed = sibling !== null && gateWiring(sibling, event).length > 0;
3039
- if (empty2 || shadowed) {
3040
- reasons.push(
3041
- `${event} hooks declared ${empty2 ? "empty" : "without the tamperward entry"} in an override file` + (shadowed ? " \u2014 this shadows the gate the sibling settings file wires" : "")
3042
- );
4017
+ const hadOther = new Set(before !== null ? otherCommands(before, event) : []);
4018
+ for (const cmd of otherCommands(after, event)) {
4019
+ if (!hadOther.has(cmd) && FORGERY.test(cmd)) reasons.push(`a ${event} hook entry beside the gate emits a hook decision of its own (\`${cmd.slice(0, 60)}\`)`);
3043
4020
  }
3044
4021
  }
3045
4022
  return reasons;
3046
4023
  }
3047
- function siblingOf(c, changes, ctx) {
3048
- const path = siblingSettings(c.path);
3049
- const inSet = changes.find((o) => o.kind === "file" && o.path === path);
3050
- if (inSet) return inSet.after != null ? parseSettings(inSet.after) : null;
3051
- if (!ctx?.cwd) return null;
3052
- try {
3053
- const p = join2(ctx.cwd, path);
3054
- return existsSync(p) ? parseSettings(readFileSync2(p, "utf8")) : null;
3055
- } catch {
3056
- return null;
3057
- }
3058
- }
3059
- function claudeSettingsFindings(c, changes, policy, ctx) {
4024
+ function claudeSettingsFindings(c, policy) {
3060
4025
  if (c.after == null) return null;
3061
4026
  const after = parseSettings(c.after);
3062
4027
  const before = c.before != null ? parseSettings(c.before) : null;
@@ -3064,14 +4029,19 @@ function claudeSettingsFindings(c, changes, policy, ctx) {
3064
4029
  file: c.path,
3065
4030
  message: `The Claude Code hook wiring was weakened: ${reason}.`,
3066
4031
  evidence: reason,
3067
- remediation: "Leave the PreToolUse and Stop entries that run tamperward, and the matcher that covers every editing tool, exactly as `tamperward init` wrote them. Editing the wiring is editing the gate."
4032
+ remediation: "Leave the PreToolUse and Stop entries that run tamperward, and the matcher that covers every editing tool, exactly as `tamperward init` wrote them. Editing the wiring is editing the gate: re-run `tamperward init` to restore it, or sign off."
3068
4033
  });
3069
4034
  if (c.before != null && before === null) return null;
3070
4035
  if (after === null) {
3071
4036
  if (c.before == null) return [];
3072
4037
  return [finding("the settings file no longer parses as JSON \u2014 the runtime ignores it, hooks and all")];
3073
4038
  }
3074
- return settingsWeakening(before, after, siblingOf(c, changes, ctx)).map(finding);
4039
+ const reasons = settingsWeakening(before, after, /settings\.local\.json$/.test(c.path));
4040
+ const had = new Set(c.before != null ? duplicateJsonKeys(c.before) : []);
4041
+ for (const k of duplicateJsonKeys(c.after)) {
4042
+ if (!had.has(k)) reasons.push(`the key "${k}" appears twice \u2014 which reading the runtime keeps is not something the gate can vouch for`);
4043
+ }
4044
+ return reasons.map(finding);
3075
4045
  }
3076
4046
  function policyFindings(c, policy) {
3077
4047
  if (c.after == null) return null;
@@ -3093,12 +4063,54 @@ function renameKept(c) {
3093
4063
  if (id === null || hookIdentity(c.path) !== id) return false;
3094
4064
  return c.before != null && c.after != null ? c.before === c.after : c.hunks.length === 0;
3095
4065
  }
3096
- function configFindings(c, policy) {
3097
- if (c.before == null || c.after == null) return null;
3098
- const before = parseDoc(c.before);
3099
- const after = parseDoc(c.after);
3100
- if (before === null) return null;
3101
- const reasons = after === null ? ["the file no longer parses as YAML \u2014 the tool that reads it runs nothing"] : isLefthook(c.path) ? lefthookWeakening(before, after) : preCommitWeakening(before, after);
4066
+ function lefthookBase(c, changes, ctx) {
4067
+ const dir = c.path.includes("/") ? c.path.slice(0, c.path.lastIndexOf("/") + 1) : "";
4068
+ const names = ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml"].map((n) => dir + n);
4069
+ const inSet = changes.find((o) => o.kind === "file" && names.includes(o.path));
4070
+ if (inSet) return inSet.after != null ? parseDoc(inSet.after) : null;
4071
+ if (!ctx?.cwd) return null;
4072
+ for (const n of names) {
4073
+ try {
4074
+ const p = join3(ctx.cwd, n);
4075
+ if (existsSync(p)) return parseDoc(readFileSync3(p, "utf8"));
4076
+ } catch {
4077
+ }
4078
+ }
4079
+ return null;
4080
+ }
4081
+ function syntheticBase(over) {
4082
+ const base = {};
4083
+ for (const [section, v] of Object.entries(over)) {
4084
+ if (!isObj(v)) continue;
4085
+ for (const group of ["commands", "scripts"]) {
4086
+ const g = v[group];
4087
+ if (!isObj(g)) continue;
4088
+ for (const name of Object.keys(g)) {
4089
+ if (!/tamperward/.test(name)) continue;
4090
+ const sec = base[section] ??= {};
4091
+ const grp = sec[group] ??= {};
4092
+ grp[name] = { run: "npx tamperward check --staged" };
4093
+ }
4094
+ }
4095
+ }
4096
+ return base;
4097
+ }
4098
+ function configFindings(c, changes, policy, ctx) {
4099
+ if (c.after == null) return null;
4100
+ let reasons;
4101
+ if (isLefthookLocal(c.path)) {
4102
+ const overBefore = c.before != null ? parseDoc(c.before) : {};
4103
+ const overAfter = parseDoc(c.after);
4104
+ if (overBefore === null) return null;
4105
+ const base = lefthookBase(c, changes, ctx) ?? syntheticBase(overAfter ?? {});
4106
+ reasons = overAfter === null ? ["the file no longer parses as YAML \u2014 the tool that reads it runs nothing"] : lefthookWeakening(mergeDocs(base, overBefore), mergeDocs(base, overAfter));
4107
+ } else {
4108
+ if (c.before == null) return null;
4109
+ const before = parseDoc(c.before);
4110
+ const after = parseDoc(c.after);
4111
+ if (before === null) return null;
4112
+ reasons = after === null ? ["the file no longer parses as YAML \u2014 the tool that reads it runs nothing"] : isLefthook(c.path) ? lefthookWeakening(before, after) : preCommitWeakening(before, after);
4113
+ }
3102
4114
  return reasons.map(
3103
4115
  (reason) => makeFinding(RULE6, policy, {
3104
4116
  file: c.path,
@@ -3108,6 +4120,11 @@ function configFindings(c, policy) {
3108
4120
  })
3109
4121
  );
3110
4122
  }
4123
+ function repoGateFiles(ctx) {
4124
+ const files = trackedFiles(ctx);
4125
+ if (!files) return [];
4126
+ return files.filter((f) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(f) || /^\.husky\/[^/_][^/]*$/.test(f));
4127
+ }
3111
4128
  function updateIndexHiding(toks, policy) {
3112
4129
  if (!toks.some((t) => t === "git" || t.endsWith("/git")) || !toks.includes("update-index")) return null;
3113
4130
  const hide = toks.find((t) => /^--(?:skip-worktree|assume-unchanged)$/.test(t));
@@ -3129,6 +4146,7 @@ function repoRelative(path, ctx) {
3129
4146
  const rel = relative(ctx.cwd, path);
3130
4147
  return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
3131
4148
  }
4149
+ var isHuskyScript = (path) => /(?:^|\/)\.husky\/[^/]+$/.test(path);
3132
4150
  var hookTampering = {
3133
4151
  id: RULE6,
3134
4152
  surface: ["file", "command"],
@@ -3138,7 +4156,7 @@ var hookTampering = {
3138
4156
  for (const c of changes) {
3139
4157
  let semantic;
3140
4158
  if (c.kind === "file") {
3141
- const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false);
4159
+ const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false) || isClaudeSettings(c.path);
3142
4160
  if (!targetsHook) {
3143
4161
  if (isPackageJson(c.path) && c.before != null && c.after != null) {
3144
4162
  for (const reason of packageJsonWeakening(c.before, c.after)) {
@@ -3182,12 +4200,12 @@ var hookTampering = {
3182
4200
  remediation: "Renaming a hook out of place disables it. Restore the original path."
3183
4201
  })
3184
4202
  );
3185
- } else if ((c.path.endsWith(POLICY_FILE) || isClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c, changes, policy, ctx)) !== null) {
4203
+ } else if ((c.path.endsWith(POLICY_FILE) || isClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c, policy)) !== null) {
3186
4204
  out2.push(...semantic);
3187
4205
  } else if (c.op === "rename") {
3188
4206
  } else if (isCodeowners(c.path)) {
3189
4207
  if (c.before != null && c.after != null) {
3190
- for (const reason of codeownersWeakening(c.before, c.after)) {
4208
+ for (const reason of codeownersWeakening(c.before, c.after, [...repoGateFiles(ctx), c.path])) {
3191
4209
  out2.push(
3192
4210
  makeFinding(RULE6, policy, {
3193
4211
  file: c.path,
@@ -3198,9 +4216,40 @@ var hookTampering = {
3198
4216
  );
3199
4217
  }
3200
4218
  }
3201
- } else if ((isLefthook(c.path) || isPreCommitConfig(c.path)) && (semantic = configFindings(c, policy)) !== null) {
4219
+ } else if ((isLefthook(c.path) || isPreCommitConfig(c.path)) && (semantic = configFindings(c, changes, policy, ctx)) !== null) {
3202
4220
  out2.push(...semantic);
3203
4221
  } else if (!c.path.endsWith(POLICY_FILE)) {
4222
+ const husky = isHuskyScript(c.path);
4223
+ const beforePin = c.before != null && c.after != null ? initScriptPin(c.before) : null;
4224
+ if (beforePin !== null) {
4225
+ const afterPin = initScriptPin(c.after);
4226
+ const kept = afterPin !== null && (beforePin === "" ? afterPin === "" || PLAIN_SEMVER.test(afterPin) : pinNotBelow(afterPin, beforePin));
4227
+ if (!kept) {
4228
+ out2.push(
4229
+ makeFinding(RULE6, policy, {
4230
+ file: c.path,
4231
+ message: "A protected hook script no longer matches the shape `tamperward init` writes.",
4232
+ evidence: "the gate script no longer matches the shape init wrote; re-run init or sign off",
4233
+ remediation: "The pre-commit script init writes is the marker line and the pinned staged check, nothing else. Re-run `tamperward init` to restore it, or sign off on the customised script."
4234
+ })
4235
+ );
4236
+ }
4237
+ continue;
4238
+ }
4239
+ if (!husky && c.after != null) {
4240
+ const problem = shebangProblem(c.after);
4241
+ if (problem && (c.before == null || problem !== shebangProblem(c.before))) {
4242
+ out2.push(
4243
+ makeFinding(RULE6, policy, {
4244
+ file: c.path,
4245
+ line: 1,
4246
+ message: `A protected hook's interpreter was changed: ${problem}.`,
4247
+ evidence: c.after.split("\n")[0],
4248
+ remediation: "Keep a shell in shebang position. git execs the hook directly, and an interpreter that never reads the script is a disabled hook."
4249
+ })
4250
+ );
4251
+ }
4252
+ }
3204
4253
  const added = addedLines(c);
3205
4254
  const earlyExit = added.find((l) => insertsPassingExit(l.content));
3206
4255
  if (earlyExit) {
@@ -3228,7 +4277,7 @@ var hookTampering = {
3228
4277
  }
3229
4278
  const beforeLines = c.before != null ? c.before.split("\n") : removedLines(c).map((l) => l.content);
3230
4279
  const afterLines = c.after != null ? c.after.split("\n") : added.map((l) => l.content);
3231
- const lost = scriptWeakening(beforeLines, afterLines);
4280
+ const lost = scriptWeakening(beforeLines, afterLines, husky ? { errexit: true } : {});
3232
4281
  if (lost.length) {
3233
4282
  out2.push(
3234
4283
  makeFinding(RULE6, policy, {
@@ -3272,6 +4321,7 @@ var hookTampering = {
3272
4321
  }
3273
4322
  }
3274
4323
  } else {
4324
+ const fed = [];
3275
4325
  for (const seg of segments(c.raw)) {
3276
4326
  const toks = tokens(seg).map(unquote);
3277
4327
  const hiding = updateIndexHiding(toks, policy);
@@ -3284,11 +4334,12 @@ var hookTampering = {
3284
4334
  remediation: "Leave protected files visible to git. Hiding one from the diff hides it from the gate, which is the tamper \u2014 use `--no-skip-worktree` / `--no-assume-unchanged` to undo it."
3285
4335
  })
3286
4336
  );
4337
+ fed.push(...toks);
3287
4338
  continue;
3288
4339
  }
3289
- const why = shellWritesHook(seg, toks, policy);
4340
+ const why = shellWritesHook(seg, toks, policy, ctx) ?? xargsWritesHook(toks, fed, policy, ctx);
3290
4341
  if (why) {
3291
- const target = shellHookTarget(seg, toks, policy);
4342
+ const target = shellHookTarget(seg, toks, policy, ctx) ?? shellHookTarget("", fed, policy, ctx);
3292
4343
  out2.push(
3293
4344
  makeFinding(RULE6, policy, {
3294
4345
  ...target ? { file: repoRelative(target, ctx) } : {},
@@ -3298,6 +4349,7 @@ var hookTampering = {
3298
4349
  })
3299
4350
  );
3300
4351
  }
4352
+ fed.push(...toks);
3301
4353
  }
3302
4354
  }
3303
4355
  }
@@ -3396,13 +4448,13 @@ function collect(root, runner) {
3396
4448
  sel.opaque = true;
3397
4449
  return;
3398
4450
  }
3399
- const list = [];
4451
+ const list2 = [];
3400
4452
  for (const el of e.elements) {
3401
- if (ts3.isObjectLiteralExpression(el)) list.push(collect(el, runner));
4453
+ if (ts3.isObjectLiteralExpression(el)) list2.push(collect(el, runner));
3402
4454
  else sel.opaque = true;
3403
4455
  }
3404
- sel.projects = list;
3405
- if (list.some((p) => p.opaque)) sel.opaque = true;
4456
+ sel.projects = list2;
4457
+ if (list2.some((p) => p.opaque)) sel.opaque = true;
3406
4458
  };
3407
4459
  const visit = (node) => {
3408
4460
  if (ts3.isPropertyAssignment(node)) {
@@ -3461,14 +4513,14 @@ function parseSelection(src, runner, path = "") {
3461
4513
  if (runner === "vitest" && isWorkspaceFile(path)) {
3462
4514
  const arr = workspaceArray(sf);
3463
4515
  if (arr) {
3464
- const list = [];
4516
+ const list2 = [];
3465
4517
  for (const el of arr.elements) {
3466
- if (ts3.isObjectLiteralExpression(el)) list.push(collect(el, runner));
4518
+ if (ts3.isObjectLiteralExpression(el)) list2.push(collect(el, runner));
3467
4519
  else sel.opaque = true;
3468
4520
  }
3469
- sel.projects = list;
4521
+ sel.projects = list2;
3470
4522
  sel.present = true;
3471
- if (list.some((p) => p.opaque)) sel.opaque = true;
4523
+ if (list2.some((p) => p.opaque)) sel.opaque = true;
3472
4524
  } else if (src.trim()) sel.opaque = true;
3473
4525
  } else sel = collect(sf, runner);
3474
4526
  } catch {
@@ -4101,7 +5153,7 @@ var GUARDED_RULE = "hook-tampering";
4101
5153
  var BASELINE_HOOKS = defaultPolicy().protected.hooks;
4102
5154
  function isGuardedFinding(f) {
4103
5155
  if (f.rule !== GUARDED_RULE || !f.file) return false;
4104
- return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS);
5156
+ return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS) || isClaudeSettings(f.file);
4105
5157
  }
4106
5158
  function key(f) {
4107
5159
  return `${f.rule}|${f.file ?? ""}|${f.line ?? ""}|${f.evidence}`;
@@ -4160,8 +5212,8 @@ function hasBlocking(findings) {
4160
5212
  }
4161
5213
 
4162
5214
  // src/policy-load.ts
4163
- import { readFileSync as readFileSync3, existsSync as existsSync2 } from "node:fs";
4164
- import { isAbsolute as isAbsolute2, join as join3, posix } from "node:path";
5215
+ import { readFileSync as readFileSync4, existsSync as existsSync2 } from "node:fs";
5216
+ import { isAbsolute as isAbsolute2, join as join4, posix } from "node:path";
4165
5217
  import { parse as parse2 } from "yaml";
4166
5218
  var PolicyError = class extends Error {
4167
5219
  };
@@ -4242,8 +5294,8 @@ function parsePolicy(raw, where = POLICY_FILE) {
4242
5294
  const version = normalizeVersion(r.version, where);
4243
5295
  validate(r, where);
4244
5296
  const base = defaultPolicy(version);
4245
- const globs4 = (list) => list?.map(normalizeGlob);
4246
- const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat, globs4(list) ?? []])) : void 0;
5297
+ const globs4 = (list2) => list2?.map(normalizeGlob);
5298
+ const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list2]) => [cat, globs4(list2) ?? []])) : void 0;
4247
5299
  const userRules = r.rules ? Object.fromEntries(
4248
5300
  Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs4(cfg.exclude) } : cfg])
4249
5301
  ) : void 0;
@@ -4285,9 +5337,9 @@ function parseOrThrow(src, where) {
4285
5337
  return parsePolicy(raw, where);
4286
5338
  }
4287
5339
  function loadPolicy(cwd = process.cwd()) {
4288
- const path = join3(cwd, POLICY_FILE);
5340
+ const path = join4(cwd, POLICY_FILE);
4289
5341
  if (!existsSync2(path)) return defaultPolicy();
4290
- return parseOrThrow(readFileSync3(path, "utf8"), POLICY_FILE);
5342
+ return parseOrThrow(readFileSync4(path, "utf8"), POLICY_FILE);
4291
5343
  }
4292
5344
  function loadPolicyAt(rev, cwd) {
4293
5345
  const src = fileAt(rev, POLICY_FILE, { cwd });
@@ -4297,8 +5349,8 @@ function loadPolicyAt(rev, cwd) {
4297
5349
 
4298
5350
  // src/signoff.ts
4299
5351
  import { createHash } from "node:crypto";
4300
- import { appendFileSync, mkdirSync, readFileSync as readFileSync4, existsSync as existsSync3 } from "node:fs";
4301
- import { dirname, join as join4 } from "node:path";
5352
+ import { appendFileSync, mkdirSync, readFileSync as readFileSync5, existsSync as existsSync3 } from "node:fs";
5353
+ import { dirname as dirname2, join as join5 } from "node:path";
4302
5354
  var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
4303
5355
  function fingerprint(rule, file, evidence) {
4304
5356
  return createHash("sha256").update(`${rule}\0${file ?? ""}\0${evidence}`).digest("hex").slice(0, 16);
@@ -4309,13 +5361,13 @@ function ledgerPath(cwd, policy) {
4309
5361
  if (!ledgerInsideRepo(rel)) {
4310
5362
  throw new PolicyError(`signoff.ledger must be a relative path inside the repository, got ${JSON.stringify(rel)}`);
4311
5363
  }
4312
- return join4(cwd, rel);
5364
+ return join5(cwd, rel);
4313
5365
  }
4314
5366
  function readLedger(cwd, policy) {
4315
5367
  const p = ledgerPath(cwd, policy);
4316
5368
  if (!existsSync3(p)) return [];
4317
5369
  const out2 = [];
4318
- for (const line of readFileSync4(p, "utf8").split("\n")) {
5370
+ for (const line of readFileSync5(p, "utf8").split("\n")) {
4319
5371
  if (!line.trim()) continue;
4320
5372
  try {
4321
5373
  const e = JSON.parse(line);
@@ -4327,7 +5379,7 @@ function readLedger(cwd, policy) {
4327
5379
  }
4328
5380
  function appendEntry(cwd, policy, e) {
4329
5381
  const p = ledgerPath(cwd, policy);
4330
- mkdirSync(dirname(p), { recursive: true });
5382
+ mkdirSync(dirname2(p), { recursive: true });
4331
5383
  appendFileSync(p, JSON.stringify(e) + "\n");
4332
5384
  }
4333
5385
  function makeEntry(f, reason, now, ttlMs = DEFAULT_TTL_MS) {
@@ -4684,21 +5736,21 @@ function runCheck(opts) {
4684
5736
  }
4685
5737
 
4686
5738
  // src/cli/hook.ts
4687
- import { readFileSync as readFileSync10, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7, statSync as statSync4 } from "node:fs";
5739
+ import { readFileSync as readFileSync11, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7, statSync as statSync4 } from "node:fs";
4688
5740
  import { createHash as createHash4 } from "node:crypto";
4689
- import { join as join9 } from "node:path";
5741
+ import { join as join10 } from "node:path";
4690
5742
 
4691
5743
  // src/adapters/claude/changes.ts
4692
5744
  import { execFileSync as execFileSync3 } from "node:child_process";
4693
- import { readFileSync as readFileSync5, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
5745
+ import { readFileSync as readFileSync6, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
4694
5746
  import { tmpdir } from "node:os";
4695
- import { isAbsolute as isAbsolute3, join as join5, relative as relative2, resolve } from "node:path";
5747
+ import { isAbsolute as isAbsolute3, join as join6, relative as relative2, resolve } from "node:path";
4696
5748
  function asStr(v) {
4697
5749
  return typeof v === "string" ? v : "";
4698
5750
  }
4699
5751
  function readDisk(path) {
4700
5752
  try {
4701
- return existsSync4(path) ? readFileSync5(path, "utf8") : null;
5753
+ return existsSync4(path) ? readFileSync6(path, "utf8") : null;
4702
5754
  } catch {
4703
5755
  return null;
4704
5756
  }
@@ -4721,11 +5773,11 @@ function synthFileChange(displayPath, before, after) {
4721
5773
  if (before === null) op = "add";
4722
5774
  else if (after === null) op = "delete";
4723
5775
  else op = "modify";
4724
- const dir = mkdtempSync(join5(tmpdir(), "hf-"));
5776
+ const dir = mkdtempSync(join6(tmpdir(), "hf-"));
4725
5777
  let raw = "";
4726
5778
  try {
4727
- const a = join5(dir, "a");
4728
- const b = join5(dir, "b");
5779
+ const a = join6(dir, "a");
5780
+ const b = join6(dir, "b");
4729
5781
  writeFileSync(a, before ?? "");
4730
5782
  writeFileSync(b, after ?? "");
4731
5783
  try {
@@ -4810,14 +5862,14 @@ function formatDenial(blocks) {
4810
5862
  }
4811
5863
 
4812
5864
  // src/session.ts
4813
- import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync2 } from "node:fs";
4814
- import { dirname as dirname2, join as join6 } from "node:path";
5865
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "node:fs";
5866
+ import { dirname as dirname3, join as join7 } from "node:path";
4815
5867
  var UNSAFE = /[^A-Za-z0-9_-]/g;
4816
5868
  var SHA = /^[0-9a-f]{7,40}$/;
4817
5869
  function baselinePath(cwd, sessionId) {
4818
5870
  const gd = gitDir(cwd);
4819
5871
  if (!gd) return null;
4820
- return join6(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
5872
+ return join7(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
4821
5873
  }
4822
5874
  function turnBaseline(cwd, sessionId) {
4823
5875
  if (!sessionId) return null;
@@ -4825,12 +5877,12 @@ function turnBaseline(cwd, sessionId) {
4825
5877
  const p = baselinePath(cwd, sessionId);
4826
5878
  if (!p) return null;
4827
5879
  if (existsSync5(p)) {
4828
- const v = readFileSync6(p, "utf8").trim();
5880
+ const v = readFileSync7(p, "utf8").trim();
4829
5881
  if (SHA.test(v)) return v;
4830
5882
  }
4831
5883
  const head = headSha(cwd);
4832
5884
  if (!head) return null;
4833
- mkdirSync2(dirname2(p), { recursive: true });
5885
+ mkdirSync2(dirname3(p), { recursive: true });
4834
5886
  writeFileSync2(p, head);
4835
5887
  return head;
4836
5888
  } catch {
@@ -4843,7 +5895,7 @@ function advanceTurnBaseline(cwd, sessionId) {
4843
5895
  const p = baselinePath(cwd, sessionId);
4844
5896
  const head = headSha(cwd);
4845
5897
  if (!p || !head) return;
4846
- mkdirSync2(dirname2(p), { recursive: true });
5898
+ mkdirSync2(dirname3(p), { recursive: true });
4847
5899
  writeFileSync2(p, head);
4848
5900
  } catch {
4849
5901
  }
@@ -4851,8 +5903,8 @@ function advanceTurnBaseline(cwd, sessionId) {
4851
5903
 
4852
5904
  // src/effect.ts
4853
5905
  import { createHash as createHash2 } from "node:crypto";
4854
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync7, renameSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync3 } from "node:fs";
4855
- import { isAbsolute as isAbsolute4, join as join7 } from "node:path";
5906
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync8, renameSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync3 } from "node:fs";
5907
+ import { isAbsolute as isAbsolute4, join as join8 } from "node:path";
4856
5908
  import { execFileSync as execFileSync4 } from "node:child_process";
4857
5909
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
4858
5910
  var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
@@ -4864,7 +5916,7 @@ function gitDir2(cwd) {
4864
5916
  stdio: ["ignore", "pipe", "pipe"]
4865
5917
  }).trim();
4866
5918
  if (!d) return null;
4867
- return isAbsolute4(d) ? d : join7(cwd, d);
5919
+ return isAbsolute4(d) ? d : join8(cwd, d);
4868
5920
  } catch {
4869
5921
  return null;
4870
5922
  }
@@ -4873,8 +5925,8 @@ function statePath(cwd, sessionId, kind) {
4873
5925
  if (!sessionId) return null;
4874
5926
  const gd = gitDir2(cwd);
4875
5927
  if (!gd) return null;
4876
- const dir = join7(gd, "tamperward");
4877
- return join7(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
5928
+ const dir = join8(gd, "tamperward");
5929
+ return join8(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
4878
5930
  }
4879
5931
  function ptreePath(cwd, sessionId) {
4880
5932
  return statePath(cwd, sessionId, "ptree");
@@ -4888,8 +5940,8 @@ function contentHash(content) {
4888
5940
  }
4889
5941
  function snapshotProtected(cwd, policy, prev) {
4890
5942
  const out2 = {};
4891
- const walk = (rel) => {
4892
- const abs2 = rel ? join7(cwd, rel) : cwd;
5943
+ const walk2 = (rel) => {
5944
+ const abs2 = rel ? join8(cwd, rel) : cwd;
4893
5945
  let names;
4894
5946
  try {
4895
5947
  names = readdirSync(abs2);
@@ -4901,15 +5953,15 @@ function snapshotProtected(cwd, policy, prev) {
4901
5953
  const r = rel ? `${rel}/${name}` : name;
4902
5954
  let st;
4903
5955
  try {
4904
- st = statSync(join7(cwd, r));
5956
+ st = statSync(join8(cwd, r));
4905
5957
  } catch {
4906
5958
  continue;
4907
5959
  }
4908
- if (st.isDirectory()) walk(r);
5960
+ if (st.isDirectory()) walk2(r);
4909
5961
  else if (st.isFile() && isProtected(r, policy)) {
4910
5962
  let hash = "";
4911
5963
  try {
4912
- hash = sha(readFileSync7(join7(cwd, r)));
5964
+ hash = sha(readFileSync8(join8(cwd, r)));
4913
5965
  } catch {
4914
5966
  hash = "unreadable";
4915
5967
  }
@@ -4917,7 +5969,7 @@ function snapshotProtected(cwd, policy, prev) {
4917
5969
  }
4918
5970
  }
4919
5971
  };
4920
- walk("");
5972
+ walk2("");
4921
5973
  return out2;
4922
5974
  }
4923
5975
  function driftBetween(expected, current) {
@@ -4937,7 +5989,7 @@ function driftBetween(expected, current) {
4937
5989
  function loadTree(p) {
4938
5990
  if (!p || !existsSync6(p)) return null;
4939
5991
  try {
4940
- return JSON.parse(readFileSync7(p, "utf8"));
5992
+ return JSON.parse(readFileSync8(p, "utf8"));
4941
5993
  } catch {
4942
5994
  return null;
4943
5995
  }
@@ -4945,7 +5997,7 @@ function loadTree(p) {
4945
5997
  function saveTree(p, tree) {
4946
5998
  if (!p) return;
4947
5999
  try {
4948
- mkdirSync3(join7(p, ".."), { recursive: true });
6000
+ mkdirSync3(join8(p, ".."), { recursive: true });
4949
6001
  const tmp = `${p}.${process.pid}.tmp`;
4950
6002
  writeFileSync3(tmp, JSON.stringify(tree));
4951
6003
  renameSync(tmp, p);
@@ -4976,15 +6028,15 @@ function dropTurnTree(cwd, sessionId) {
4976
6028
  }
4977
6029
 
4978
6030
  // src/cli/watch.ts
4979
- import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync8, statSync as statSync2, watch } from "node:fs";
4980
- import { join as join8 } from "node:path";
6031
+ import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync9, statSync as statSync2, watch } from "node:fs";
6032
+ import { join as join9 } from "node:path";
4981
6033
  import { createHash as createHash3 } from "node:crypto";
4982
6034
  var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
4983
6035
  function snap(abs2) {
4984
6036
  try {
4985
6037
  const st = statSync2(abs2);
4986
6038
  if (!st.isFile()) return { mode: st.mode, size: null, hash: null };
4987
- const hash = createHash3("sha256").update(readFileSync8(abs2)).digest("hex").slice(0, 16);
6039
+ const hash = createHash3("sha256").update(readFileSync9(abs2)).digest("hex").slice(0, 16);
4988
6040
  return { mode: st.mode, size: st.size, hash };
4989
6041
  } catch {
4990
6042
  return { mode: null, size: null, hash: null };
@@ -4992,7 +6044,7 @@ function snap(abs2) {
4992
6044
  }
4993
6045
  function defaultEventLog(cwd) {
4994
6046
  if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
4995
- return join8(gitDir(cwd) ?? join8(cwd, ".git"), "tamperward", "fsevents.jsonl");
6047
+ return join9(gitDir(cwd) ?? join9(cwd, ".git"), "tamperward", "fsevents.jsonl");
4996
6048
  }
4997
6049
  function watchTree(dir, cb) {
4998
6050
  if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
@@ -5009,12 +6061,12 @@ function watchTree(dir, cb) {
5009
6061
  if (watchers.has(rel) || SKIP2.test(rel + "/")) return;
5010
6062
  let w;
5011
6063
  try {
5012
- w = watch(rel ? join8(dir, rel) : dir, (kind, fname) => {
6064
+ w = watch(rel ? join9(dir, rel) : dir, (kind, fname) => {
5013
6065
  if (!fname) return;
5014
6066
  const child = rel ? `${rel}/${String(fname)}` : String(fname);
5015
6067
  cb(kind, child);
5016
6068
  try {
5017
- if (statSync2(join8(dir, child)).isDirectory()) addDir(child);
6069
+ if (statSync2(join9(dir, child)).isDirectory()) addDir(child);
5018
6070
  } catch {
5019
6071
  }
5020
6072
  });
@@ -5023,11 +6075,11 @@ function watchTree(dir, cb) {
5023
6075
  }
5024
6076
  watchers.set(rel, w);
5025
6077
  };
5026
- const walk = (rel) => {
6078
+ const walk2 = (rel) => {
5027
6079
  addDir(rel);
5028
6080
  let names = [];
5029
6081
  try {
5030
- names = readdirSync2(rel ? join8(dir, rel) : dir);
6082
+ names = readdirSync2(rel ? join9(dir, rel) : dir);
5031
6083
  } catch {
5032
6084
  return;
5033
6085
  }
@@ -5035,25 +6087,25 @@ function watchTree(dir, cb) {
5035
6087
  const child = rel ? `${rel}/${name}` : name;
5036
6088
  if (SKIP2.test(child + "/")) continue;
5037
6089
  try {
5038
- if (statSync2(join8(dir, child)).isDirectory()) walk(child);
6090
+ if (statSync2(join9(dir, child)).isDirectory()) walk2(child);
5039
6091
  } catch {
5040
6092
  }
5041
6093
  }
5042
6094
  };
5043
- walk("");
6095
+ walk2("");
5044
6096
  return { close: () => {
5045
6097
  for (const w of watchers.values()) w.close();
5046
6098
  } };
5047
6099
  }
5048
6100
  function startWatcher(dir, log, policy) {
5049
6101
  try {
5050
- mkdirSync4(join8(log, ".."), { recursive: true });
6102
+ mkdirSync4(join9(log, ".."), { recursive: true });
5051
6103
  } catch {
5052
6104
  }
5053
6105
  const last = /* @__PURE__ */ new Map();
5054
6106
  return watchTree(dir, (kind, rel) => {
5055
6107
  if (SKIP2.test(rel) || !isProtected(rel, policy)) return;
5056
- const s = snap(join8(dir, rel));
6108
+ const s = snap(join9(dir, rel));
5057
6109
  const key2 = `${s.hash}:${s.mode}`;
5058
6110
  if (last.get(rel) === key2) return;
5059
6111
  last.set(rel, key2);
@@ -5080,7 +6132,7 @@ function runWatch(args) {
5080
6132
  }
5081
6133
 
5082
6134
  // src/detectors/fs-events.ts
5083
- import { readFileSync as readFileSync9, statSync as statSync3 } from "node:fs";
6135
+ import { readFileSync as readFileSync10, statSync as statSync3 } from "node:fs";
5084
6136
  var RULE11 = "transient-protected-mutation";
5085
6137
  function readEvents(log, offset) {
5086
6138
  let size = 0;
@@ -5090,7 +6142,7 @@ function readEvents(log, offset) {
5090
6142
  return { events: [], newOffset: offset };
5091
6143
  }
5092
6144
  if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
5093
- const raw = readFileSync9(log, "utf8").slice(offset);
6145
+ const raw = readFileSync10(log, "utf8").slice(offset);
5094
6146
  const events = [];
5095
6147
  for (const line of raw.split("\n")) {
5096
6148
  if (!line.trim()) continue;
@@ -5143,7 +6195,7 @@ var HookInputError = class extends Error {
5143
6195
  };
5144
6196
  function readStdin() {
5145
6197
  try {
5146
- return readFileSync10(0, "utf8");
6198
+ return readFileSync11(0, "utf8");
5147
6199
  } catch (e) {
5148
6200
  throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
5149
6201
  }
@@ -5202,7 +6254,7 @@ function fileChanges(changes) {
5202
6254
  }
5203
6255
  function hashOnDisk(cwd, rel) {
5204
6256
  try {
5205
- return contentHash(readFileSync10(join9(cwd, rel)));
6257
+ return contentHash(readFileSync11(join10(cwd, rel)));
5206
6258
  } catch {
5207
6259
  return null;
5208
6260
  }
@@ -5391,7 +6443,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
5391
6443
  if (!existsSync7(log)) return none;
5392
6444
  let offset = 0;
5393
6445
  if (cp && existsSync7(cp)) {
5394
- const n = Number(readFileSync10(cp, "utf8"));
6446
+ const n = Number(readFileSync11(cp, "utf8"));
5395
6447
  offset = Number.isFinite(n) ? n : 0;
5396
6448
  }
5397
6449
  const { events, newOffset } = readEvents(log, offset);
@@ -5405,7 +6457,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
5405
6457
  const finalHash = (path) => {
5406
6458
  try {
5407
6459
  if (!statSync4(`${cwd}/${path}`).isFile()) return null;
5408
- return createHash4("sha256").update(readFileSync10(`${cwd}/${path}`)).digest("hex").slice(0, 16);
6460
+ return createHash4("sha256").update(readFileSync11(`${cwd}/${path}`)).digest("hex").slice(0, 16);
5409
6461
  } catch {
5410
6462
  return null;
5411
6463
  }
@@ -5548,9 +6600,8 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
5548
6600
  // src/cli/init.ts
5549
6601
  import { execFileSync as execFileSync5 } from "node:child_process";
5550
6602
  import { createHash as createHash5 } from "node:crypto";
5551
- import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
5552
- import { basename, dirname as dirname3, isAbsolute as isAbsolute5, join as join10, relative as relative3, resolve as resolve2 } from "node:path";
5553
- import { fileURLToPath } from "node:url";
6603
+ import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync12, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
6604
+ import { basename, dirname as dirname4, isAbsolute as isAbsolute5, join as join11, relative as relative3, resolve as resolve2 } from "node:path";
5554
6605
  var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
5555
6606
  var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
5556
6607
  function gitConfig(cwd, key2) {
@@ -5560,12 +6611,9 @@ function gitConfig(cwd, key2) {
5560
6611
  return null;
5561
6612
  }
5562
6613
  }
5563
- var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
5564
- var MARKER = "# tamperward: block agent shortcuts before they land";
5565
6614
  var HOOK_RE = /\btamperward(?:@\S+)?\s+hook\s+claude\b/;
5566
6615
  var SWEEP_RE = /\btamperward(?:@\S+)?\s+sweep\s+claude\b/;
5567
6616
  var PRECOMMIT_RE = /\btamperward(?:@\S+)?\s+check\s+--staged\b/;
5568
- var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
5569
6617
  var CODEOWNERS_MARK = "# tamperward: the gate cannot guard the file that defines it";
5570
6618
  var CODEOWNERS_PATHS = ["/.github/workflows/", "/.tamperward.yml", "/.github/CODEOWNERS"];
5571
6619
  function coveredBy(existing, critical) {
@@ -5609,24 +6657,6 @@ version: 1
5609
6657
  # snapshot-rewrite: { severity: block }
5610
6658
  # ignore: [] # visible blind spots \u2014 the count is always reported
5611
6659
  `;
5612
- function shippedVersion() {
5613
- try {
5614
- const here = dirname3(fileURLToPath(import.meta.url));
5615
- for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
5616
- try {
5617
- const pkg = JSON.parse(readFileSync11(join10(here, rel), "utf8"));
5618
- if (pkg.name === "tamperward" && pkg.version) return pkg.version;
5619
- } catch {
5620
- }
5621
- }
5622
- } catch {
5623
- }
5624
- return "latest";
5625
- }
5626
- var TW_VERSION = shippedVersion();
5627
- var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
5628
- var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
5629
- var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
5630
6660
  function pinOf(command) {
5631
6661
  const m = command.match(OURS);
5632
6662
  return m ? m[1] ?? "" : null;
@@ -5721,13 +6751,13 @@ function readWorkflowMark(src) {
5721
6751
  if (!m) return null;
5722
6752
  return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
5723
6753
  }
5724
- function toolSet2(matcher2) {
6754
+ function toolSet(matcher2) {
5725
6755
  const m = String(matcher2 ?? "").trim();
5726
6756
  if (m === "" || m === "*") return null;
5727
6757
  return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
5728
6758
  }
5729
6759
  function missingTools(matcher2) {
5730
- const have = toolSet2(matcher2);
6760
+ const have = toolSet(matcher2);
5731
6761
  if (have === null) return [];
5732
6762
  return PRE_MATCHER.split("|").filter((t) => !have.has(t));
5733
6763
  }
@@ -5747,7 +6777,7 @@ function hooksShapeError(hooks) {
5747
6777
  return null;
5748
6778
  }
5749
6779
  function planPolicy(cwd) {
5750
- const path = join10(cwd, POLICY_FILE);
6780
+ const path = join11(cwd, POLICY_FILE);
5751
6781
  if (existsSync8(path)) {
5752
6782
  try {
5753
6783
  loadPolicy(cwd);
@@ -5766,12 +6796,12 @@ function planPolicy(cwd) {
5766
6796
  }
5767
6797
  function planClaudeHooks(cwd) {
5768
6798
  const rel = ".claude/settings.json";
5769
- const path = join10(cwd, rel);
6799
+ const path = join11(cwd, rel);
5770
6800
  let settings = {};
5771
6801
  if (existsSync8(path)) {
5772
6802
  let parsed;
5773
6803
  try {
5774
- parsed = JSON.parse(readFileSync11(path, "utf8"));
6804
+ parsed = JSON.parse(readFileSync12(path, "utf8"));
5775
6805
  } catch {
5776
6806
  return {
5777
6807
  item: "agent",
@@ -5824,12 +6854,12 @@ function planClaudeHooks(cwd) {
5824
6854
  });
5825
6855
  }
5826
6856
  for (const m of stale) {
5827
- m.matcher = [...toolSet2(m.matcher) ?? /* @__PURE__ */ new Set(), ...missingTools(m.matcher)].join("|");
6857
+ m.matcher = [...toolSet(m.matcher) ?? /* @__PURE__ */ new Set(), ...missingTools(m.matcher)].join("|");
5828
6858
  }
5829
6859
  if (needStop) {
5830
6860
  (hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
5831
6861
  }
5832
- mkdirSync5(dirname3(path), { recursive: true });
6862
+ mkdirSync5(dirname4(path), { recursive: true });
5833
6863
  writeFileSync5(path, JSON.stringify(settings, null, 2) + "\n");
5834
6864
  }
5835
6865
  };
@@ -5864,7 +6894,7 @@ function hooksDir(cwd) {
5864
6894
  if (out2) return resolve2(cwd, out2);
5865
6895
  } catch {
5866
6896
  }
5867
- return existsSync8(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
6897
+ return existsSync8(join11(cwd, ".git")) ? join11(cwd, ".git", "hooks") : null;
5868
6898
  }
5869
6899
  function display(cwd, path) {
5870
6900
  const rel = relative3(cwd, path);
@@ -5891,30 +6921,30 @@ function planPreCommit(cwd) {
5891
6921
  const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
5892
6922
  let path;
5893
6923
  let note;
5894
- if (existsSync8(join10(cwd, ".husky"))) {
5895
- path = join10(cwd, ".husky", "pre-commit");
6924
+ if (existsSync8(join11(cwd, ".husky"))) {
6925
+ path = join11(cwd, ".husky", "pre-commit");
5896
6926
  note = "husky";
5897
6927
  } else {
5898
- const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join10(cwd, c))));
6928
+ const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join11(cwd, c))));
5899
6929
  if (managed) {
5900
6930
  return skip(`${managed.name} manages this repository's hooks and regenerates them on install, so a line in the hooks directory would not last \u2014 ${managed.hint}`);
5901
6931
  }
5902
6932
  const dir = hooksDir(cwd);
5903
6933
  if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
5904
- if (basename(dir) === "_" && basename(dirname3(dir)) === ".husky") {
5905
- path = join10(dirname3(dir), "pre-commit");
6934
+ if (basename(dir) === "_" && basename(dirname4(dir)) === ".husky") {
6935
+ path = join11(dirname4(dir), "pre-commit");
5906
6936
  note = "husky";
5907
6937
  } else {
5908
- path = join10(dir, "pre-commit");
6938
+ path = join11(dir, "pre-commit");
5909
6939
  const rel2 = display(cwd, dir);
5910
- note = rel2 === join10(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
6940
+ note = rel2 === join11(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
5911
6941
  }
5912
6942
  }
5913
6943
  const rel = display(cwd, path);
5914
6944
  if (existsSync8(path) && !statSync5(path).isFile()) {
5915
6945
  return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
5916
6946
  }
5917
- const existing = existsSync8(path) ? readFileSync11(path, "utf8") : null;
6947
+ const existing = existsSync8(path) ? readFileSync12(path, "utf8") : null;
5918
6948
  if (existing === null) {
5919
6949
  return {
5920
6950
  item: "pre-commit",
@@ -5922,7 +6952,7 @@ function planPreCommit(cwd) {
5922
6952
  status: "create",
5923
6953
  detail: `create via ${note}`,
5924
6954
  apply: () => {
5925
- mkdirSync5(dirname3(path), { recursive: true });
6955
+ mkdirSync5(dirname4(path), { recursive: true });
5926
6956
  writeFileSync5(path, `#!/bin/sh
5927
6957
  ${block.join("\n")}
5928
6958
  `);
@@ -6003,10 +7033,10 @@ ${block.join("\n")}
6003
7033
  function planCodeowners(cwd) {
6004
7034
  const rel = ".github/CODEOWNERS";
6005
7035
  const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
6006
- (r) => existsSync8(join10(cwd, r))
7036
+ (r) => existsSync8(join11(cwd, r))
6007
7037
  );
6008
- const path = join10(cwd, existingRel ?? rel);
6009
- const existing = existingRel ? readFileSync11(path, "utf8") : null;
7038
+ const path = join11(cwd, existingRel ?? rel);
7039
+ const existing = existingRel ? readFileSync12(path, "utf8") : null;
6010
7040
  const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
6011
7041
  if (missing.length === 0) {
6012
7042
  return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
@@ -6032,16 +7062,16 @@ ${CODEOWNERS_MARK}
6032
7062
  warning: `${inferred.owner} was inferred from the origin remote and is not confirmed to be a user. If "${inferred.owner.slice(1)}" is an organisation, replace it with ${inferred.owner}/<team> in CODEOWNERS: GitHub rejects a bare organisation as an owner, and a rule with an invalid owner protects nothing.`
6033
7063
  } : {},
6034
7064
  apply: () => {
6035
- mkdirSync5(dirname3(path), { recursive: true });
7065
+ mkdirSync5(dirname4(path), { recursive: true });
6036
7066
  writeFileSync5(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
6037
7067
  }
6038
7068
  };
6039
7069
  }
6040
7070
  function planWorkflow(cwd, force) {
6041
7071
  const rel = ".github/workflows/tamperward.yml";
6042
- const path = join10(cwd, rel);
7072
+ const path = join11(cwd, rel);
6043
7073
  const write = () => {
6044
- mkdirSync5(dirname3(path), { recursive: true });
7074
+ mkdirSync5(dirname4(path), { recursive: true });
6045
7075
  writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
6046
7076
  };
6047
7077
  if (!existsSync8(path)) {
@@ -6055,7 +7085,7 @@ function planWorkflow(cwd, force) {
6055
7085
  }
6056
7086
  let src;
6057
7087
  try {
6058
- src = readFileSync11(path, "utf8");
7088
+ src = readFileSync12(path, "utf8");
6059
7089
  } catch {
6060
7090
  return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
6061
7091
  }
@@ -6154,15 +7184,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
6154
7184
  // src/cli/verify.ts
6155
7185
  import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
6156
7186
  import { createHash as createHash8 } from "node:crypto";
6157
- import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, readlinkSync as readlinkSync2, rmSync as rmSync3, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
7187
+ import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync15, readlinkSync as readlinkSync2, rmSync as rmSync3, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
6158
7188
  import { tmpdir as tmpdir2 } from "node:os";
6159
- import { dirname as dirname4, join as join13, resolve as resolve3 } from "node:path";
7189
+ import { dirname as dirname5, join as join14, resolve as resolve3 } from "node:path";
6160
7190
 
6161
7191
  // src/git/trusted.ts
6162
7192
  import { execFileSync as execFileSync6 } from "node:child_process";
6163
7193
  import { createHash as createHash6 } from "node:crypto";
6164
- import { existsSync as existsSync9, readFileSync as readFileSync12 } from "node:fs";
6165
- import { isAbsolute as isAbsolute6, join as join11 } from "node:path";
7194
+ import { existsSync as existsSync9, readFileSync as readFileSync13 } from "node:fs";
7195
+ import { isAbsolute as isAbsolute6, join as join12 } from "node:path";
6166
7196
  function trustedGitEnv() {
6167
7197
  return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
6168
7198
  }
@@ -6175,7 +7205,7 @@ function gitDirOf(cwd) {
6175
7205
  env: trustedGitEnv()
6176
7206
  }).trim();
6177
7207
  if (!d) return null;
6178
- return isAbsolute6(d) ? d : join11(cwd, d);
7208
+ return isAbsolute6(d) ? d : join12(cwd, d);
6179
7209
  } catch {
6180
7210
  return null;
6181
7211
  }
@@ -6200,9 +7230,9 @@ function objectRewriteState(cwd) {
6200
7230
  parts.push(`${rel}:<no-git-dir>`);
6201
7231
  continue;
6202
7232
  }
6203
- const p = join11(dir, rel);
7233
+ const p = join12(dir, rel);
6204
7234
  try {
6205
- parts.push(`${rel}:` + (existsSync9(p) ? readFileSync12(p, "utf8").trim() : ""));
7235
+ parts.push(`${rel}:` + (existsSync9(p) ? readFileSync13(p, "utf8").trim() : ""));
6206
7236
  } catch {
6207
7237
  parts.push(`${rel}:<unreadable>`);
6208
7238
  }
@@ -6213,8 +7243,8 @@ function objectRewriteState(cwd) {
6213
7243
  // src/fingerprint.ts
6214
7244
  import { execFileSync as execFileSync7 } from "node:child_process";
6215
7245
  import { createHash as createHash7 } from "node:crypto";
6216
- import { readdirSync as readdirSync3, readFileSync as readFileSync13, readlinkSync, statSync as statSync6 } from "node:fs";
6217
- import { join as join12 } from "node:path";
7246
+ import { readdirSync as readdirSync3, readFileSync as readFileSync14, readlinkSync, statSync as statSync6 } from "node:fs";
7247
+ import { join as join13 } from "node:path";
6218
7248
  function treeFingerprint(cwd) {
6219
7249
  const files = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
6220
7250
  cwd,
@@ -6227,7 +7257,7 @@ function treeFingerprint(cwd) {
6227
7257
  h.update(rel);
6228
7258
  h.update("\0");
6229
7259
  try {
6230
- h.update(readFileSync13(join12(cwd, rel)));
7260
+ h.update(readFileSync14(join13(cwd, rel)));
6231
7261
  } catch {
6232
7262
  h.update("<unreadable>");
6233
7263
  }
@@ -6236,9 +7266,9 @@ function treeFingerprint(cwd) {
6236
7266
  return h.digest("hex");
6237
7267
  }
6238
7268
  function depsFingerprint(cwd) {
6239
- const root = join12(cwd, "node_modules");
7269
+ const root = join13(cwd, "node_modules");
6240
7270
  const h = createHash7("sha256");
6241
- const walk = (dir) => {
7271
+ const walk2 = (dir) => {
6242
7272
  let entries;
6243
7273
  try {
6244
7274
  entries = readdirSync3(dir, { withFileTypes: true });
@@ -6246,12 +7276,12 @@ function depsFingerprint(cwd) {
6246
7276
  return;
6247
7277
  }
6248
7278
  for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
6249
- const p = join12(dir, e.name);
6250
- if (e.isDirectory()) walk(p);
7279
+ const p = join13(dir, e.name);
7280
+ if (e.isDirectory()) walk2(p);
6251
7281
  else if (e.isFile()) {
6252
7282
  h.update(p);
6253
7283
  try {
6254
- h.update(readFileSync13(p));
7284
+ h.update(readFileSync14(p));
6255
7285
  h.update(String(statSync6(p).mode));
6256
7286
  } catch {
6257
7287
  h.update("<unreadable>");
@@ -6271,7 +7301,7 @@ function depsFingerprint(cwd) {
6271
7301
  } catch {
6272
7302
  return "none";
6273
7303
  }
6274
- walk(root);
7304
+ walk2(root);
6275
7305
  return h.digest("hex");
6276
7306
  }
6277
7307
 
@@ -6366,7 +7396,7 @@ function baseIsAncestorOfHead(base, cwd) {
6366
7396
  function materialize(cwd, dest) {
6367
7397
  const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
6368
7398
  for (const rel of listed) {
6369
- const src = join13(cwd, rel);
7399
+ const src = join14(cwd, rel);
6370
7400
  let st;
6371
7401
  try {
6372
7402
  st = statSync7(src);
@@ -6374,18 +7404,18 @@ function materialize(cwd, dest) {
6374
7404
  continue;
6375
7405
  }
6376
7406
  if (!st.isFile()) continue;
6377
- const out2 = join13(dest, rel);
6378
- mkdirSync6(dirname4(out2), { recursive: true });
7407
+ const out2 = join14(dest, rel);
7408
+ mkdirSync6(dirname5(out2), { recursive: true });
6379
7409
  dropSymlink(out2);
6380
7410
  if (lstatSync(src).isSymbolicLink()) {
6381
- writeFileSync6(out2, readFileSync14(src));
7411
+ writeFileSync6(out2, readFileSync15(src));
6382
7412
  } else {
6383
7413
  cpSync(src, out2, { dereference: true });
6384
7414
  }
6385
7415
  chmodSync2(out2, st.mode);
6386
7416
  }
6387
7417
  const nm = resolve3(cwd, "node_modules");
6388
- if (existsSync10(nm)) symlinkSync(nm, join13(dest, "node_modules"), "dir");
7418
+ if (existsSync10(nm)) symlinkSync(nm, join14(dest, "node_modules"), "dir");
6389
7419
  }
6390
7420
  function dropSymlink(p) {
6391
7421
  try {
@@ -6418,11 +7448,11 @@ function overlayDigest(dest, paths) {
6418
7448
  for (const rel of [...paths].sort()) {
6419
7449
  h.update(rel);
6420
7450
  h.update("\0");
6421
- const p = join13(dest, rel);
7451
+ const p = join14(dest, rel);
6422
7452
  try {
6423
7453
  const st = lstatSync(p);
6424
7454
  h.update(String(st.mode));
6425
- h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync14(p));
7455
+ h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync15(p));
6426
7456
  } catch {
6427
7457
  h.update("<gone>");
6428
7458
  }
@@ -6445,8 +7475,8 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
6445
7475
  throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
6446
7476
  }
6447
7477
  const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
6448
- const out2 = join13(dest, e.path);
6449
- mkdirSync6(dirname4(out2), { recursive: true });
7478
+ const out2 = join14(dest, e.path);
7479
+ mkdirSync6(dirname5(out2), { recursive: true });
6450
7480
  rmSync3(out2, { force: true });
6451
7481
  if (e.mode === "120000") {
6452
7482
  symlinkSync(content.toString("utf8"), out2);
@@ -6460,7 +7490,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
6460
7490
  const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
6461
7491
  for (const rel of inCopy) {
6462
7492
  if (!isOverlay(rel) || baseProtected.has(rel)) continue;
6463
- rmSync3(join13(dest, rel), { force: true });
7493
+ rmSync3(join14(dest, rel), { force: true });
6464
7494
  removed++;
6465
7495
  }
6466
7496
  return { restored, removed };
@@ -6502,8 +7532,8 @@ function suiteEnv(scratch) {
6502
7532
  if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
6503
7533
  env[k] = v;
6504
7534
  }
6505
- const userRc = join13(scratch, "npmrc-user");
6506
- const globalRc = join13(scratch, "npmrc-global");
7535
+ const userRc = join14(scratch, "npmrc-user");
7536
+ const globalRc = join14(scratch, "npmrc-global");
6507
7537
  for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
6508
7538
  env.npm_config_userconfig = userRc;
6509
7539
  env.npm_config_globalconfig = globalRc;
@@ -6513,8 +7543,8 @@ function suiteEnv(scratch) {
6513
7543
  }
6514
7544
  function runSuite(dir, cmd, budgetSecs) {
6515
7545
  const t0 = Date.now();
6516
- const outDir = mkdtempSync2(join13(tmpdir2(), "tw-verify-run-"));
6517
- const outFile = join13(outDir, "result.json");
7546
+ const outDir = mkdtempSync2(join14(tmpdir2(), "tw-verify-run-"));
7547
+ const outFile = join14(outDir, "result.json");
6518
7548
  try {
6519
7549
  spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
6520
7550
  cwd: dir,
@@ -6527,7 +7557,7 @@ function runSuite(dir, cmd, budgetSecs) {
6527
7557
  const secs = Math.round((Date.now() - t0) / 1e3);
6528
7558
  let r;
6529
7559
  try {
6530
- r = JSON.parse(readFileSync14(outFile, "utf8"));
7560
+ r = JSON.parse(readFileSync15(outFile, "utf8"));
6531
7561
  } catch {
6532
7562
  return { exit: null, secs };
6533
7563
  }
@@ -6578,8 +7608,8 @@ function runVerify(opts) {
6578
7608
  out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
6579
7609
  return 2;
6580
7610
  }
6581
- const visRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-vis-"));
6582
- const visDir = join13(visRoot, "t");
7611
+ const visRoot = mkdtempSync2(join14(tmpdir2(), "tw-verify-vis-"));
7612
+ const visDir = join14(visRoot, "t");
6583
7613
  const cleanup = (dirs) => {
6584
7614
  if (opts.keep) return;
6585
7615
  for (const d of dirs) rmSync3(d, { recursive: true, force: true });
@@ -6607,8 +7637,8 @@ function runVerify(opts) {
6607
7637
  out2("pristine run executes through it too. Failing closed, not open.");
6608
7638
  return 2;
6609
7639
  }
6610
- const priRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-pri-"));
6611
- const priDir = join13(priRoot, "t");
7640
+ const priRoot = mkdtempSync2(join14(tmpdir2(), "tw-verify-pri-"));
7641
+ const priDir = join14(priRoot, "t");
6612
7642
  let restored = [];
6613
7643
  let removedAdded = 0;
6614
7644
  try {
@@ -6720,7 +7750,7 @@ function parseVerify(args) {
6720
7750
 
6721
7751
  // src/cli/run.ts
6722
7752
  import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
6723
- import { readdirSync as readdirSync4, readFileSync as readFileSync15, realpathSync } from "node:fs";
7753
+ import { readdirSync as readdirSync4, readFileSync as readFileSync16, realpathSync } from "node:fs";
6724
7754
  import { resolve as resolve4 } from "node:path";
6725
7755
  var out = (s) => process.stdout.write(s + "\n");
6726
7756
  var err = (s) => process.stderr.write(s + "\n");
@@ -6729,7 +7759,7 @@ function git3(args, cwd) {
6729
7759
  }
6730
7760
  function nowTicks() {
6731
7761
  try {
6732
- return Math.floor(parseFloat(readFileSync15("/proc/uptime", "utf8").split(" ")[0]) * 100);
7762
+ return Math.floor(parseFloat(readFileSync16("/proc/uptime", "utf8").split(" ")[0]) * 100);
6733
7763
  } catch {
6734
7764
  return Number.POSITIVE_INFINITY;
6735
7765
  }
@@ -6759,7 +7789,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
6759
7789
  }
6760
7790
  if (held !== real && !held.startsWith(real + "/")) continue;
6761
7791
  try {
6762
- const stat = readFileSync15(`/proc/${pid}/stat`, "utf8");
7792
+ const stat = readFileSync16(`/proc/${pid}/stat`, "utf8");
6763
7793
  const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
6764
7794
  if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
6765
7795
  } catch {