tamperward 2.3.0 → 2.4.1

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 +1008 -191
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -202,6 +202,27 @@ function parseHunk(lines, start) {
202
202
  return { hunk: { oldStart, oldLines, newStart, newLines, lines: out2 }, next: i };
203
203
  }
204
204
 
205
+ // src/diff/synth.ts
206
+ function linesOf(content) {
207
+ if (content === "") return [];
208
+ const lines = content.split("\n");
209
+ if (content.endsWith("\n")) lines.pop();
210
+ return lines;
211
+ }
212
+ function addHunks(content) {
213
+ const lines = linesOf(content);
214
+ if (lines.length === 0) return [];
215
+ return [
216
+ {
217
+ oldStart: 0,
218
+ oldLines: 0,
219
+ newStart: 1,
220
+ newLines: lines.length,
221
+ lines: lines.map((l, i) => ({ type: "add", content: l, oldLine: null, newLine: i + 1 }))
222
+ }
223
+ ];
224
+ }
225
+
205
226
  // src/git/build.ts
206
227
  function git(args, cwd) {
207
228
  try {
@@ -256,6 +277,9 @@ function mergeBaseOf(base, head, opts = {}) {
256
277
  function fileAt(rev, path, opts = {}) {
257
278
  return blobAt(rev, path, opts.cwd);
258
279
  }
280
+ function fileOnDisk(path, opts = {}) {
281
+ return fromDisk(path, opts.cwd);
282
+ }
259
283
  function isGitRepo(cwd) {
260
284
  try {
261
285
  git(["rev-parse", "--git-dir"], cwd);
@@ -306,18 +330,35 @@ function diffWorktree(opts = {}) {
306
330
  (c) => enrich(c, (p) => blobAt("HEAD", p, opts.cwd), (p) => fromDisk(p, opts.cwd))
307
331
  );
308
332
  }
333
+ function untrackedAdds(opts = {}, keep, exclude) {
334
+ const others = git(["ls-files", "--others", "--exclude-standard", "-z"], opts.cwd).split("\0").filter(Boolean);
335
+ return others.filter((rel) => !exclude?.has(rel) && (!keep || keep(rel))).map((rel) => {
336
+ const after = fromDisk(rel, opts.cwd);
337
+ return {
338
+ kind: "file",
339
+ path: rel,
340
+ oldPath: null,
341
+ op: "add",
342
+ before: null,
343
+ after,
344
+ binary: false,
345
+ hunks: after == null ? [] : addHunks(after)
346
+ };
347
+ });
348
+ }
309
349
  function diffWorktreeWithUntracked(opts = {}) {
310
350
  const tracked = diffWorktree(opts);
311
- const others = git(["ls-files", "--others", "--exclude-standard", "-z"], opts.cwd).split("\0").filter(Boolean);
312
351
  const seen = new Set(tracked.filter((c) => c.kind === "file").map((c) => c.path));
313
- const untracked = others.filter((rel) => !seen.has(rel)).map(
314
- (rel) => enrich(
315
- { kind: "file", path: rel, oldPath: null, op: "add", before: null, after: null, binary: false, hunks: [] },
316
- () => null,
317
- (p) => fromDisk(p, opts.cwd)
318
- )
319
- );
320
- return [...tracked, ...untracked];
352
+ return [...tracked, ...untrackedAdds(opts, void 0, seen)];
353
+ }
354
+ function hiddenTrackedPaths(opts = {}) {
355
+ const out2 = [];
356
+ for (const entry of git(["ls-files", "-v", "-z"], opts.cwd).split("\0")) {
357
+ if (entry.length < 3 || entry[1] !== " ") continue;
358
+ const tag = entry[0];
359
+ if (tag === "S" || tag !== tag.toUpperCase()) out2.push(entry.slice(2));
360
+ }
361
+ return out2;
321
362
  }
322
363
 
323
364
  // src/detectors/finding.ts
@@ -947,8 +988,20 @@ var PATTERNS2 = {
947
988
  py: [
948
989
  // `import pytest as pt` makes the decorator `@pt.mark.skip`; the module alias is free.
949
990
  { re: /@\w+\.mark\.(?:skip|skipif|xfail)\b/, why: "a pytest skip/skipif/xfail marker" },
991
+ // `from pytest import mark` makes it `@mark.skip`; `marks=pytest.mark.skip` is the
992
+ // parametrize form. Both carry the same marker without the `@<module>.` shape.
993
+ { re: /@mark\.(?:skip|skipif|xfail)\b/, why: "a pytest skip/skipif/xfail marker (from pytest import mark)" },
994
+ { re: /\bmarks\s*=\s*(?:\w+\.)?mark\.(?:skip|skipif|xfail)\b/, why: "a parametrize marks= skip/skipif/xfail marker" },
950
995
  { re: /\bpytestmark\s*=.*\.mark\.(?:skip|skipif|xfail)\b/, why: "a module-level pytestmark skip (every test in the file is skipped)" },
951
- { re: /\bpytest\.(?:skip|xfail|importorskip)\(/, why: "a runtime pytest.skip()/xfail() call" },
996
+ { re: /\bpytest\.(?:skip|xfail|importorskip)\(/, why: "a runtime pytest.skip()/xfail()/importorskip() call" },
997
+ // Collection-time drops. A conftest.py is protected as a test file, and these
998
+ // are how pytest is told never to collect a test in the first place — no
999
+ // marker ever appears in the test itself. `collect_ignore` / `collect_ignore_glob`
1000
+ // are conftest globals (pytest docs, "Customizing test collection");
1001
+ // `pytest_ignore_collect` is the hook whose only job is to say "skip this path";
1002
+ // `__test__ = False` de-collects a class or function.
1003
+ { re: /\bcollect_ignore(?:_glob)?\s*(?:=|\+=|\.(?:append|extend)\()/, why: "a collect_ignore / collect_ignore_glob entry (the files are never collected)" },
1004
+ { re: /\bdef\s+pytest_ignore_collect\s*\(/, why: "a pytest_ignore_collect hook (paths are dropped at collection)" },
952
1005
  // `from unittest import skip` makes the decorator bare `@skip`.
953
1006
  { re: /@(?:unittest\.)?(?:skip|skipIf|skipUnless|expectedFailure)\b/, why: "a unittest skip/expectedFailure decorator" },
954
1007
  { re: /\bself\.skipTest\(/, why: "a runtime self.skipTest() call" },
@@ -1162,12 +1215,12 @@ var coverageLowering = {
1162
1215
  );
1163
1216
  }
1164
1217
  for (const l of added) {
1165
- if (/--passWithNoTests\b/.test(l.content)) {
1218
+ if (/--passWithNoTests\b/.test(l.content) || /\bpassWithNoTests\s*:\s*true\b/.test(l.content)) {
1166
1219
  out2.push(
1167
1220
  makeFinding(RULE4, policy, {
1168
1221
  file: c.path,
1169
1222
  line: l.newLine ?? void 0,
1170
- message: "--passWithNoTests added \u2014 the suite can now pass with zero tests.",
1223
+ message: "passWithNoTests added \u2014 the suite can now pass with zero tests.",
1171
1224
  evidence: l.content.trim(),
1172
1225
  remediation: "Remove --passWithNoTests; an empty suite must not be a green check."
1173
1226
  })
@@ -1309,6 +1362,10 @@ var ciTampering = {
1309
1362
  }
1310
1363
  };
1311
1364
 
1365
+ // src/detectors/hook-tampering.ts
1366
+ import { existsSync, readFileSync as readFileSync2 } from "node:fs";
1367
+ import { join as join2 } from "node:path";
1368
+
1312
1369
  // src/detectors/policy-diff.ts
1313
1370
  import { parse } from "yaml";
1314
1371
  var globs = (list) => Array.isArray(list) ? list.filter((g) => typeof g === "string").map(normalizeGlob) : [];
@@ -1419,19 +1476,390 @@ function policyWeakening(before, after) {
1419
1476
  }
1420
1477
  return reasons;
1421
1478
  }
1479
+ function policyAddWeakening(after) {
1480
+ let doc;
1481
+ try {
1482
+ doc = parse(after);
1483
+ } catch {
1484
+ return ["the added policy is not valid YAML \u2014 the gate fails closed until it is fixed"];
1485
+ }
1486
+ if (doc === null || doc === void 0) return [];
1487
+ if (typeof doc !== "object" || Array.isArray(doc)) {
1488
+ return ["the added policy is not a policy mapping \u2014 the gate fails closed until it is fixed"];
1489
+ }
1490
+ return policyWeakening("{}", after) ?? [];
1491
+ }
1422
1492
 
1423
- // src/detectors/hook-tampering.ts
1424
- var RULE6 = "hook-tampering";
1425
- var HOOK_INVOCATION = /\b(tamperward|npx|npm|pnpm|yarn|bun|jest|vitest|eslint|tsc|lefthook|husky|lint-staged|make|cargo|pytest)\b/;
1426
- var isShellComment = (s) => /^\s*#/.test(s);
1427
- var ownerExec = (mode) => (parseInt(mode.slice(-3), 8) & 64) !== 0;
1493
+ // src/detectors/hook-wiring.ts
1494
+ import { parse as parseYaml } from "yaml";
1495
+ function stripComment(line) {
1496
+ let single = false;
1497
+ let double = false;
1498
+ for (let i = 0; i < line.length; i++) {
1499
+ const ch = line[i];
1500
+ if (ch === "'" && !double) single = !single;
1501
+ else if (ch === '"' && !single) double = !double;
1502
+ else if (ch === "#" && !single && !double && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i);
1503
+ }
1504
+ return line;
1505
+ }
1506
+ var isComment = (line) => /^\s*#/.test(line);
1507
+ var RUNNER = /^(?:npx|pnpm|yarn|bunx|bun|npm)\s+(?:(?:exec|dlx|x|run)\s+)?(?:(?:--yes|-y|--no-install|--silent|-s|--)\s+)*/;
1508
+ var TOOL = /^(tamperward|jest|vitest|eslint|tsc|lint-staged|lefthook|husky|pytest|make|cargo|go|pre-commit|mypy|ruff|rubocop|prettier|gradlew?)\b/;
1509
+ var SCRIPT = /^(test|tests|lint|typecheck|type-check|coverage|check)\b/;
1510
+ var SUBCOMMAND_TOOLS = /* @__PURE__ */ new Set(["tamperward", "make", "cargo", "go", "lefthook", "husky", "pre-commit"]);
1511
+ var PREFIX = /^(?:(?:then|do|else|\{|\(|!|exec|command|time|nice|env|sudo|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"(?=\s|$)|'[^']*'(?=\s|$)|[^\s"']\S*|(?=\s)))\s+)*/;
1512
+ function checkIdentity(segment) {
1513
+ const core = segment.trim().replace(PREFIX, "").replace(/\s+/g, " ");
1514
+ const viaRunner = RUNNER.test(core);
1515
+ const rest = core.replace(RUNNER, "");
1516
+ const tool = rest.match(TOOL)?.[1] ?? (viaRunner ? rest.match(SCRIPT)?.[1] : void 0);
1517
+ if (!tool) return null;
1518
+ const toks = rest.replace(/^tamperward@\S+/, "tamperward").split(" ");
1519
+ if (tool === "tamperward") {
1520
+ const sub = toks[1];
1521
+ if (!sub || sub.startsWith("-")) return null;
1522
+ return `tamperward ${sub}`;
1523
+ }
1524
+ if (SUBCOMMAND_TOOLS.has(tool) && toks[1] && !toks[1].startsWith("-")) return `${tool} ${toks[1]}`;
1525
+ return tool;
1526
+ }
1527
+ function split(stmt) {
1528
+ const segs = [];
1529
+ const ops = [];
1530
+ const re = /(&&|\|\||;|\|)/g;
1531
+ let last = 0;
1532
+ for (const m of stmt.matchAll(re)) {
1533
+ segs.push(stmt.slice(last, m.index));
1534
+ ops.push(m[1]);
1535
+ last = (m.index ?? 0) + m[1].length;
1536
+ }
1537
+ segs.push(stmt.slice(last));
1538
+ return { segs, ops };
1539
+ }
1540
+ var unq = (s) => s.replace(/^["']|["']$/g, "");
1541
+ function alwaysFalse(cond) {
1542
+ const c = cond.trim().replace(/;$/, "").trim();
1543
+ if (/^(?:false|!\s*true|\(\s*exit\s+[1-9]\d*\s*\))$/.test(c)) return true;
1544
+ const m = c.match(/^(?:\[\[\s+(.+?)\s+\]\]|\[\s+(.+?)\s+\]|test\s+(.+))$/);
1545
+ if (!m) return false;
1546
+ const inner = (m[1] ?? m[2] ?? m[3]).trim();
1547
+ if (inner.includes("$")) return false;
1548
+ const parts = inner.match(/^(\S+)\s+(-eq|-ne|-lt|-le|-gt|-ge|=|==|!=)\s+(\S+)$/);
1549
+ if (parts) {
1550
+ const [, a, op, b] = parts;
1551
+ const x = unq(a);
1552
+ const y = unq(b);
1553
+ const nx = Number(x);
1554
+ const ny = Number(y);
1555
+ const ints = /^-?\d+$/.test(x) && /^-?\d+$/.test(y);
1556
+ switch (op) {
1557
+ case "-eq":
1558
+ return ints && nx !== ny;
1559
+ case "-ne":
1560
+ return ints && nx === ny;
1561
+ case "-lt":
1562
+ return ints && !(nx < ny);
1563
+ case "-le":
1564
+ return ints && !(nx <= ny);
1565
+ case "-gt":
1566
+ return ints && !(nx > ny);
1567
+ case "-ge":
1568
+ return ints && !(nx >= ny);
1569
+ case "=":
1570
+ case "==":
1571
+ return x !== y;
1572
+ case "!=":
1573
+ return x === y;
1574
+ }
1575
+ }
1576
+ const unary = inner.match(/^(-n|-z)\s+(\S+)$/);
1577
+ if (unary) {
1578
+ const v = unq(unary[2]);
1579
+ return unary[1] === "-n" ? v === "" : v !== "";
1580
+ }
1581
+ if (/^(?:""|'')$/.test(inner)) return true;
1582
+ return false;
1583
+ }
1584
+ var alwaysTrue = (cond) => /^(?:true|:|\[\s+1\s+-eq\s+1\s+\]|\[\s+-n\s+"?\S"?\s+\])$/.test(cond.trim().replace(/;$/, "").trim());
1585
+ var EXIT_ZERO = /^(?:exit|return)(?:\s+0+)?$/;
1586
+ var EXIT_ANY = /^(?:exit|return)(?:\s+\S+)?$/;
1587
+ var FAILS_LOUDLY = /\b(?:exit|return)\s+(?:[1-9]\d*|"?\$\?"?|"?\$\{?[A-Za-z_]\w*\}?"?)(?=[\s;})]|$)|\bfalse\b|\bkill\b|\bexit\s*$|\breturn\s*$/;
1588
+ function insertsPassingExit(line) {
1589
+ if (isComment(line)) return false;
1590
+ return split(stripComment(line)).segs.some((s) => EXIT_ZERO.test(s.trim().replace(PREFIX, "")));
1591
+ }
1592
+ function insertsDeadGuard(line) {
1593
+ if (isComment(line)) return false;
1594
+ const m = stripComment(line).trim().match(/^(?:if|elif)\s+(.+?)\s*(?:;\s*then)?\s*$/);
1595
+ return !!m && alwaysFalse(m[1]);
1596
+ }
1597
+ function invocations(lines) {
1598
+ const out2 = [];
1599
+ const pipefail = lines.some((l) => /\bpipefail\b/.test(stripComment(l)));
1600
+ const frames = [{ dead: false }];
1601
+ const dead = () => frames.some((f) => f.dead);
1602
+ for (const raw of lines) {
1603
+ if (isComment(raw)) {
1604
+ const id = checkIdentity(raw.replace(/^\s*#+\s*/, ""));
1605
+ if (id) out2.push({ identity: id, line: raw, state: "comment" });
1606
+ continue;
1607
+ }
1608
+ const stmt = stripComment(raw).trim();
1609
+ if (!stmt) continue;
1610
+ const ifm = stmt.match(/^(if|elif)\s+(.+?)\s*(?:;\s*then)?\s*$/);
1611
+ if (ifm) {
1612
+ if (ifm[1] === "if") frames.push({ dead: alwaysFalse(ifm[2]) });
1613
+ else if (frames.length > 1) frames[frames.length - 1].dead = alwaysFalse(ifm[2]);
1614
+ continue;
1615
+ }
1616
+ if (/^else\b/.test(stmt)) {
1617
+ if (frames.length > 1) frames[frames.length - 1].dead = false;
1618
+ continue;
1619
+ }
1620
+ if (/^fi\b/.test(stmt)) {
1621
+ if (frames.length > 1) frames.pop();
1622
+ continue;
1623
+ }
1624
+ const { segs, ops } = split(stmt);
1625
+ for (let i = 0; i < segs.length; i++) {
1626
+ const seg = segs[i].trim().replace(PREFIX, "");
1627
+ const id = checkIdentity(seg);
1628
+ if (!id) {
1629
+ if (EXIT_ANY.test(seg) && !dead() && (i === 0 || ops[i - 1] === ";")) frames[frames.length - 1].dead = true;
1630
+ continue;
1631
+ }
1632
+ let state = dead() ? "unreachable" : "live";
1633
+ if (state === "live" && i > 0) {
1634
+ if (ops[i - 1] === "&&" && alwaysFalse(segs[i - 1])) state = "unreachable";
1635
+ if (ops[i - 1] === "||" && alwaysTrue(segs[i - 1])) state = "unreachable";
1636
+ }
1637
+ if (state === "live" && i < ops.length) {
1638
+ const after = segs.slice(i + 1).join(" ");
1639
+ if (ops[i] === "||" && !FAILS_LOUDLY.test(after)) state = "neutered";
1640
+ else if (ops[i] === "|" && !pipefail) state = "neutered";
1641
+ else if (ops[i] === ";" && segs.slice(i + 1).some((s) => /^(?:true|:)$/.test(s.trim()) || EXIT_ZERO.test(s.trim()))) state = "neutered";
1642
+ }
1643
+ out2.push({ identity: id, line: raw, state });
1644
+ }
1645
+ }
1646
+ return out2;
1647
+ }
1648
+ function scriptWeakening(before, after) {
1649
+ const live = (inv) => new Set(inv.filter((i) => i.state === "live").map((i) => i.identity));
1650
+ const bl = live(invocations(before));
1651
+ const afterInv = invocations(after);
1652
+ const al = live(afterInv);
1653
+ const out2 = [];
1654
+ for (const id of bl) {
1655
+ if (al.has(id)) continue;
1656
+ const trace = afterInv.find((i) => i.identity === id);
1657
+ 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";
1658
+ const evidence = (trace?.line ?? before.find((l) => checkIdentity(stripComment(l).trim().replace(PREFIX, "")) === id) ?? id).trim();
1659
+ out2.push({ reason: `the check invocation \`${id}\` was ${how}`, evidence });
1660
+ }
1661
+ return out2;
1662
+ }
1663
+ var isCodeowners = (path) => /(?:^|\/)CODEOWNERS$/.test(path);
1664
+ var GATE_CRITICAL = [
1665
+ "/.github/workflows/",
1666
+ "/.tamperward.yml",
1667
+ "/.github/CODEOWNERS",
1668
+ "/CODEOWNERS",
1669
+ "/docs/CODEOWNERS",
1670
+ "/.husky/",
1671
+ "/.claude/settings.json",
1672
+ "/.claude/settings.local.json",
1673
+ "/.pre-commit-config.yaml",
1674
+ "/lefthook.yml",
1675
+ "/lefthook.yaml"
1676
+ ];
1677
+ function covers(pattern, critical) {
1678
+ if (pattern === "*" || pattern === "**") return true;
1679
+ let p = pattern.replace(/\/\*\*$/, "/").replace(/\/\*$/, "/");
1680
+ if (!p.startsWith("/")) p = "/" + p;
1681
+ if (p === critical) return true;
1682
+ if (p.endsWith("/") && critical.startsWith(p)) return true;
1683
+ return critical.startsWith(p + "/");
1684
+ }
1685
+ function ownersOf(content, critical) {
1686
+ let owners = null;
1687
+ for (const line of content.split("\n")) {
1688
+ const t = line.trim();
1689
+ if (!t || t.startsWith("#")) continue;
1690
+ const [pattern, ...rest] = t.split(/\s+/);
1691
+ if (covers(pattern, critical)) owners = rest.filter((o) => !o.startsWith("#"));
1692
+ }
1693
+ return owners;
1694
+ }
1695
+ function codeownersWeakening(before, after) {
1696
+ const out2 = [];
1697
+ for (const critical of GATE_CRITICAL) {
1698
+ const was = ownersOf(before, critical);
1699
+ if (!was || was.length === 0) continue;
1700
+ const now = ownersOf(after, critical);
1701
+ if (!now) out2.push(`the code-owner rule covering ${critical} was removed \u2014 the gate's own files no longer require a human reviewer`);
1702
+ else if (now.length === 0) out2.push(`the code-owner rule covering ${critical} lost its owners \u2014 the gate's own files no longer require a human reviewer`);
1703
+ }
1704
+ return out2;
1705
+ }
1706
+ var isDoc = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
1707
+ function parseDoc(src) {
1708
+ try {
1709
+ const v = parseYaml(src);
1710
+ return isDoc(v) ? v : null;
1711
+ } catch {
1712
+ return null;
1713
+ }
1714
+ }
1715
+ var isLefthook = (path) => /(?:^|\/)\.?lefthook(?:-local)?\.(?:ya?ml|toml|json)$/.test(path);
1716
+ var isPreCommitConfig = (path) => /(?:^|\/)\.pre-commit-config\.ya?ml$/.test(path);
1717
+ var isPackageJson = (path) => /(?:^|\/)package\.json$/.test(path);
1718
+ var GATE = /\btamperward\b/;
1719
+ var str = (v) => typeof v === "string" ? v : Array.isArray(v) ? v.map(String).join(" ") : v == null ? "" : JSON.stringify(v);
1720
+ function lefthookEntries(doc) {
1721
+ const out2 = /* @__PURE__ */ new Map();
1722
+ for (const [section, v] of Object.entries(doc)) {
1723
+ if (!isDoc(v)) continue;
1724
+ for (const group of ["commands", "scripts"]) {
1725
+ const g = v[group];
1726
+ if (!isDoc(g)) continue;
1727
+ for (const [name, cfg] of Object.entries(g)) {
1728
+ if (!isDoc(cfg) || !GATE.test(str(cfg.run ?? cfg.runner))) continue;
1729
+ out2.set(`${section}.${group}.${name}`, {
1730
+ skip: cfg.skip,
1731
+ sectionSkip: v.skip,
1732
+ glob: str(cfg.glob),
1733
+ exclude: str(cfg.exclude),
1734
+ only: str(cfg.only)
1735
+ });
1736
+ }
1737
+ }
1738
+ }
1739
+ return out2;
1740
+ }
1741
+ function lefthookWeakening(before, after) {
1742
+ const out2 = [];
1743
+ const b = lefthookEntries(before);
1744
+ const a = lefthookEntries(after);
1745
+ for (const [key2, was] of b) {
1746
+ const now = a.get(key2);
1747
+ if (!now) {
1748
+ out2.push(`lefthook entry ${key2} (the gate) was removed`);
1749
+ continue;
1750
+ }
1751
+ if (now.skip === true && was.skip !== true) out2.push(`lefthook entry ${key2} (the gate) is now \`skip: true\``);
1752
+ if (now.sectionSkip === true && was.sectionSkip !== true) out2.push(`the lefthook hook carrying ${key2} (the gate) is now \`skip: true\``);
1753
+ 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`);
1754
+ if (now.exclude !== was.exclude && now.exclude) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`exclude\` (${now.exclude})`);
1755
+ if (now.only !== was.only && now.only) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`only\` (${now.only})`);
1756
+ }
1757
+ return out2;
1758
+ }
1759
+ var COMMIT_STAGES = /* @__PURE__ */ new Set(["commit", "pre-commit", "push", "pre-push", "commit-msg", "pre-merge-commit", "prepare-commit-msg"]);
1760
+ function preCommitEntries(doc) {
1761
+ const out2 = /* @__PURE__ */ new Map();
1762
+ const repos = Array.isArray(doc.repos) ? doc.repos : [];
1763
+ for (const repo of repos) {
1764
+ if (!isDoc(repo)) continue;
1765
+ const hooks = Array.isArray(repo.hooks) ? repo.hooks : [];
1766
+ for (const h of hooks) {
1767
+ if (!isDoc(h)) continue;
1768
+ if (!GATE.test(`${str(repo.repo)} ${str(h.id)} ${str(h.entry)} ${str(h.name)}`)) continue;
1769
+ out2.set(`${str(repo.repo)}:${str(h.id)}`, {
1770
+ stages: Array.isArray(h.stages) ? h.stages.map(String) : null,
1771
+ exclude: str(h.exclude),
1772
+ files: str(h.files)
1773
+ });
1774
+ }
1775
+ }
1776
+ return out2;
1777
+ }
1778
+ function preCommitWeakening(before, after) {
1779
+ const out2 = [];
1780
+ const b = preCommitEntries(before);
1781
+ const a = preCommitEntries(after);
1782
+ for (const [key2, was] of b) {
1783
+ const now = a.get(key2);
1784
+ if (!now) {
1785
+ out2.push(`pre-commit hook ${key2} (the gate) was removed`);
1786
+ continue;
1787
+ }
1788
+ const wasStages = was.stages ?? [...COMMIT_STAGES];
1789
+ if (now.stages && !now.stages.some((s) => COMMIT_STAGES.has(s)) && wasStages.some((s) => COMMIT_STAGES.has(s))) {
1790
+ out2.push(`pre-commit hook ${key2} (the gate) now runs only at stages [${now.stages.join(", ")}] \u2014 no commit or push triggers it`);
1791
+ } else if (now.stages && was.stages) {
1792
+ const lost = was.stages.filter((s) => COMMIT_STAGES.has(s) && !now.stages.includes(s));
1793
+ if (lost.length) out2.push(`pre-commit hook ${key2} (the gate) no longer runs at stage(s) ${lost.join(", ")}`);
1794
+ }
1795
+ if (now.exclude !== was.exclude && now.exclude) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`exclude\` (${now.exclude})`);
1796
+ if (now.files !== was.files && now.files) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`files\` (${now.files})`);
1797
+ }
1798
+ const topB = str(before.exclude);
1799
+ const topA = str(after.exclude);
1800
+ 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`);
1801
+ return out2;
1802
+ }
1803
+ var INSTALLER = /\b(?:husky|lefthook|simple-git-hooks|pre-commit)\b/;
1804
+ var INSTALL_SCRIPTS = ["prepare", "postinstall", "install", "preinstall"];
1805
+ function packageJsonWeakening(before, after) {
1806
+ let b;
1807
+ let a;
1808
+ try {
1809
+ b = JSON.parse(before);
1810
+ a = JSON.parse(after);
1811
+ } catch {
1812
+ return [];
1813
+ }
1814
+ if (!isDoc(b) || !isDoc(a)) return [];
1815
+ const scripts = (d) => {
1816
+ const sc = d.scripts;
1817
+ return isDoc(sc) ? INSTALL_SCRIPTS.map((k) => str(sc[k])).join(" ") : "";
1818
+ };
1819
+ const was = scripts(b).match(INSTALLER)?.[0];
1820
+ if (!was) return [];
1821
+ if (INSTALLER.test(scripts(a))) return [];
1822
+ return [`the install script that wires the git hooks (\`${was}\`) was removed from package.json scripts \u2014 a fresh checkout installs no hooks`];
1823
+ }
1824
+ var GIT_HOOKS = /* @__PURE__ */ new Set([
1825
+ "applypatch-msg",
1826
+ "pre-applypatch",
1827
+ "post-applypatch",
1828
+ "pre-commit",
1829
+ "pre-merge-commit",
1830
+ "prepare-commit-msg",
1831
+ "commit-msg",
1832
+ "post-commit",
1833
+ "pre-rebase",
1834
+ "post-checkout",
1835
+ "post-merge",
1836
+ "pre-push",
1837
+ "pre-receive",
1838
+ "update",
1839
+ "post-receive",
1840
+ "post-update",
1841
+ "push-to-checkout",
1842
+ "pre-auto-gc",
1843
+ "post-rewrite",
1844
+ "sendemail-validate"
1845
+ ]);
1846
+ function hookIdentity(path) {
1847
+ const base = path.split("/").pop() ?? "";
1848
+ if (isLefthook(path)) return "lefthook";
1849
+ if (isPreCommitConfig(path)) return "pre-commit-config";
1850
+ if (base === "CODEOWNERS") return "codeowners";
1851
+ const husky = path.match(/(?:^|\/)\.husky\/([^/]+)$/);
1852
+ if (husky && GIT_HOOKS.has(husky[1])) return `husky:${husky[1]}`;
1853
+ return null;
1854
+ }
1855
+ var ownerExec = (mode) => (parseInt(mode.slice(-3).padStart(3, "0"), 8) & 64) !== 0;
1428
1856
  var SYMBOLIC = /^[ugoa]*([-+=])([rwxXst]*)$/;
1429
- function chmodTouchesExec(seg) {
1430
- const toks = tokens(seg).map(unquote);
1431
- const at = toks.findIndex((t) => /^chmod$/.test(t));
1857
+ function chmodDropsExec(toks) {
1858
+ const at = toks.findIndex((t) => t === "chmod" || t.endsWith("/chmod"));
1432
1859
  if (at === -1) return false;
1433
1860
  for (const tok of toks.slice(at + 1)) {
1434
- if (/^[0-7]{3,4}$/.test(tok)) {
1861
+ if (/^--reference=/.test(tok)) return true;
1862
+ if (/^[0-7]{1,5}$/.test(tok)) {
1435
1863
  if (!ownerExec(tok)) return true;
1436
1864
  continue;
1437
1865
  }
@@ -1439,23 +1867,260 @@ function chmodTouchesExec(seg) {
1439
1867
  const m = clause.match(SYMBOLIC);
1440
1868
  if (!m) continue;
1441
1869
  const [, op, perms] = m;
1442
- const namesExec = /[xX]/.test(perms);
1443
- if (op === "=" ? !namesExec : namesExec) return true;
1870
+ if (op === "-" && /[xX]/.test(perms)) return true;
1871
+ if (op === "=" && !/[xX]/.test(perms)) return true;
1444
1872
  }
1445
1873
  }
1446
1874
  return false;
1447
1875
  }
1876
+ var WRAPPERS = /^(?:sudo|command|exec|time|nice|env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/;
1877
+ function shellWritesHook(seg, toks, policy) {
1878
+ const hook = (t) => isProtected(t.replace(/^\.\//, ""), policy, "hooks");
1879
+ for (const m of seg.matchAll(/(?:^|\s)\d*>{1,2}\|?\s*(\S+)/g)) {
1880
+ if (hook(m[1].replace(/^["']|["']$/g, ""))) return "a redirect empties or rewrites a protected hook";
1881
+ }
1882
+ let at = 0;
1883
+ while (at < toks.length && WRAPPERS.test(toks[at])) at++;
1884
+ const cmd = (toks[at] ?? "").replace(/^.*\//, "");
1885
+ const args = toks.slice(at + 1);
1886
+ const positional = args.filter((a) => a !== "--" && !a.startsWith("-"));
1887
+ const last = positional[positional.length - 1] ?? "";
1888
+ const anyHook = args.some(hook);
1889
+ const inPlace = args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a));
1890
+ switch (cmd) {
1891
+ case "rm":
1892
+ case "unlink":
1893
+ case "shred":
1894
+ return anyHook ? "rm deletes a protected hook" : null;
1895
+ case "truncate":
1896
+ return anyHook ? "truncate empties a protected hook" : null;
1897
+ case "chmod":
1898
+ return anyHook && chmodDropsExec(toks) ? "chmod removes execute permission from a hook \u2014 git will skip it" : null;
1899
+ case "mv":
1900
+ return anyHook ? "mv moves a protected hook out of place or overwrites it" : null;
1901
+ case "cp":
1902
+ case "install":
1903
+ case "ln":
1904
+ return hook(last) ? `${cmd} replaces a protected hook in place` : null;
1905
+ case "tee":
1906
+ case "sponge":
1907
+ return anyHook ? `${cmd} rewrites a protected hook in place` : null;
1908
+ case "dd":
1909
+ return args.some((a) => a.startsWith("of=") && hook(a.slice(3))) ? "dd rewrites a protected hook in place" : null;
1910
+ case "sed":
1911
+ case "perl":
1912
+ return anyHook && inPlace ? `an in-place ${cmd} rewrites a protected hook` : null;
1913
+ case "awk":
1914
+ case "gawk":
1915
+ 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;
1916
+ case "git": {
1917
+ let i = 0;
1918
+ while (i < args.length && args[i].startsWith("-")) i += args[i] === "-C" || args[i] === "-c" ? 2 : 1;
1919
+ const sub = args[i];
1920
+ const rest = args.slice(i + 1);
1921
+ if (!rest.some(hook)) return null;
1922
+ if (sub === "checkout") {
1923
+ const dd = rest.indexOf("--");
1924
+ const before = dd === -1 ? rest : rest.slice(0, dd);
1925
+ const revs = before.filter((p) => !p.startsWith("-") && !hook(p));
1926
+ return revs.length > 0 ? "git checkout <rev> -- <hook> puts an older version of a protected hook back" : null;
1927
+ }
1928
+ if (sub === "restore" && rest.some((a) => /^--source(?:=|$)/.test(a) || a === "-s")) {
1929
+ return "git restore --source=<rev> <hook> puts an older version of a protected hook back";
1930
+ }
1931
+ return null;
1932
+ }
1933
+ default:
1934
+ return null;
1935
+ }
1936
+ }
1937
+
1938
+ // src/detectors/hook-tampering.ts
1939
+ var RULE6 = "hook-tampering";
1940
+ var ownerExec2 = (mode) => (parseInt(mode.slice(-3), 8) & 64) !== 0;
1941
+ var CLAUDE_SETTINGS = /(?:^|\/)\.claude\/settings(?:\.local)?\.json$/;
1942
+ var isClaudeSettings = (p) => CLAUDE_SETTINGS.test(p);
1943
+ function siblingSettings(p) {
1944
+ return p.endsWith("settings.local.json") ? p.replace(/settings\.local\.json$/, "settings.json") : p.replace(/settings\.json$/, "settings.local.json");
1945
+ }
1946
+ var TW_INVOCATION = /\btamperward(?:@\S+)?\s+(?:hook|sweep)\s+claude\b/;
1947
+ var EVENTS = ["PreToolUse", "Stop"];
1948
+ function parseSettings(src) {
1949
+ try {
1950
+ const v = JSON.parse(src);
1951
+ return v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
1952
+ } catch {
1953
+ return null;
1954
+ }
1955
+ }
1956
+ function toolSet(matcher2) {
1957
+ const m = typeof matcher2 === "string" ? matcher2.trim() : "";
1958
+ if (m === "" || m === "*") return null;
1959
+ return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
1960
+ }
1961
+ function eventEntries(s, event) {
1962
+ const hooks = s.hooks;
1963
+ if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) return null;
1964
+ const arr = hooks[event];
1965
+ return Array.isArray(arr) ? arr : null;
1966
+ }
1967
+ var declares = (s, event) => eventEntries(s, event) !== null;
1968
+ function gateWiring(s, event) {
1969
+ const out2 = [];
1970
+ for (const m of eventEntries(s, event) ?? []) {
1971
+ if (m === null || typeof m !== "object") continue;
1972
+ const matcher2 = m;
1973
+ if (!Array.isArray(matcher2.hooks)) continue;
1974
+ for (const h of matcher2.hooks) {
1975
+ if (h === null || typeof h !== "object") continue;
1976
+ const entry = h;
1977
+ if (entry.type !== void 0 && entry.type !== "command") continue;
1978
+ if (typeof entry.command !== "string" || !TW_INVOCATION.test(entry.command)) continue;
1979
+ out2.push({ tools: toolSet(matcher2.matcher), command: entry.command });
1980
+ }
1981
+ }
1982
+ return out2;
1983
+ }
1984
+ function coverage(ws2) {
1985
+ if (ws2.length === 0) return "none";
1986
+ if (ws2.some((w) => w.tools === null)) return null;
1987
+ return new Set(ws2.flatMap((w) => [...w.tools]));
1988
+ }
1989
+ var hooksDisabled = (s) => s.disableAllHooks === true;
1990
+ function settingsWeakening(before, after, sibling) {
1991
+ const reasons = [];
1992
+ if (hooksDisabled(after) && !(before !== null && hooksDisabled(before))) {
1993
+ reasons.push("`disableAllHooks: true` switches every hook off, the gate included");
1994
+ }
1995
+ for (const event of EVENTS) {
1996
+ const was = before ? coverage(gateWiring(before, event)) : "none";
1997
+ const now = coverage(gateWiring(after, event));
1998
+ if (was !== "none") {
1999
+ if (now === "none") {
2000
+ reasons.push(`the tamperward ${event} hook entry was removed`);
2001
+ } else if (event === "PreToolUse") {
2002
+ if (was === null && now !== null) {
2003
+ reasons.push(`the PreToolUse matcher for the gate was narrowed from every tool to ${[...now].join("|")}`);
2004
+ } else if (was !== null && now !== null) {
2005
+ const lost = [...was].filter((t) => !now.has(t));
2006
+ if (lost.length) reasons.push(`the PreToolUse matcher for the gate no longer covers ${lost.join(", ")}`);
2007
+ }
2008
+ }
2009
+ continue;
2010
+ }
2011
+ if (now !== "none" || !declares(after, event)) continue;
2012
+ if (before !== null && declares(before, event)) continue;
2013
+ const empty = (eventEntries(after, event) ?? []).length === 0;
2014
+ const shadowed = sibling !== null && gateWiring(sibling, event).length > 0;
2015
+ if (empty || shadowed) {
2016
+ reasons.push(
2017
+ `${event} hooks declared ${empty ? "empty" : "without the tamperward entry"} in an override file` + (shadowed ? " \u2014 this shadows the gate the sibling settings file wires" : "")
2018
+ );
2019
+ }
2020
+ }
2021
+ return reasons;
2022
+ }
2023
+ function siblingOf(c, changes, ctx) {
2024
+ const path = siblingSettings(c.path);
2025
+ const inSet = changes.find((o) => o.kind === "file" && o.path === path);
2026
+ if (inSet) return inSet.after != null ? parseSettings(inSet.after) : null;
2027
+ if (!ctx?.cwd) return null;
2028
+ try {
2029
+ const p = join2(ctx.cwd, path);
2030
+ return existsSync(p) ? parseSettings(readFileSync2(p, "utf8")) : null;
2031
+ } catch {
2032
+ return null;
2033
+ }
2034
+ }
2035
+ function claudeSettingsFindings(c, changes, policy, ctx) {
2036
+ if (c.after == null) return null;
2037
+ const after = parseSettings(c.after);
2038
+ const before = c.before != null ? parseSettings(c.before) : null;
2039
+ const finding = (reason) => makeFinding(RULE6, policy, {
2040
+ file: c.path,
2041
+ message: `The Claude Code hook wiring was weakened: ${reason}.`,
2042
+ evidence: reason,
2043
+ 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."
2044
+ });
2045
+ if (c.before != null && before === null) return null;
2046
+ if (after === null) {
2047
+ if (c.before == null) return [];
2048
+ return [finding("the settings file no longer parses as JSON \u2014 the runtime ignores it, hooks and all")];
2049
+ }
2050
+ return settingsWeakening(before, after, siblingOf(c, changes, ctx)).map(finding);
2051
+ }
2052
+ function policyFindings(c, policy) {
2053
+ if (c.after == null) return null;
2054
+ const added = c.before == null;
2055
+ const reasons = added ? policyAddWeakening(c.after) : policyWeakening(c.before, c.after);
2056
+ if (reasons === null) return null;
2057
+ return reasons.map(
2058
+ (reason) => makeFinding(RULE6, policy, {
2059
+ file: c.path,
2060
+ message: added ? `A policy file was added that weakens the baseline: ${reason}.` : `The policy was weakened: ${reason}.`,
2061
+ evidence: reason,
2062
+ remediation: "A change that weakens the guardrail is a high-risk edit requiring human sign-off, not an automated pass."
2063
+ })
2064
+ );
2065
+ }
2066
+ function renameKept(c) {
2067
+ if (!c.oldPath) return false;
2068
+ const id = hookIdentity(c.oldPath);
2069
+ if (id === null || hookIdentity(c.path) !== id) return false;
2070
+ return c.before != null && c.after != null ? c.before === c.after : c.hunks.length === 0;
2071
+ }
2072
+ function configFindings(c, policy) {
2073
+ if (c.before == null || c.after == null) return null;
2074
+ const before = parseDoc(c.before);
2075
+ const after = parseDoc(c.after);
2076
+ if (before === null) return null;
2077
+ 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);
2078
+ return reasons.map(
2079
+ (reason) => makeFinding(RULE6, policy, {
2080
+ file: c.path,
2081
+ message: `The hook configuration was weakened: ${reason}.`,
2082
+ evidence: reason,
2083
+ remediation: "Leave the entry that runs the gate as `tamperward init` wrote it. Skipping, staging-off or scoping it is disabling it."
2084
+ })
2085
+ );
2086
+ }
2087
+ function updateIndexHiding(toks, policy) {
2088
+ if (!toks.some((t) => t === "git" || t.endsWith("/git")) || !toks.includes("update-index")) return null;
2089
+ const hide = toks.find((t) => /^--(?:skip-worktree|assume-unchanged)$/.test(t));
2090
+ if (hide && toks.some((t) => isProtected(t, policy))) {
2091
+ return `git update-index ${hide} hides a protected file from every git diff \u2014 edits to it no longer reach the sweep or the pre-commit gate`;
2092
+ }
2093
+ if (toks.some((t) => /^--chmod=-x$/.test(t)) && toks.some((t) => isProtected(t, policy, "hooks"))) {
2094
+ return "git update-index --chmod=-x clears the execute bit on a protected hook through the index, which disables it";
2095
+ }
2096
+ return null;
2097
+ }
1448
2098
  var hookTampering = {
1449
2099
  id: RULE6,
1450
2100
  surface: ["file", "command"],
1451
2101
  certainty: "mechanical",
1452
- run(changes, policy) {
2102
+ run(changes, policy, _view, ctx) {
1453
2103
  const out2 = [];
1454
2104
  for (const c of changes) {
2105
+ let semantic;
1455
2106
  if (c.kind === "file") {
1456
2107
  const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false);
1457
- if (!targetsHook) continue;
1458
- if (c.op !== "delete" && c.oldMode && c.newMode && ownerExec(c.oldMode) && !ownerExec(c.newMode)) {
2108
+ if (!targetsHook) {
2109
+ if (isPackageJson(c.path) && c.before != null && c.after != null) {
2110
+ for (const reason of packageJsonWeakening(c.before, c.after)) {
2111
+ out2.push(
2112
+ makeFinding(RULE6, policy, {
2113
+ file: c.path,
2114
+ message: `The hook installation was weakened: ${reason}.`,
2115
+ evidence: reason,
2116
+ remediation: "Keep the lifecycle script that installs the git hooks. Without it a clone runs no hook at all."
2117
+ })
2118
+ );
2119
+ }
2120
+ }
2121
+ continue;
2122
+ }
2123
+ if (c.op !== "delete" && c.oldMode && c.newMode && ownerExec2(c.oldMode) && !ownerExec2(c.newMode)) {
1459
2124
  out2.push(
1460
2125
  makeFinding(RULE6, policy, {
1461
2126
  file: c.path,
@@ -1474,7 +2139,7 @@ var hookTampering = {
1474
2139
  remediation: "Restore it. The hooks and the policy are protected assets, not obstacles to remove."
1475
2140
  })
1476
2141
  );
1477
- } else if (c.op === "rename") {
2142
+ } else if (c.op === "rename" && !renameKept(c)) {
1478
2143
  out2.push(
1479
2144
  makeFinding(RULE6, policy, {
1480
2145
  file: c.path,
@@ -1483,22 +2148,27 @@ var hookTampering = {
1483
2148
  remediation: "Renaming a hook out of place disables it. Restore the original path."
1484
2149
  })
1485
2150
  );
1486
- } else if (c.path.endsWith(POLICY_FILE) && c.before != null && c.after != null && policyWeakening(c.before, c.after) !== null) {
1487
- for (const reason of policyWeakening(c.before, c.after) ?? []) {
1488
- out2.push(
1489
- makeFinding(RULE6, policy, {
1490
- file: c.path,
1491
- message: `The policy was weakened: ${reason}.`,
1492
- evidence: reason,
1493
- remediation: "A change that weakens the guardrail is a high-risk edit requiring human sign-off, not an automated pass."
1494
- })
1495
- );
2151
+ } 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) {
2152
+ out2.push(...semantic);
2153
+ } else if (c.op === "rename") {
2154
+ } else if (isCodeowners(c.path)) {
2155
+ if (c.before != null && c.after != null) {
2156
+ for (const reason of codeownersWeakening(c.before, c.after)) {
2157
+ out2.push(
2158
+ makeFinding(RULE6, policy, {
2159
+ file: c.path,
2160
+ message: `CODEOWNERS was weakened: ${reason}.`,
2161
+ evidence: reason,
2162
+ remediation: "Keep a code owner on the workflows, the policy and the hooks. That rule is the only thing requiring a human on a change to the gate itself."
2163
+ })
2164
+ );
2165
+ }
1496
2166
  }
2167
+ } else if ((isLefthook(c.path) || isPreCommitConfig(c.path)) && (semantic = configFindings(c, policy)) !== null) {
2168
+ out2.push(...semantic);
1497
2169
  } else if (!c.path.endsWith(POLICY_FILE)) {
1498
2170
  const added = addedLines(c);
1499
- const removed = removedLines(c);
1500
- const live = added.filter((l) => !isShellComment(l.content));
1501
- const earlyExit = added.find((l) => /^\s*exit\s+0\s*$/.test(l.content));
2171
+ const earlyExit = added.find((l) => insertsPassingExit(l.content));
1502
2172
  if (earlyExit) {
1503
2173
  out2.push(
1504
2174
  makeFinding(RULE6, policy, {
@@ -1510,20 +2180,30 @@ var hookTampering = {
1510
2180
  })
1511
2181
  );
1512
2182
  }
1513
- for (const l of removed) {
1514
- if (isShellComment(l.content) || !l.content.trim()) continue;
1515
- const m = l.content.match(HOOK_INVOCATION);
1516
- if (!m) continue;
1517
- if (live.some((a) => a.content.includes(m[0]))) continue;
2183
+ const deadGuard = c.before == null || c.after == null ? added.find((l) => insertsDeadGuard(l.content)) : void 0;
2184
+ if (deadGuard) {
1518
2185
  out2.push(
1519
2186
  makeFinding(RULE6, policy, {
1520
2187
  file: c.path,
1521
- message: `A check invocation (\`${m[0]}\`) was removed from a protected hook.`,
1522
- evidence: l.content.trim(),
1523
- remediation: "Restore the check. Removing or commenting out the gate's invocation disables it just as surely as deleting the hook."
2188
+ line: deadGuard.newLine ?? void 0,
2189
+ message: "A guard that can never be true was inserted into a protected hook \u2014 what it wraps no longer runs.",
2190
+ evidence: deadGuard.content.trim(),
2191
+ remediation: "Remove the dead condition. A check behind `if false` is a removed check."
2192
+ })
2193
+ );
2194
+ }
2195
+ const beforeLines = c.before != null ? c.before.split("\n") : removedLines(c).map((l) => l.content);
2196
+ const afterLines = c.after != null ? c.after.split("\n") : added.map((l) => l.content);
2197
+ const lost = scriptWeakening(beforeLines, afterLines);
2198
+ if (lost.length) {
2199
+ out2.push(
2200
+ makeFinding(RULE6, policy, {
2201
+ file: c.path,
2202
+ message: `A check invocation was removed from a protected hook: ${lost[0].reason}.`,
2203
+ evidence: lost[0].evidence,
2204
+ remediation: "Restore the check. Removing, commenting out, guarding off or swallowing the gate's exit status disables it just as surely as deleting the hook."
1524
2205
  })
1525
2206
  );
1526
- break;
1527
2207
  }
1528
2208
  } else {
1529
2209
  const INLINE = /^\s*([A-Za-z0-9_-]+):\s*\{[^}]*\bseverity:\s*(block|warn)\b/;
@@ -1559,18 +2239,19 @@ var hookTampering = {
1559
2239
  }
1560
2240
  } else {
1561
2241
  for (const seg of segments(c.raw)) {
1562
- const hitsHook = tokens(seg).map(unquote).some((t) => isProtected(t, policy, "hooks"));
1563
- if (!hitsHook) continue;
1564
- let why = null;
1565
- if (chmodTouchesExec(seg)) {
1566
- why = "chmod alters execute permission on a hook, which can disable it";
1567
- } else if (/\brm\b/.test(seg)) {
1568
- why = "rm deletes a protected hook";
1569
- } else if (/\btruncate\b/.test(seg) || /(?:^|\s)>\s*\S/.test(seg)) {
1570
- why = "a redirect/truncate empties a protected hook";
1571
- } else if (/\b(tee|sed|cp|dd|install|mv)\b/.test(seg)) {
1572
- why = "a shell rewrite (tee/sed/cp/dd/install/mv) replaces a protected hook in place";
2242
+ const toks = tokens(seg).map(unquote);
2243
+ const hiding = updateIndexHiding(toks, policy);
2244
+ if (hiding) {
2245
+ out2.push(
2246
+ makeFinding(RULE6, policy, {
2247
+ message: `Hook tampering via shell: ${hiding}.`,
2248
+ evidence: seg,
2249
+ 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."
2250
+ })
2251
+ );
2252
+ continue;
1573
2253
  }
2254
+ const why = shellWritesHook(seg, toks, policy);
1574
2255
  if (why) {
1575
2256
  out2.push(
1576
2257
  makeFinding(RULE6, policy, {
@@ -1790,7 +2471,7 @@ var testContentRemoval = {
1790
2471
 
1791
2472
  // src/detectors/snapshot-rewrite.ts
1792
2473
  var RULE9 = "snapshot-rewrite";
1793
- var RUNNER = /\b(?:jest|vitest|playwright|ava)\b/;
2474
+ var RUNNER2 = /\b(?:jest|vitest|playwright|ava)\b/;
1794
2475
  var PM_TEST_SCRIPT = /(?:^|\s)(?:npm|pnpm|yarn|bun)\s+(?:t|tst|test|run(?:-script)?\s+\S*test\S*)(?:\s|$)/;
1795
2476
  var UPDATE_ANY = /--(?:update-?[sS]napshots?|test-update-snapshots|snapshot-update)\b/;
1796
2477
  var UPDATE_WITH_RUNNER = /(?:^|\s)(?:-u|--update)(?:\s|$)/;
@@ -1817,7 +2498,7 @@ var snapshotRewrite = {
1817
2498
  for (const seg of segments(c.raw)) {
1818
2499
  let why = null;
1819
2500
  if (UPDATE_ANY.test(seg)) why = "the snapshot-update flag rewrites every failing snapshot to match current output";
1820
- else if ((RUNNER.test(seg) || PM_TEST_SCRIPT.test(seg)) && UPDATE_WITH_RUNNER.test(seg)) why = "running the test runner in update mode rewrites failing snapshots to match current output";
2501
+ else if ((RUNNER2.test(seg) || PM_TEST_SCRIPT.test(seg)) && UPDATE_WITH_RUNNER.test(seg)) why = "running the test runner in update mode rewrites failing snapshots to match current output";
1821
2502
  else if (INSTA_ACCEPT.test(seg)) why = "cargo insta accept rewrites every pending snapshot to match current output";
1822
2503
  else if (UPDATE_EXPECT.test(seg)) why = "UPDATE_EXPECT=1 rewrites every expect-test expectation to match current output";
1823
2504
  else if (REGEN_SCRIPT.test(seg)) why = "a regeneration script rewrites the recorded expected output from current output";
@@ -1899,6 +2580,12 @@ var allDetectors = [
1899
2580
  ];
1900
2581
 
1901
2582
  // src/engine.ts
2583
+ var GUARDED_RULE = "hook-tampering";
2584
+ var BASELINE_HOOKS = defaultPolicy().protected.hooks;
2585
+ function isGuardedFinding(f) {
2586
+ if (f.rule !== GUARDED_RULE || !f.file) return false;
2587
+ return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS);
2588
+ }
1902
2589
  function key(f) {
1903
2590
  return `${f.rule}|${f.file ?? ""}|${f.line ?? ""}|${f.evidence}`;
1904
2591
  }
@@ -1910,13 +2597,13 @@ function isSuppressed(c, policy) {
1910
2597
  function activeChanges(changes, policy) {
1911
2598
  return changes.filter((c) => !isSuppressed(c, policy));
1912
2599
  }
1913
- function evaluate(changes, policy, detectors = allDetectors, view) {
2600
+ function evaluate(changes, policy, detectors = allDetectors, view, ctx) {
1914
2601
  const active = activeChanges(changes, policy);
1915
2602
  const out2 = [];
1916
2603
  for (const d of detectors) {
1917
- if (!isEnabled(d.id, policy)) continue;
2604
+ if (d.id !== GUARDED_RULE && !isEnabled(d.id, policy)) continue;
1918
2605
  try {
1919
- out2.push(...d.run(active, policy, view));
2606
+ out2.push(...d.run(active, policy, view, ctx));
1920
2607
  } catch (e) {
1921
2608
  process.stderr.write(`tamperward: detector "${d.id}" errored: ${String(e)}
1922
2609
  `);
@@ -1939,20 +2626,25 @@ function evaluate(changes, policy, detectors = allDetectors, view) {
1939
2626
  seen.add(k);
1940
2627
  return true;
1941
2628
  });
1942
- return deduped.filter((f) => {
1943
- if (f.rule !== "detector-error" && !isEnabled(f.rule, policy)) return false;
2629
+ return deduped.map(pinGuarded).filter((f) => {
2630
+ if (f.rule === "detector-error" || isGuardedFinding(f)) return true;
2631
+ if (!isEnabled(f.rule, policy)) return false;
1944
2632
  const ex = policy.rules[f.rule]?.exclude;
1945
2633
  if (!ex?.length || !f.file || isPolicyFile(f.file)) return true;
1946
2634
  return !matchesAny(f.file, ex);
1947
2635
  });
1948
2636
  }
2637
+ function pinGuarded(f) {
2638
+ if (!isGuardedFinding(f) || f.severity === "block") return f;
2639
+ return { ...f, severity: "block", signoff: { ...f.signoff, required: true } };
2640
+ }
1949
2641
  function hasBlocking(findings) {
1950
2642
  return findings.some((f) => f.severity === "block");
1951
2643
  }
1952
2644
 
1953
2645
  // src/policy-load.ts
1954
- import { readFileSync as readFileSync2, existsSync } from "node:fs";
1955
- import { isAbsolute, join as join2, posix } from "node:path";
2646
+ import { readFileSync as readFileSync3, existsSync as existsSync2 } from "node:fs";
2647
+ import { isAbsolute, join as join3, posix } from "node:path";
1956
2648
  import { parse as parse2 } from "yaml";
1957
2649
  var PolicyError = class extends Error {
1958
2650
  };
@@ -2076,9 +2768,9 @@ function parseOrThrow(src, where) {
2076
2768
  return parsePolicy(raw, where);
2077
2769
  }
2078
2770
  function loadPolicy(cwd = process.cwd()) {
2079
- const path = join2(cwd, POLICY_FILE);
2080
- if (!existsSync(path)) return defaultPolicy();
2081
- return parseOrThrow(readFileSync2(path, "utf8"), POLICY_FILE);
2771
+ const path = join3(cwd, POLICY_FILE);
2772
+ if (!existsSync2(path)) return defaultPolicy();
2773
+ return parseOrThrow(readFileSync3(path, "utf8"), POLICY_FILE);
2082
2774
  }
2083
2775
  function loadPolicyAt(rev, cwd) {
2084
2776
  const src = fileAt(rev, POLICY_FILE, { cwd });
@@ -2088,8 +2780,8 @@ function loadPolicyAt(rev, cwd) {
2088
2780
 
2089
2781
  // src/signoff.ts
2090
2782
  import { createHash } from "node:crypto";
2091
- import { appendFileSync, mkdirSync, readFileSync as readFileSync3, existsSync as existsSync2 } from "node:fs";
2092
- import { dirname, join as join3 } from "node:path";
2783
+ import { appendFileSync, mkdirSync, readFileSync as readFileSync4, existsSync as existsSync3 } from "node:fs";
2784
+ import { dirname, join as join4 } from "node:path";
2093
2785
  var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
2094
2786
  function fingerprint(rule, file, evidence) {
2095
2787
  return createHash("sha256").update(`${rule}\0${file ?? ""}\0${evidence}`).digest("hex").slice(0, 16);
@@ -2100,13 +2792,13 @@ function ledgerPath(cwd, policy) {
2100
2792
  if (!ledgerInsideRepo(rel)) {
2101
2793
  throw new PolicyError(`signoff.ledger must be a relative path inside the repository, got ${JSON.stringify(rel)}`);
2102
2794
  }
2103
- return join3(cwd, rel);
2795
+ return join4(cwd, rel);
2104
2796
  }
2105
2797
  function readLedger(cwd, policy) {
2106
2798
  const p = ledgerPath(cwd, policy);
2107
- if (!existsSync2(p)) return [];
2799
+ if (!existsSync3(p)) return [];
2108
2800
  const out2 = [];
2109
- for (const line of readFileSync3(p, "utf8").split("\n")) {
2801
+ for (const line of readFileSync4(p, "utf8").split("\n")) {
2110
2802
  if (!line.trim()) continue;
2111
2803
  try {
2112
2804
  const e = JSON.parse(line);
@@ -2449,7 +3141,7 @@ function check(opts) {
2449
3141
  return 2;
2450
3142
  }
2451
3143
  const ignoredFiles = changes.filter((c) => isSuppressed(c, policy)).length;
2452
- let findings = evaluate(changes, policy, void 0, view);
3144
+ let findings = evaluate(changes, policy, void 0, view, { cwd });
2453
3145
  const { findings: remaining, cleared } = layer === "local" ? applyLocalSignoffs(findings, cwd, policy) : applyOobSignoffs(findings, oobFromEnv(), oobHeadFromEnv());
2454
3146
  findings = remaining;
2455
3147
  if (cleared.length) {
@@ -2474,20 +3166,21 @@ function runCheck(opts) {
2474
3166
  }
2475
3167
 
2476
3168
  // src/cli/hook.ts
2477
- import { readFileSync as readFileSync9, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync6, statSync as statSync4 } from "node:fs";
3169
+ import { readFileSync as readFileSync10, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7, statSync as statSync4 } from "node:fs";
2478
3170
  import { createHash as createHash4 } from "node:crypto";
3171
+ import { join as join9 } from "node:path";
2479
3172
 
2480
3173
  // src/adapters/claude/changes.ts
2481
3174
  import { execFileSync as execFileSync2 } from "node:child_process";
2482
- import { readFileSync as readFileSync4, existsSync as existsSync3, writeFileSync, mkdtempSync, rmSync } from "node:fs";
3175
+ import { readFileSync as readFileSync5, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
2483
3176
  import { tmpdir } from "node:os";
2484
- import { isAbsolute as isAbsolute2, join as join4, relative, resolve } from "node:path";
3177
+ import { isAbsolute as isAbsolute2, join as join5, relative, resolve } from "node:path";
2485
3178
  function asStr(v) {
2486
3179
  return typeof v === "string" ? v : "";
2487
3180
  }
2488
3181
  function readDisk(path) {
2489
3182
  try {
2490
- return existsSync3(path) ? readFileSync4(path, "utf8") : null;
3183
+ return existsSync4(path) ? readFileSync5(path, "utf8") : null;
2491
3184
  } catch {
2492
3185
  return null;
2493
3186
  }
@@ -2510,11 +3203,11 @@ function synthFileChange(displayPath, before, after) {
2510
3203
  if (before === null) op = "add";
2511
3204
  else if (after === null) op = "delete";
2512
3205
  else op = "modify";
2513
- const dir = mkdtempSync(join4(tmpdir(), "hf-"));
3206
+ const dir = mkdtempSync(join5(tmpdir(), "hf-"));
2514
3207
  let raw = "";
2515
3208
  try {
2516
- const a = join4(dir, "a");
2517
- const b = join4(dir, "b");
3209
+ const a = join5(dir, "a");
3210
+ const b = join5(dir, "b");
2518
3211
  writeFileSync(a, before ?? "");
2519
3212
  writeFileSync(b, after ?? "");
2520
3213
  try {
@@ -2599,22 +3292,22 @@ function formatDenial(blocks) {
2599
3292
  }
2600
3293
 
2601
3294
  // src/session.ts
2602
- import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "node:fs";
2603
- import { dirname as dirname2, join as join5 } from "node:path";
3295
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync2 } from "node:fs";
3296
+ import { dirname as dirname2, join as join6 } from "node:path";
2604
3297
  var UNSAFE = /[^A-Za-z0-9_-]/g;
2605
3298
  var SHA = /^[0-9a-f]{7,40}$/;
2606
3299
  function baselinePath(cwd, sessionId) {
2607
3300
  const gd = gitDir(cwd);
2608
3301
  if (!gd) return null;
2609
- return join5(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
3302
+ return join6(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
2610
3303
  }
2611
3304
  function turnBaseline(cwd, sessionId) {
2612
3305
  if (!sessionId) return null;
2613
3306
  try {
2614
3307
  const p = baselinePath(cwd, sessionId);
2615
3308
  if (!p) return null;
2616
- if (existsSync4(p)) {
2617
- const v = readFileSync5(p, "utf8").trim();
3309
+ if (existsSync5(p)) {
3310
+ const v = readFileSync6(p, "utf8").trim();
2618
3311
  if (SHA.test(v)) return v;
2619
3312
  }
2620
3313
  const head = headSha(cwd);
@@ -2640,8 +3333,8 @@ function advanceTurnBaseline(cwd, sessionId) {
2640
3333
 
2641
3334
  // src/effect.ts
2642
3335
  import { createHash as createHash2 } from "node:crypto";
2643
- import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync, writeFileSync as writeFileSync3 } from "node:fs";
2644
- import { isAbsolute as isAbsolute3, join as join6 } from "node:path";
3336
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync7, statSync, writeFileSync as writeFileSync3 } from "node:fs";
3337
+ import { isAbsolute as isAbsolute3, join as join7 } from "node:path";
2645
3338
  import { execFileSync as execFileSync3 } from "node:child_process";
2646
3339
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
2647
3340
  var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
@@ -2653,23 +3346,32 @@ function gitDir2(cwd) {
2653
3346
  stdio: ["ignore", "pipe", "pipe"]
2654
3347
  }).trim();
2655
3348
  if (!d) return null;
2656
- return isAbsolute3(d) ? d : join6(cwd, d);
3349
+ return isAbsolute3(d) ? d : join7(cwd, d);
2657
3350
  } catch {
2658
3351
  return null;
2659
3352
  }
2660
3353
  }
2661
- function ptreePath(cwd, sessionId) {
3354
+ function statePath(cwd, sessionId, kind) {
2662
3355
  if (!sessionId) return null;
2663
3356
  const gd = gitDir2(cwd);
2664
3357
  if (!gd) return null;
2665
- const dir = join6(gd, "tamperward");
2666
- return join6(dir, `ptree-${sessionId.replace(UNSAFE2, "")}.json`);
3358
+ const dir = join7(gd, "tamperward");
3359
+ return join7(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
3360
+ }
3361
+ function ptreePath(cwd, sessionId) {
3362
+ return statePath(cwd, sessionId, "ptree");
3363
+ }
3364
+ function turnTreePath(cwd, sessionId) {
3365
+ return statePath(cwd, sessionId, "turntree");
2667
3366
  }
2668
3367
  var sha = (buf) => createHash2("sha256").update(buf).digest("hex").slice(0, 16);
3368
+ function contentHash(content) {
3369
+ return sha(Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"));
3370
+ }
2669
3371
  function snapshotProtected(cwd, policy, prev) {
2670
3372
  const out2 = {};
2671
3373
  const walk = (rel) => {
2672
- const abs2 = rel ? join6(cwd, rel) : cwd;
3374
+ const abs2 = rel ? join7(cwd, rel) : cwd;
2673
3375
  let names;
2674
3376
  try {
2675
3377
  names = readdirSync(abs2);
@@ -2681,7 +3383,7 @@ function snapshotProtected(cwd, policy, prev) {
2681
3383
  const r = rel ? `${rel}/${name}` : name;
2682
3384
  let st;
2683
3385
  try {
2684
- st = statSync(join6(cwd, r));
3386
+ st = statSync(join7(cwd, r));
2685
3387
  } catch {
2686
3388
  continue;
2687
3389
  }
@@ -2689,7 +3391,7 @@ function snapshotProtected(cwd, policy, prev) {
2689
3391
  else if (st.isFile() && isProtected(r, policy)) {
2690
3392
  let hash = "";
2691
3393
  try {
2692
- hash = sha(readFileSync6(join6(cwd, r)));
3394
+ hash = sha(readFileSync7(join7(cwd, r)));
2693
3395
  } catch {
2694
3396
  hash = "unreadable";
2695
3397
  }
@@ -2710,35 +3412,45 @@ function driftBetween(expected, current) {
2710
3412
  }
2711
3413
  return { changed, deleted };
2712
3414
  }
2713
- function loadPtree(cwd, sessionId) {
2714
- const p = ptreePath(cwd, sessionId);
2715
- if (!p || !existsSync5(p)) return null;
3415
+ function loadTree(p) {
3416
+ if (!p || !existsSync6(p)) return null;
2716
3417
  try {
2717
- return JSON.parse(readFileSync6(p, "utf8"));
3418
+ return JSON.parse(readFileSync7(p, "utf8"));
2718
3419
  } catch {
2719
3420
  return null;
2720
3421
  }
2721
3422
  }
2722
- function savePtree(cwd, sessionId, tree) {
2723
- const p = ptreePath(cwd, sessionId);
3423
+ function saveTree(p, tree) {
2724
3424
  if (!p) return;
2725
3425
  try {
2726
- mkdirSync3(join6(p, ".."), { recursive: true });
3426
+ mkdirSync3(join7(p, ".."), { recursive: true });
2727
3427
  writeFileSync3(p, JSON.stringify(tree));
2728
3428
  } catch {
2729
3429
  }
2730
3430
  }
3431
+ function loadPtree(cwd, sessionId) {
3432
+ return loadTree(ptreePath(cwd, sessionId));
3433
+ }
3434
+ function savePtree(cwd, sessionId, tree) {
3435
+ saveTree(ptreePath(cwd, sessionId), tree);
3436
+ }
3437
+ function loadTurnTree(cwd, sessionId) {
3438
+ return loadTree(turnTreePath(cwd, sessionId));
3439
+ }
3440
+ function saveTurnTree(cwd, sessionId, tree) {
3441
+ saveTree(turnTreePath(cwd, sessionId), tree);
3442
+ }
2731
3443
 
2732
3444
  // src/cli/watch.ts
2733
- import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync as statSync2, watch } from "node:fs";
2734
- import { join as join7 } from "node:path";
3445
+ import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync8, statSync as statSync2, watch } from "node:fs";
3446
+ import { join as join8 } from "node:path";
2735
3447
  import { createHash as createHash3 } from "node:crypto";
2736
3448
  var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
2737
3449
  function snap(abs2) {
2738
3450
  try {
2739
3451
  const st = statSync2(abs2);
2740
3452
  if (!st.isFile()) return { mode: st.mode, size: null, hash: null };
2741
- const hash = createHash3("sha256").update(readFileSync7(abs2)).digest("hex").slice(0, 16);
3453
+ const hash = createHash3("sha256").update(readFileSync8(abs2)).digest("hex").slice(0, 16);
2742
3454
  return { mode: st.mode, size: st.size, hash };
2743
3455
  } catch {
2744
3456
  return { mode: null, size: null, hash: null };
@@ -2746,7 +3458,7 @@ function snap(abs2) {
2746
3458
  }
2747
3459
  function defaultEventLog(cwd) {
2748
3460
  if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
2749
- return join7(gitDir(cwd) ?? join7(cwd, ".git"), "tamperward", "fsevents.jsonl");
3461
+ return join8(gitDir(cwd) ?? join8(cwd, ".git"), "tamperward", "fsevents.jsonl");
2750
3462
  }
2751
3463
  function watchTree(dir, cb) {
2752
3464
  if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
@@ -2763,12 +3475,12 @@ function watchTree(dir, cb) {
2763
3475
  if (watchers.has(rel) || SKIP2.test(rel + "/")) return;
2764
3476
  let w;
2765
3477
  try {
2766
- w = watch(rel ? join7(dir, rel) : dir, (kind, fname) => {
3478
+ w = watch(rel ? join8(dir, rel) : dir, (kind, fname) => {
2767
3479
  if (!fname) return;
2768
3480
  const child = rel ? `${rel}/${String(fname)}` : String(fname);
2769
3481
  cb(kind, child);
2770
3482
  try {
2771
- if (statSync2(join7(dir, child)).isDirectory()) addDir(child);
3483
+ if (statSync2(join8(dir, child)).isDirectory()) addDir(child);
2772
3484
  } catch {
2773
3485
  }
2774
3486
  });
@@ -2781,7 +3493,7 @@ function watchTree(dir, cb) {
2781
3493
  addDir(rel);
2782
3494
  let names = [];
2783
3495
  try {
2784
- names = readdirSync2(rel ? join7(dir, rel) : dir);
3496
+ names = readdirSync2(rel ? join8(dir, rel) : dir);
2785
3497
  } catch {
2786
3498
  return;
2787
3499
  }
@@ -2789,7 +3501,7 @@ function watchTree(dir, cb) {
2789
3501
  const child = rel ? `${rel}/${name}` : name;
2790
3502
  if (SKIP2.test(child + "/")) continue;
2791
3503
  try {
2792
- if (statSync2(join7(dir, child)).isDirectory()) walk(child);
3504
+ if (statSync2(join8(dir, child)).isDirectory()) walk(child);
2793
3505
  } catch {
2794
3506
  }
2795
3507
  }
@@ -2801,13 +3513,13 @@ function watchTree(dir, cb) {
2801
3513
  }
2802
3514
  function startWatcher(dir, log, policy) {
2803
3515
  try {
2804
- mkdirSync4(join7(log, ".."), { recursive: true });
3516
+ mkdirSync4(join8(log, ".."), { recursive: true });
2805
3517
  } catch {
2806
3518
  }
2807
3519
  const last = /* @__PURE__ */ new Map();
2808
3520
  return watchTree(dir, (kind, rel) => {
2809
3521
  if (SKIP2.test(rel) || !isProtected(rel, policy)) return;
2810
- const s = snap(join7(dir, rel));
3522
+ const s = snap(join8(dir, rel));
2811
3523
  const key2 = `${s.hash}:${s.mode}`;
2812
3524
  if (last.get(rel) === key2) return;
2813
3525
  last.set(rel, key2);
@@ -2834,7 +3546,7 @@ function runWatch(args) {
2834
3546
  }
2835
3547
 
2836
3548
  // src/detectors/fs-events.ts
2837
- import { readFileSync as readFileSync8, statSync as statSync3 } from "node:fs";
3549
+ import { readFileSync as readFileSync9, statSync as statSync3 } from "node:fs";
2838
3550
  var RULE11 = "transient-protected-mutation";
2839
3551
  function readEvents(log, offset) {
2840
3552
  let size = 0;
@@ -2844,7 +3556,7 @@ function readEvents(log, offset) {
2844
3556
  return { events: [], newOffset: offset };
2845
3557
  }
2846
3558
  if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
2847
- const raw = readFileSync8(log, "utf8").slice(offset);
3559
+ const raw = readFileSync9(log, "utf8").slice(offset);
2848
3560
  const events = [];
2849
3561
  for (const line of raw.split("\n")) {
2850
3562
  if (!line.trim()) continue;
@@ -2897,7 +3609,7 @@ var HookInputError = class extends Error {
2897
3609
  };
2898
3610
  function readStdin() {
2899
3611
  try {
2900
- return readFileSync9(0, "utf8");
3612
+ return readFileSync10(0, "utf8");
2901
3613
  } catch (e) {
2902
3614
  throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
2903
3615
  }
@@ -2951,12 +3663,107 @@ function verdict(blocks, kind) {
2951
3663
  } : { decision: "block", reason };
2952
3664
  return { exitCode: 0, stdout: JSON.stringify(payload) + "\n" };
2953
3665
  }
3666
+ function fileChanges(changes) {
3667
+ return changes.filter((c) => c.kind === "file");
3668
+ }
3669
+ function hashOnDisk(cwd, rel) {
3670
+ try {
3671
+ return contentHash(readFileSync10(join9(cwd, rel)));
3672
+ } catch {
3673
+ return null;
3674
+ }
3675
+ }
3676
+ function gitMode(mode) {
3677
+ return (mode & 64) !== 0 ? "100755" : "100644";
3678
+ }
3679
+ function turnView(cwd, sessionId, policy, base) {
3680
+ const tracked = base ? diffSince(base, { cwd }) : diffWorktree({ cwd });
3681
+ const seen = /* @__PURE__ */ new Set();
3682
+ for (const c of fileChanges(tracked)) {
3683
+ seen.add(c.path);
3684
+ if (c.oldPath) seen.add(c.oldPath);
3685
+ }
3686
+ const turnTree = loadTurnTree(cwd, sessionId);
3687
+ const untracked = untrackedAdds({ cwd }, (rel) => isProtected(rel, policy), seen).filter(
3688
+ (c) => c.kind !== "file" || !turnTree || turnTree[c.path]?.hash !== hashOnDisk(cwd, c.path)
3689
+ );
3690
+ for (const c of fileChanges(untracked)) seen.add(c.path);
3691
+ const trusted = base ?? "HEAD";
3692
+ const hidden = [];
3693
+ for (const rel of hiddenTrackedPaths({ cwd })) {
3694
+ if (seen.has(rel) || !isProtected(rel, policy)) continue;
3695
+ hidden.push(...synthFileChange(rel, fileAt(trusted, rel, { cwd }), fileOnDisk(rel, { cwd })));
3696
+ }
3697
+ return [...tracked, ...untracked, ...hidden];
3698
+ }
3699
+ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree) {
3700
+ const d = driftBetween(expected, current);
3701
+ const seen = /* @__PURE__ */ new Set();
3702
+ for (const c of fileChanges(covered)) {
3703
+ seen.add(c.path);
3704
+ if (c.oldPath) seen.add(c.oldPath);
3705
+ }
3706
+ const changes = [];
3707
+ const blocks = [];
3708
+ const trusted = base ?? "HEAD";
3709
+ const candidates = base ? [base, "HEAD", ""] : ["HEAD", ""];
3710
+ for (const rel of [...d.changed, ...d.deleted]) {
3711
+ if (seen.has(rel)) continue;
3712
+ const after = fileOnDisk(rel, { cwd });
3713
+ const trustedBlob = fileAt(trusted, rel, { cwd });
3714
+ const atGitState = after != null && trustedBlob != null && contentHash(after) === contentHash(trustedBlob);
3715
+ const atTurnStart = current[rel] != null && turnTree?.[rel]?.hash === current[rel].hash;
3716
+ if ((atGitState || atTurnStart) && current[rel] && expected[rel] && current[rel].mode === expected[rel].mode) {
3717
+ continue;
3718
+ }
3719
+ const want = expected[rel]?.hash;
3720
+ let before = null;
3721
+ for (const rev of candidates) {
3722
+ const blob = fileAt(rev, rel, { cwd });
3723
+ if (blob != null && contentHash(blob) === want) {
3724
+ before = blob;
3725
+ break;
3726
+ }
3727
+ }
3728
+ if (before == null) {
3729
+ blocks.push({
3730
+ rule: "hidden-drift",
3731
+ severity: "block",
3732
+ file: rel,
3733
+ message: `Protected file ${rel} changed outside git's view, and its sanctioned content cannot be reconstructed to judge the change.`,
3734
+ evidence: rel,
3735
+ remediation: "Restore the file to its last sanctioned content (git restore, or undo the edit) and bring it back into git's view (git update-index --no-skip-worktree / --no-assume-unchanged); make the change through a tool call the gate can see.",
3736
+ signoff: { required: true, command: `tamperward allow hidden-drift --file ${rel} --reason "..."` }
3737
+ });
3738
+ continue;
3739
+ }
3740
+ const synth = synthFileChange(rel, before, after);
3741
+ if (synth.length > 0) {
3742
+ changes.push(...synth);
3743
+ } else if (after != null && current[rel] && expected[rel] && current[rel].mode !== expected[rel].mode) {
3744
+ changes.push({
3745
+ kind: "file",
3746
+ path: rel,
3747
+ oldPath: null,
3748
+ op: "modify",
3749
+ before,
3750
+ after,
3751
+ binary: false,
3752
+ hunks: [],
3753
+ oldMode: gitMode(expected[rel].mode),
3754
+ newMode: gitMode(current[rel].mode)
3755
+ });
3756
+ }
3757
+ }
3758
+ return { changes, blocks };
3759
+ }
2954
3760
  function effectDriftBlocks(cwd, sessionId, policy) {
2955
3761
  if (!sessionId || !isGitRepo(cwd)) return null;
2956
3762
  const expected = loadPtree(cwd, sessionId);
2957
3763
  const current = snapshotProtected(cwd, policy, expected ?? void 0);
2958
3764
  if (!expected) {
2959
3765
  savePtree(cwd, sessionId, current);
3766
+ if (!loadTurnTree(cwd, sessionId)) saveTurnTree(cwd, sessionId, current);
2960
3767
  return null;
2961
3768
  }
2962
3769
  const d = driftBetween(expected, current);
@@ -2965,12 +3772,13 @@ function effectDriftBlocks(cwd, sessionId, policy) {
2965
3772
  return null;
2966
3773
  }
2967
3774
  const base = turnBaseline(cwd, sessionId);
2968
- const all = base ? diffSince(base, { cwd }) : diffWorktree({ cwd });
3775
+ const all = turnView(cwd, sessionId, policy, base);
2969
3776
  const drifted = /* @__PURE__ */ new Set([...d.changed, ...d.deleted]);
2970
3777
  const scoped = all.filter(
2971
3778
  (c) => c.kind === "file" && (drifted.has(c.path) || c.oldPath != null && drifted.has(c.oldPath))
2972
3779
  );
2973
- const blocks = evaluate(scoped, policy, void 0, "turn").filter((f) => f.severity === "block");
3780
+ const gap = uncoveredDrift(cwd, policy, expected, current, scoped, base, loadTurnTree(cwd, sessionId));
3781
+ const blocks = evaluate([...scoped, ...gap.changes], policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(gap.blocks);
2974
3782
  if (blocks.length > 0) return blocks;
2975
3783
  savePtree(cwd, sessionId, current);
2976
3784
  return null;
@@ -3003,7 +3811,7 @@ function preToolUseVerdict(input) {
3003
3811
  const driftBlocks = effectDriftBlocks(cwd, input.session_id, policy);
3004
3812
  if (driftBlocks) return verdict(driftBlocks, "PreToolUse");
3005
3813
  const changes = changesFromClaudeHook(input, cwd);
3006
- const blocks = evaluate(changes, policy, void 0, "tool-call").filter((f) => f.severity === "block");
3814
+ const blocks = evaluate(changes, policy, void 0, "tool-call", { cwd }).filter((f) => f.severity === "block");
3007
3815
  if (blocks.length === 0) sanctionPredictedWrites(cwd, input.session_id, policy, changes);
3008
3816
  return verdict(blocks, "PreToolUse");
3009
3817
  } catch (e) {
@@ -3019,10 +3827,10 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
3019
3827
  } };
3020
3828
  const log = defaultEventLog(cwd);
3021
3829
  const cp = cursorPath(cwd, sessionId);
3022
- if (!existsSync6(log)) return none;
3830
+ if (!existsSync7(log)) return none;
3023
3831
  let offset = 0;
3024
- if (cp && existsSync6(cp)) {
3025
- const n = Number(readFileSync9(cp, "utf8"));
3832
+ if (cp && existsSync7(cp)) {
3833
+ const n = Number(readFileSync10(cp, "utf8"));
3026
3834
  offset = Number.isFinite(n) ? n : 0;
3027
3835
  }
3028
3836
  const { events, newOffset } = readEvents(log, offset);
@@ -3036,7 +3844,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
3036
3844
  const finalHash = (path) => {
3037
3845
  try {
3038
3846
  if (!statSync4(`${cwd}/${path}`).isFile()) return null;
3039
- return createHash4("sha256").update(readFileSync9(`${cwd}/${path}`)).digest("hex").slice(0, 16);
3847
+ return createHash4("sha256").update(readFileSync10(`${cwd}/${path}`)).digest("hex").slice(0, 16);
3040
3848
  } catch {
3041
3849
  return null;
3042
3850
  }
@@ -3071,13 +3879,22 @@ function stopVerdict(input) {
3071
3879
  try {
3072
3880
  const policy = loadPolicy(cwd);
3073
3881
  const base = turnBaseline(cwd, input.session_id);
3074
- const changes = base ? diffSince(base, { cwd }) : diffWorktree({ cwd });
3075
- blocks = evaluate(changes, policy, void 0, "turn").filter((f) => f.severity === "block");
3882
+ let changes = turnView(cwd, input.session_id, policy, base);
3883
+ const expected = loadPtree(cwd, input.session_id);
3884
+ const current = input.session_id ? snapshotProtected(cwd, policy, expected ?? void 0) : null;
3885
+ let hiddenBlocks = [];
3886
+ if (expected && current) {
3887
+ const gap = uncoveredDrift(cwd, policy, expected, current, changes, base, loadTurnTree(cwd, input.session_id));
3888
+ changes = changes.concat(gap.changes);
3889
+ hiddenBlocks = gap.blocks;
3890
+ }
3891
+ blocks = evaluate(changes, policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(hiddenBlocks);
3076
3892
  const transient = turnTransientBlocks(cwd, input.session_id, policy, changes);
3077
3893
  blocks = blocks.concat(transient.blocks);
3078
3894
  commitCursor = transient.commit;
3079
- if (blocks.length === 0 && input.session_id) {
3080
- savePtree(cwd, input.session_id, snapshotProtected(cwd, policy, loadPtree(cwd, input.session_id) ?? void 0));
3895
+ if (blocks.length === 0 && input.session_id && current) {
3896
+ savePtree(cwd, input.session_id, current);
3897
+ saveTurnTree(cwd, input.session_id, current);
3081
3898
  }
3082
3899
  } catch (e) {
3083
3900
  return failClosed("Stop", errText(e));
@@ -3135,7 +3952,7 @@ function runAllow(opts) {
3135
3952
  const policy = loadPolicy(cwd);
3136
3953
  let findings;
3137
3954
  try {
3138
- findings = evaluate(diffWorktree({ cwd }), policy, void 0, "worktree");
3955
+ findings = evaluate(diffWorktree({ cwd }), policy, void 0, "worktree", { cwd });
3139
3956
  } catch {
3140
3957
  process.stderr.write("tamperward: cannot read the working tree (not a git repo?).\n");
3141
3958
  return 2;
@@ -3170,8 +3987,8 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
3170
3987
  // src/cli/init.ts
3171
3988
  import { execFileSync as execFileSync4 } from "node:child_process";
3172
3989
  import { createHash as createHash5 } from "node:crypto";
3173
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
3174
- import { basename, dirname as dirname3, isAbsolute as isAbsolute4, join as join8, relative as relative2, resolve as resolve2 } from "node:path";
3990
+ import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
3991
+ import { basename, dirname as dirname3, isAbsolute as isAbsolute4, join as join10, relative as relative2, resolve as resolve2 } from "node:path";
3175
3992
  import { fileURLToPath } from "node:url";
3176
3993
  var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
3177
3994
  var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -3236,7 +4053,7 @@ function shippedVersion() {
3236
4053
  const here = dirname3(fileURLToPath(import.meta.url));
3237
4054
  for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
3238
4055
  try {
3239
- const pkg = JSON.parse(readFileSync10(join8(here, rel), "utf8"));
4056
+ const pkg = JSON.parse(readFileSync11(join10(here, rel), "utf8"));
3240
4057
  if (pkg.name === "tamperward" && pkg.version) return pkg.version;
3241
4058
  } catch {
3242
4059
  }
@@ -3343,13 +4160,13 @@ function readWorkflowMark(src) {
3343
4160
  if (!m) return null;
3344
4161
  return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
3345
4162
  }
3346
- function toolSet(matcher2) {
4163
+ function toolSet2(matcher2) {
3347
4164
  const m = String(matcher2 ?? "").trim();
3348
4165
  if (m === "" || m === "*") return null;
3349
4166
  return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
3350
4167
  }
3351
4168
  function missingTools(matcher2) {
3352
- const have = toolSet(matcher2);
4169
+ const have = toolSet2(matcher2);
3353
4170
  if (have === null) return [];
3354
4171
  return PRE_MATCHER.split("|").filter((t) => !have.has(t));
3355
4172
  }
@@ -3369,8 +4186,8 @@ function hooksShapeError(hooks) {
3369
4186
  return null;
3370
4187
  }
3371
4188
  function planPolicy(cwd) {
3372
- const path = join8(cwd, POLICY_FILE);
3373
- if (existsSync7(path)) {
4189
+ const path = join10(cwd, POLICY_FILE);
4190
+ if (existsSync8(path)) {
3374
4191
  try {
3375
4192
  loadPolicy(cwd);
3376
4193
  } catch (e) {
@@ -3388,12 +4205,12 @@ function planPolicy(cwd) {
3388
4205
  }
3389
4206
  function planClaudeHooks(cwd) {
3390
4207
  const rel = ".claude/settings.json";
3391
- const path = join8(cwd, rel);
4208
+ const path = join10(cwd, rel);
3392
4209
  let settings = {};
3393
- if (existsSync7(path)) {
4210
+ if (existsSync8(path)) {
3394
4211
  let parsed;
3395
4212
  try {
3396
- parsed = JSON.parse(readFileSync10(path, "utf8"));
4213
+ parsed = JSON.parse(readFileSync11(path, "utf8"));
3397
4214
  } catch {
3398
4215
  return {
3399
4216
  item: "agent",
@@ -3427,7 +4244,7 @@ function planClaudeHooks(cwd) {
3427
4244
  return {
3428
4245
  item: "agent",
3429
4246
  path: rel,
3430
- status: existsSync7(path) ? "update" : "create",
4247
+ status: existsSync8(path) ? "update" : "create",
3431
4248
  detail: [
3432
4249
  needPre && "wire PreToolUse deny",
3433
4250
  needStop && "wire Stop sweep",
@@ -3446,7 +4263,7 @@ function planClaudeHooks(cwd) {
3446
4263
  });
3447
4264
  }
3448
4265
  for (const m of stale) {
3449
- m.matcher = [...toolSet(m.matcher) ?? /* @__PURE__ */ new Set(), ...missingTools(m.matcher)].join("|");
4266
+ m.matcher = [...toolSet2(m.matcher) ?? /* @__PURE__ */ new Set(), ...missingTools(m.matcher)].join("|");
3450
4267
  }
3451
4268
  if (needStop) {
3452
4269
  (hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
@@ -3486,7 +4303,7 @@ function hooksDir(cwd) {
3486
4303
  if (out2) return resolve2(cwd, out2);
3487
4304
  } catch {
3488
4305
  }
3489
- return existsSync7(join8(cwd, ".git")) ? join8(cwd, ".git", "hooks") : null;
4306
+ return existsSync8(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
3490
4307
  }
3491
4308
  function display(cwd, path) {
3492
4309
  const rel = relative2(cwd, path);
@@ -3513,30 +4330,30 @@ function planPreCommit(cwd) {
3513
4330
  const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
3514
4331
  let path;
3515
4332
  let note;
3516
- if (existsSync7(join8(cwd, ".husky"))) {
3517
- path = join8(cwd, ".husky", "pre-commit");
4333
+ if (existsSync8(join10(cwd, ".husky"))) {
4334
+ path = join10(cwd, ".husky", "pre-commit");
3518
4335
  note = "husky";
3519
4336
  } else {
3520
- const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync7(join8(cwd, c))));
4337
+ const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join10(cwd, c))));
3521
4338
  if (managed) {
3522
4339
  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}`);
3523
4340
  }
3524
4341
  const dir = hooksDir(cwd);
3525
4342
  if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
3526
4343
  if (basename(dir) === "_" && basename(dirname3(dir)) === ".husky") {
3527
- path = join8(dirname3(dir), "pre-commit");
4344
+ path = join10(dirname3(dir), "pre-commit");
3528
4345
  note = "husky";
3529
4346
  } else {
3530
- path = join8(dir, "pre-commit");
4347
+ path = join10(dir, "pre-commit");
3531
4348
  const rel2 = display(cwd, dir);
3532
- note = rel2 === join8(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
4349
+ 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)`;
3533
4350
  }
3534
4351
  }
3535
4352
  const rel = display(cwd, path);
3536
- if (existsSync7(path) && !statSync5(path).isFile()) {
4353
+ if (existsSync8(path) && !statSync5(path).isFile()) {
3537
4354
  return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
3538
4355
  }
3539
- const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
4356
+ const existing = existsSync8(path) ? readFileSync11(path, "utf8") : null;
3540
4357
  if (existing === null) {
3541
4358
  return {
3542
4359
  item: "pre-commit",
@@ -3625,10 +4442,10 @@ ${block.join("\n")}
3625
4442
  function planCodeowners(cwd) {
3626
4443
  const rel = ".github/CODEOWNERS";
3627
4444
  const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
3628
- (r) => existsSync7(join8(cwd, r))
4445
+ (r) => existsSync8(join10(cwd, r))
3629
4446
  );
3630
- const path = join8(cwd, existingRel ?? rel);
3631
- const existing = existingRel ? readFileSync10(path, "utf8") : null;
4447
+ const path = join10(cwd, existingRel ?? rel);
4448
+ const existing = existingRel ? readFileSync11(path, "utf8") : null;
3632
4449
  const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
3633
4450
  if (missing.length === 0) {
3634
4451
  return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
@@ -3661,12 +4478,12 @@ ${CODEOWNERS_MARK}
3661
4478
  }
3662
4479
  function planWorkflow(cwd, force) {
3663
4480
  const rel = ".github/workflows/tamperward.yml";
3664
- const path = join8(cwd, rel);
4481
+ const path = join10(cwd, rel);
3665
4482
  const write = () => {
3666
4483
  mkdirSync5(dirname3(path), { recursive: true });
3667
4484
  writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
3668
4485
  };
3669
- if (!existsSync7(path)) {
4486
+ if (!existsSync8(path)) {
3670
4487
  return {
3671
4488
  item: "ci",
3672
4489
  path: rel,
@@ -3677,7 +4494,7 @@ function planWorkflow(cwd, force) {
3677
4494
  }
3678
4495
  let src;
3679
4496
  try {
3680
- src = readFileSync10(path, "utf8");
4497
+ src = readFileSync11(path, "utf8");
3681
4498
  } catch {
3682
4499
  return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
3683
4500
  }
@@ -3776,15 +4593,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
3776
4593
  // src/cli/verify.ts
3777
4594
  import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
3778
4595
  import { createHash as createHash8 } from "node:crypto";
3779
- import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "node:fs";
4596
+ import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
3780
4597
  import { tmpdir as tmpdir2 } from "node:os";
3781
- import { dirname as dirname4, join as join11, resolve as resolve3 } from "node:path";
4598
+ import { dirname as dirname4, join as join13, resolve as resolve3 } from "node:path";
3782
4599
 
3783
4600
  // src/git/trusted.ts
3784
4601
  import { execFileSync as execFileSync5 } from "node:child_process";
3785
4602
  import { createHash as createHash6 } from "node:crypto";
3786
- import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
3787
- import { isAbsolute as isAbsolute5, join as join9 } from "node:path";
4603
+ import { existsSync as existsSync9, readFileSync as readFileSync12 } from "node:fs";
4604
+ import { isAbsolute as isAbsolute5, join as join11 } from "node:path";
3788
4605
  function trustedGitEnv() {
3789
4606
  return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
3790
4607
  }
@@ -3797,7 +4614,7 @@ function gitDirOf(cwd) {
3797
4614
  env: trustedGitEnv()
3798
4615
  }).trim();
3799
4616
  if (!d) return null;
3800
- return isAbsolute5(d) ? d : join9(cwd, d);
4617
+ return isAbsolute5(d) ? d : join11(cwd, d);
3801
4618
  } catch {
3802
4619
  return null;
3803
4620
  }
@@ -3822,9 +4639,9 @@ function objectRewriteState(cwd) {
3822
4639
  parts.push(`${rel}:<no-git-dir>`);
3823
4640
  continue;
3824
4641
  }
3825
- const p = join9(dir, rel);
4642
+ const p = join11(dir, rel);
3826
4643
  try {
3827
- parts.push(`${rel}:` + (existsSync8(p) ? readFileSync11(p, "utf8").trim() : ""));
4644
+ parts.push(`${rel}:` + (existsSync9(p) ? readFileSync12(p, "utf8").trim() : ""));
3828
4645
  } catch {
3829
4646
  parts.push(`${rel}:<unreadable>`);
3830
4647
  }
@@ -3835,8 +4652,8 @@ function objectRewriteState(cwd) {
3835
4652
  // src/fingerprint.ts
3836
4653
  import { execFileSync as execFileSync6 } from "node:child_process";
3837
4654
  import { createHash as createHash7 } from "node:crypto";
3838
- import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as statSync6 } from "node:fs";
3839
- import { join as join10 } from "node:path";
4655
+ import { readdirSync as readdirSync3, readFileSync as readFileSync13, readlinkSync, statSync as statSync6 } from "node:fs";
4656
+ import { join as join12 } from "node:path";
3840
4657
  function treeFingerprint(cwd) {
3841
4658
  const files = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
3842
4659
  cwd,
@@ -3849,7 +4666,7 @@ function treeFingerprint(cwd) {
3849
4666
  h.update(rel);
3850
4667
  h.update("\0");
3851
4668
  try {
3852
- h.update(readFileSync12(join10(cwd, rel)));
4669
+ h.update(readFileSync13(join12(cwd, rel)));
3853
4670
  } catch {
3854
4671
  h.update("<unreadable>");
3855
4672
  }
@@ -3858,7 +4675,7 @@ function treeFingerprint(cwd) {
3858
4675
  return h.digest("hex");
3859
4676
  }
3860
4677
  function depsFingerprint(cwd) {
3861
- const root = join10(cwd, "node_modules");
4678
+ const root = join12(cwd, "node_modules");
3862
4679
  const h = createHash7("sha256");
3863
4680
  const walk = (dir) => {
3864
4681
  let entries;
@@ -3868,12 +4685,12 @@ function depsFingerprint(cwd) {
3868
4685
  return;
3869
4686
  }
3870
4687
  for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
3871
- const p = join10(dir, e.name);
4688
+ const p = join12(dir, e.name);
3872
4689
  if (e.isDirectory()) walk(p);
3873
4690
  else if (e.isFile()) {
3874
4691
  h.update(p);
3875
4692
  try {
3876
- h.update(readFileSync12(p));
4693
+ h.update(readFileSync13(p));
3877
4694
  h.update(String(statSync6(p).mode));
3878
4695
  } catch {
3879
4696
  h.update("<unreadable>");
@@ -3988,7 +4805,7 @@ function baseIsAncestorOfHead(base, cwd) {
3988
4805
  function materialize(cwd, dest) {
3989
4806
  const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
3990
4807
  for (const rel of listed) {
3991
- const src = join11(cwd, rel);
4808
+ const src = join13(cwd, rel);
3992
4809
  let st;
3993
4810
  try {
3994
4811
  st = statSync7(src);
@@ -3996,18 +4813,18 @@ function materialize(cwd, dest) {
3996
4813
  continue;
3997
4814
  }
3998
4815
  if (!st.isFile()) continue;
3999
- const out2 = join11(dest, rel);
4816
+ const out2 = join13(dest, rel);
4000
4817
  mkdirSync6(dirname4(out2), { recursive: true });
4001
4818
  dropSymlink(out2);
4002
4819
  if (lstatSync(src).isSymbolicLink()) {
4003
- writeFileSync6(out2, readFileSync13(src));
4820
+ writeFileSync6(out2, readFileSync14(src));
4004
4821
  } else {
4005
4822
  cpSync(src, out2, { dereference: true });
4006
4823
  }
4007
4824
  chmodSync2(out2, st.mode);
4008
4825
  }
4009
4826
  const nm = resolve3(cwd, "node_modules");
4010
- if (existsSync9(nm)) symlinkSync(nm, join11(dest, "node_modules"), "dir");
4827
+ if (existsSync10(nm)) symlinkSync(nm, join13(dest, "node_modules"), "dir");
4011
4828
  }
4012
4829
  function dropSymlink(p) {
4013
4830
  try {
@@ -4040,11 +4857,11 @@ function overlayDigest(dest, paths) {
4040
4857
  for (const rel of [...paths].sort()) {
4041
4858
  h.update(rel);
4042
4859
  h.update("\0");
4043
- const p = join11(dest, rel);
4860
+ const p = join13(dest, rel);
4044
4861
  try {
4045
4862
  const st = lstatSync(p);
4046
4863
  h.update(String(st.mode));
4047
- h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync13(p));
4864
+ h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync14(p));
4048
4865
  } catch {
4049
4866
  h.update("<gone>");
4050
4867
  }
@@ -4067,7 +4884,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
4067
4884
  throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
4068
4885
  }
4069
4886
  const content = execFileSync7("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
4070
- const out2 = join11(dest, e.path);
4887
+ const out2 = join13(dest, e.path);
4071
4888
  mkdirSync6(dirname4(out2), { recursive: true });
4072
4889
  rmSync2(out2, { force: true });
4073
4890
  if (e.mode === "120000") {
@@ -4082,7 +4899,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
4082
4899
  const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
4083
4900
  for (const rel of inCopy) {
4084
4901
  if (!isOverlay(rel) || baseProtected.has(rel)) continue;
4085
- rmSync2(join11(dest, rel), { force: true });
4902
+ rmSync2(join13(dest, rel), { force: true });
4086
4903
  removed++;
4087
4904
  }
4088
4905
  return { restored, removed };
@@ -4124,8 +4941,8 @@ function suiteEnv(scratch) {
4124
4941
  if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
4125
4942
  env[k] = v;
4126
4943
  }
4127
- const userRc = join11(scratch, "npmrc-user");
4128
- const globalRc = join11(scratch, "npmrc-global");
4944
+ const userRc = join13(scratch, "npmrc-user");
4945
+ const globalRc = join13(scratch, "npmrc-global");
4129
4946
  for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
4130
4947
  env.npm_config_userconfig = userRc;
4131
4948
  env.npm_config_globalconfig = globalRc;
@@ -4135,8 +4952,8 @@ function suiteEnv(scratch) {
4135
4952
  }
4136
4953
  function runSuite(dir, cmd, budgetSecs) {
4137
4954
  const t0 = Date.now();
4138
- const outDir = mkdtempSync2(join11(tmpdir2(), "tw-verify-run-"));
4139
- const outFile = join11(outDir, "result.json");
4955
+ const outDir = mkdtempSync2(join13(tmpdir2(), "tw-verify-run-"));
4956
+ const outFile = join13(outDir, "result.json");
4140
4957
  try {
4141
4958
  spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
4142
4959
  cwd: dir,
@@ -4149,7 +4966,7 @@ function runSuite(dir, cmd, budgetSecs) {
4149
4966
  const secs = Math.round((Date.now() - t0) / 1e3);
4150
4967
  let r;
4151
4968
  try {
4152
- r = JSON.parse(readFileSync13(outFile, "utf8"));
4969
+ r = JSON.parse(readFileSync14(outFile, "utf8"));
4153
4970
  } catch {
4154
4971
  return { exit: null, secs };
4155
4972
  }
@@ -4200,8 +5017,8 @@ function runVerify(opts) {
4200
5017
  out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
4201
5018
  return 2;
4202
5019
  }
4203
- const visRoot = mkdtempSync2(join11(tmpdir2(), "tw-verify-vis-"));
4204
- const visDir = join11(visRoot, "t");
5020
+ const visRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-vis-"));
5021
+ const visDir = join13(visRoot, "t");
4205
5022
  const cleanup = (dirs) => {
4206
5023
  if (opts.keep) return;
4207
5024
  for (const d of dirs) rmSync2(d, { recursive: true, force: true });
@@ -4229,8 +5046,8 @@ function runVerify(opts) {
4229
5046
  out2("pristine run executes through it too. Failing closed, not open.");
4230
5047
  return 2;
4231
5048
  }
4232
- const priRoot = mkdtempSync2(join11(tmpdir2(), "tw-verify-pri-"));
4233
- const priDir = join11(priRoot, "t");
5049
+ const priRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-pri-"));
5050
+ const priDir = join13(priRoot, "t");
4234
5051
  let restored = [];
4235
5052
  let removedAdded = 0;
4236
5053
  try {
@@ -4342,7 +5159,7 @@ function parseVerify(args) {
4342
5159
 
4343
5160
  // src/cli/run.ts
4344
5161
  import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
4345
- import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync } from "node:fs";
5162
+ import { readdirSync as readdirSync4, readFileSync as readFileSync15, realpathSync } from "node:fs";
4346
5163
  import { resolve as resolve4 } from "node:path";
4347
5164
  var out = (s) => process.stdout.write(s + "\n");
4348
5165
  var err = (s) => process.stderr.write(s + "\n");
@@ -4351,7 +5168,7 @@ function git3(args, cwd) {
4351
5168
  }
4352
5169
  function nowTicks() {
4353
5170
  try {
4354
- return Math.floor(parseFloat(readFileSync14("/proc/uptime", "utf8").split(" ")[0]) * 100);
5171
+ return Math.floor(parseFloat(readFileSync15("/proc/uptime", "utf8").split(" ")[0]) * 100);
4355
5172
  } catch {
4356
5173
  return Number.POSITIVE_INFINITY;
4357
5174
  }
@@ -4381,7 +5198,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
4381
5198
  }
4382
5199
  if (held !== real && !held.startsWith(real + "/")) continue;
4383
5200
  try {
4384
- const stat = readFileSync14(`/proc/${pid}/stat`, "utf8");
5201
+ const stat = readFileSync15(`/proc/${pid}/stat`, "utf8");
4385
5202
  const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
4386
5203
  if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
4387
5204
  } catch {