tamperward 2.4.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 +811 -172
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -1362,6 +1362,10 @@ var ciTampering = {
1362
1362
  }
1363
1363
  };
1364
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
+
1365
1369
  // src/detectors/policy-diff.ts
1366
1370
  import { parse } from "yaml";
1367
1371
  var globs = (list) => Array.isArray(list) ? list.filter((g) => typeof g === "string").map(normalizeGlob) : [];
@@ -1472,19 +1476,390 @@ function policyWeakening(before, after) {
1472
1476
  }
1473
1477
  return reasons;
1474
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
+ }
1475
1492
 
1476
- // src/detectors/hook-tampering.ts
1477
- var RULE6 = "hook-tampering";
1478
- var HOOK_INVOCATION = /\b(tamperward|npx|npm|pnpm|yarn|bun|jest|vitest|eslint|tsc|lefthook|husky|lint-staged|make|cargo|pytest)\b/;
1479
- var isShellComment = (s) => /^\s*#/.test(s);
1480
- 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;
1481
1856
  var SYMBOLIC = /^[ugoa]*([-+=])([rwxXst]*)$/;
1482
- function chmodTouchesExec(seg) {
1483
- const toks = tokens(seg).map(unquote);
1484
- const at = toks.findIndex((t) => /^chmod$/.test(t));
1857
+ function chmodDropsExec(toks) {
1858
+ const at = toks.findIndex((t) => t === "chmod" || t.endsWith("/chmod"));
1485
1859
  if (at === -1) return false;
1486
1860
  for (const tok of toks.slice(at + 1)) {
1487
- if (/^[0-7]{3,4}$/.test(tok)) {
1861
+ if (/^--reference=/.test(tok)) return true;
1862
+ if (/^[0-7]{1,5}$/.test(tok)) {
1488
1863
  if (!ownerExec(tok)) return true;
1489
1864
  continue;
1490
1865
  }
@@ -1492,23 +1867,260 @@ function chmodTouchesExec(seg) {
1492
1867
  const m = clause.match(SYMBOLIC);
1493
1868
  if (!m) continue;
1494
1869
  const [, op, perms] = m;
1495
- const namesExec = /[xX]/.test(perms);
1496
- if (op === "=" ? !namesExec : namesExec) return true;
1870
+ if (op === "-" && /[xX]/.test(perms)) return true;
1871
+ if (op === "=" && !/[xX]/.test(perms)) return true;
1497
1872
  }
1498
1873
  }
1499
1874
  return false;
1500
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
+ }
1501
2098
  var hookTampering = {
1502
2099
  id: RULE6,
1503
2100
  surface: ["file", "command"],
1504
2101
  certainty: "mechanical",
1505
- run(changes, policy) {
2102
+ run(changes, policy, _view, ctx) {
1506
2103
  const out2 = [];
1507
2104
  for (const c of changes) {
2105
+ let semantic;
1508
2106
  if (c.kind === "file") {
1509
2107
  const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false);
1510
- if (!targetsHook) continue;
1511
- 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)) {
1512
2124
  out2.push(
1513
2125
  makeFinding(RULE6, policy, {
1514
2126
  file: c.path,
@@ -1527,7 +2139,7 @@ var hookTampering = {
1527
2139
  remediation: "Restore it. The hooks and the policy are protected assets, not obstacles to remove."
1528
2140
  })
1529
2141
  );
1530
- } else if (c.op === "rename") {
2142
+ } else if (c.op === "rename" && !renameKept(c)) {
1531
2143
  out2.push(
1532
2144
  makeFinding(RULE6, policy, {
1533
2145
  file: c.path,
@@ -1536,22 +2148,27 @@ var hookTampering = {
1536
2148
  remediation: "Renaming a hook out of place disables it. Restore the original path."
1537
2149
  })
1538
2150
  );
1539
- } else if (c.path.endsWith(POLICY_FILE) && c.before != null && c.after != null && policyWeakening(c.before, c.after) !== null) {
1540
- for (const reason of policyWeakening(c.before, c.after) ?? []) {
1541
- out2.push(
1542
- makeFinding(RULE6, policy, {
1543
- file: c.path,
1544
- message: `The policy was weakened: ${reason}.`,
1545
- evidence: reason,
1546
- remediation: "A change that weakens the guardrail is a high-risk edit requiring human sign-off, not an automated pass."
1547
- })
1548
- );
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
+ }
1549
2166
  }
2167
+ } else if ((isLefthook(c.path) || isPreCommitConfig(c.path)) && (semantic = configFindings(c, policy)) !== null) {
2168
+ out2.push(...semantic);
1550
2169
  } else if (!c.path.endsWith(POLICY_FILE)) {
1551
2170
  const added = addedLines(c);
1552
- const removed = removedLines(c);
1553
- const live = added.filter((l) => !isShellComment(l.content));
1554
- const earlyExit = added.find((l) => /^\s*exit\s+0\s*$/.test(l.content));
2171
+ const earlyExit = added.find((l) => insertsPassingExit(l.content));
1555
2172
  if (earlyExit) {
1556
2173
  out2.push(
1557
2174
  makeFinding(RULE6, policy, {
@@ -1563,20 +2180,30 @@ var hookTampering = {
1563
2180
  })
1564
2181
  );
1565
2182
  }
1566
- for (const l of removed) {
1567
- if (isShellComment(l.content) || !l.content.trim()) continue;
1568
- const m = l.content.match(HOOK_INVOCATION);
1569
- if (!m) continue;
1570
- 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) {
1571
2185
  out2.push(
1572
2186
  makeFinding(RULE6, policy, {
1573
2187
  file: c.path,
1574
- message: `A check invocation (\`${m[0]}\`) was removed from a protected hook.`,
1575
- evidence: l.content.trim(),
1576
- 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."
1577
2205
  })
1578
2206
  );
1579
- break;
1580
2207
  }
1581
2208
  } else {
1582
2209
  const INLINE = /^\s*([A-Za-z0-9_-]+):\s*\{[^}]*\bseverity:\s*(block|warn)\b/;
@@ -1612,18 +2239,19 @@ var hookTampering = {
1612
2239
  }
1613
2240
  } else {
1614
2241
  for (const seg of segments(c.raw)) {
1615
- const hitsHook = tokens(seg).map(unquote).some((t) => isProtected(t, policy, "hooks"));
1616
- if (!hitsHook) continue;
1617
- let why = null;
1618
- if (chmodTouchesExec(seg)) {
1619
- why = "chmod alters execute permission on a hook, which can disable it";
1620
- } else if (/\brm\b/.test(seg)) {
1621
- why = "rm deletes a protected hook";
1622
- } else if (/\btruncate\b/.test(seg) || /(?:^|\s)>\s*\S/.test(seg)) {
1623
- why = "a redirect/truncate empties a protected hook";
1624
- } else if (/\b(tee|sed|cp|dd|install|mv)\b/.test(seg)) {
1625
- 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;
1626
2253
  }
2254
+ const why = shellWritesHook(seg, toks, policy);
1627
2255
  if (why) {
1628
2256
  out2.push(
1629
2257
  makeFinding(RULE6, policy, {
@@ -1843,7 +2471,7 @@ var testContentRemoval = {
1843
2471
 
1844
2472
  // src/detectors/snapshot-rewrite.ts
1845
2473
  var RULE9 = "snapshot-rewrite";
1846
- var RUNNER = /\b(?:jest|vitest|playwright|ava)\b/;
2474
+ var RUNNER2 = /\b(?:jest|vitest|playwright|ava)\b/;
1847
2475
  var PM_TEST_SCRIPT = /(?:^|\s)(?:npm|pnpm|yarn|bun)\s+(?:t|tst|test|run(?:-script)?\s+\S*test\S*)(?:\s|$)/;
1848
2476
  var UPDATE_ANY = /--(?:update-?[sS]napshots?|test-update-snapshots|snapshot-update)\b/;
1849
2477
  var UPDATE_WITH_RUNNER = /(?:^|\s)(?:-u|--update)(?:\s|$)/;
@@ -1870,7 +2498,7 @@ var snapshotRewrite = {
1870
2498
  for (const seg of segments(c.raw)) {
1871
2499
  let why = null;
1872
2500
  if (UPDATE_ANY.test(seg)) why = "the snapshot-update flag rewrites every failing snapshot to match current output";
1873
- 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";
1874
2502
  else if (INSTA_ACCEPT.test(seg)) why = "cargo insta accept rewrites every pending snapshot to match current output";
1875
2503
  else if (UPDATE_EXPECT.test(seg)) why = "UPDATE_EXPECT=1 rewrites every expect-test expectation to match current output";
1876
2504
  else if (REGEN_SCRIPT.test(seg)) why = "a regeneration script rewrites the recorded expected output from current output";
@@ -1952,6 +2580,12 @@ var allDetectors = [
1952
2580
  ];
1953
2581
 
1954
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
+ }
1955
2589
  function key(f) {
1956
2590
  return `${f.rule}|${f.file ?? ""}|${f.line ?? ""}|${f.evidence}`;
1957
2591
  }
@@ -1963,13 +2597,13 @@ function isSuppressed(c, policy) {
1963
2597
  function activeChanges(changes, policy) {
1964
2598
  return changes.filter((c) => !isSuppressed(c, policy));
1965
2599
  }
1966
- function evaluate(changes, policy, detectors = allDetectors, view) {
2600
+ function evaluate(changes, policy, detectors = allDetectors, view, ctx) {
1967
2601
  const active = activeChanges(changes, policy);
1968
2602
  const out2 = [];
1969
2603
  for (const d of detectors) {
1970
- if (!isEnabled(d.id, policy)) continue;
2604
+ if (d.id !== GUARDED_RULE && !isEnabled(d.id, policy)) continue;
1971
2605
  try {
1972
- out2.push(...d.run(active, policy, view));
2606
+ out2.push(...d.run(active, policy, view, ctx));
1973
2607
  } catch (e) {
1974
2608
  process.stderr.write(`tamperward: detector "${d.id}" errored: ${String(e)}
1975
2609
  `);
@@ -1992,20 +2626,25 @@ function evaluate(changes, policy, detectors = allDetectors, view) {
1992
2626
  seen.add(k);
1993
2627
  return true;
1994
2628
  });
1995
- return deduped.filter((f) => {
1996
- 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;
1997
2632
  const ex = policy.rules[f.rule]?.exclude;
1998
2633
  if (!ex?.length || !f.file || isPolicyFile(f.file)) return true;
1999
2634
  return !matchesAny(f.file, ex);
2000
2635
  });
2001
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
+ }
2002
2641
  function hasBlocking(findings) {
2003
2642
  return findings.some((f) => f.severity === "block");
2004
2643
  }
2005
2644
 
2006
2645
  // src/policy-load.ts
2007
- import { readFileSync as readFileSync2, existsSync } from "node:fs";
2008
- 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";
2009
2648
  import { parse as parse2 } from "yaml";
2010
2649
  var PolicyError = class extends Error {
2011
2650
  };
@@ -2129,9 +2768,9 @@ function parseOrThrow(src, where) {
2129
2768
  return parsePolicy(raw, where);
2130
2769
  }
2131
2770
  function loadPolicy(cwd = process.cwd()) {
2132
- const path = join2(cwd, POLICY_FILE);
2133
- if (!existsSync(path)) return defaultPolicy();
2134
- 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);
2135
2774
  }
2136
2775
  function loadPolicyAt(rev, cwd) {
2137
2776
  const src = fileAt(rev, POLICY_FILE, { cwd });
@@ -2141,8 +2780,8 @@ function loadPolicyAt(rev, cwd) {
2141
2780
 
2142
2781
  // src/signoff.ts
2143
2782
  import { createHash } from "node:crypto";
2144
- import { appendFileSync, mkdirSync, readFileSync as readFileSync3, existsSync as existsSync2 } from "node:fs";
2145
- 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";
2146
2785
  var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
2147
2786
  function fingerprint(rule, file, evidence) {
2148
2787
  return createHash("sha256").update(`${rule}\0${file ?? ""}\0${evidence}`).digest("hex").slice(0, 16);
@@ -2153,13 +2792,13 @@ function ledgerPath(cwd, policy) {
2153
2792
  if (!ledgerInsideRepo(rel)) {
2154
2793
  throw new PolicyError(`signoff.ledger must be a relative path inside the repository, got ${JSON.stringify(rel)}`);
2155
2794
  }
2156
- return join3(cwd, rel);
2795
+ return join4(cwd, rel);
2157
2796
  }
2158
2797
  function readLedger(cwd, policy) {
2159
2798
  const p = ledgerPath(cwd, policy);
2160
- if (!existsSync2(p)) return [];
2799
+ if (!existsSync3(p)) return [];
2161
2800
  const out2 = [];
2162
- for (const line of readFileSync3(p, "utf8").split("\n")) {
2801
+ for (const line of readFileSync4(p, "utf8").split("\n")) {
2163
2802
  if (!line.trim()) continue;
2164
2803
  try {
2165
2804
  const e = JSON.parse(line);
@@ -2502,7 +3141,7 @@ function check(opts) {
2502
3141
  return 2;
2503
3142
  }
2504
3143
  const ignoredFiles = changes.filter((c) => isSuppressed(c, policy)).length;
2505
- let findings = evaluate(changes, policy, void 0, view);
3144
+ let findings = evaluate(changes, policy, void 0, view, { cwd });
2506
3145
  const { findings: remaining, cleared } = layer === "local" ? applyLocalSignoffs(findings, cwd, policy) : applyOobSignoffs(findings, oobFromEnv(), oobHeadFromEnv());
2507
3146
  findings = remaining;
2508
3147
  if (cleared.length) {
@@ -2527,21 +3166,21 @@ function runCheck(opts) {
2527
3166
  }
2528
3167
 
2529
3168
  // src/cli/hook.ts
2530
- 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";
2531
3170
  import { createHash as createHash4 } from "node:crypto";
2532
- import { join as join8 } from "node:path";
3171
+ import { join as join9 } from "node:path";
2533
3172
 
2534
3173
  // src/adapters/claude/changes.ts
2535
3174
  import { execFileSync as execFileSync2 } from "node:child_process";
2536
- 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";
2537
3176
  import { tmpdir } from "node:os";
2538
- 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";
2539
3178
  function asStr(v) {
2540
3179
  return typeof v === "string" ? v : "";
2541
3180
  }
2542
3181
  function readDisk(path) {
2543
3182
  try {
2544
- return existsSync3(path) ? readFileSync4(path, "utf8") : null;
3183
+ return existsSync4(path) ? readFileSync5(path, "utf8") : null;
2545
3184
  } catch {
2546
3185
  return null;
2547
3186
  }
@@ -2564,11 +3203,11 @@ function synthFileChange(displayPath, before, after) {
2564
3203
  if (before === null) op = "add";
2565
3204
  else if (after === null) op = "delete";
2566
3205
  else op = "modify";
2567
- const dir = mkdtempSync(join4(tmpdir(), "hf-"));
3206
+ const dir = mkdtempSync(join5(tmpdir(), "hf-"));
2568
3207
  let raw = "";
2569
3208
  try {
2570
- const a = join4(dir, "a");
2571
- const b = join4(dir, "b");
3209
+ const a = join5(dir, "a");
3210
+ const b = join5(dir, "b");
2572
3211
  writeFileSync(a, before ?? "");
2573
3212
  writeFileSync(b, after ?? "");
2574
3213
  try {
@@ -2653,22 +3292,22 @@ function formatDenial(blocks) {
2653
3292
  }
2654
3293
 
2655
3294
  // src/session.ts
2656
- import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "node:fs";
2657
- 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";
2658
3297
  var UNSAFE = /[^A-Za-z0-9_-]/g;
2659
3298
  var SHA = /^[0-9a-f]{7,40}$/;
2660
3299
  function baselinePath(cwd, sessionId) {
2661
3300
  const gd = gitDir(cwd);
2662
3301
  if (!gd) return null;
2663
- return join5(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
3302
+ return join6(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
2664
3303
  }
2665
3304
  function turnBaseline(cwd, sessionId) {
2666
3305
  if (!sessionId) return null;
2667
3306
  try {
2668
3307
  const p = baselinePath(cwd, sessionId);
2669
3308
  if (!p) return null;
2670
- if (existsSync4(p)) {
2671
- const v = readFileSync5(p, "utf8").trim();
3309
+ if (existsSync5(p)) {
3310
+ const v = readFileSync6(p, "utf8").trim();
2672
3311
  if (SHA.test(v)) return v;
2673
3312
  }
2674
3313
  const head = headSha(cwd);
@@ -2694,8 +3333,8 @@ function advanceTurnBaseline(cwd, sessionId) {
2694
3333
 
2695
3334
  // src/effect.ts
2696
3335
  import { createHash as createHash2 } from "node:crypto";
2697
- import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync, writeFileSync as writeFileSync3 } from "node:fs";
2698
- 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";
2699
3338
  import { execFileSync as execFileSync3 } from "node:child_process";
2700
3339
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
2701
3340
  var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
@@ -2707,7 +3346,7 @@ function gitDir2(cwd) {
2707
3346
  stdio: ["ignore", "pipe", "pipe"]
2708
3347
  }).trim();
2709
3348
  if (!d) return null;
2710
- return isAbsolute3(d) ? d : join6(cwd, d);
3349
+ return isAbsolute3(d) ? d : join7(cwd, d);
2711
3350
  } catch {
2712
3351
  return null;
2713
3352
  }
@@ -2716,8 +3355,8 @@ function statePath(cwd, sessionId, kind) {
2716
3355
  if (!sessionId) return null;
2717
3356
  const gd = gitDir2(cwd);
2718
3357
  if (!gd) return null;
2719
- const dir = join6(gd, "tamperward");
2720
- return join6(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
3358
+ const dir = join7(gd, "tamperward");
3359
+ return join7(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
2721
3360
  }
2722
3361
  function ptreePath(cwd, sessionId) {
2723
3362
  return statePath(cwd, sessionId, "ptree");
@@ -2732,7 +3371,7 @@ function contentHash(content) {
2732
3371
  function snapshotProtected(cwd, policy, prev) {
2733
3372
  const out2 = {};
2734
3373
  const walk = (rel) => {
2735
- const abs2 = rel ? join6(cwd, rel) : cwd;
3374
+ const abs2 = rel ? join7(cwd, rel) : cwd;
2736
3375
  let names;
2737
3376
  try {
2738
3377
  names = readdirSync(abs2);
@@ -2744,7 +3383,7 @@ function snapshotProtected(cwd, policy, prev) {
2744
3383
  const r = rel ? `${rel}/${name}` : name;
2745
3384
  let st;
2746
3385
  try {
2747
- st = statSync(join6(cwd, r));
3386
+ st = statSync(join7(cwd, r));
2748
3387
  } catch {
2749
3388
  continue;
2750
3389
  }
@@ -2752,7 +3391,7 @@ function snapshotProtected(cwd, policy, prev) {
2752
3391
  else if (st.isFile() && isProtected(r, policy)) {
2753
3392
  let hash = "";
2754
3393
  try {
2755
- hash = sha(readFileSync6(join6(cwd, r)));
3394
+ hash = sha(readFileSync7(join7(cwd, r)));
2756
3395
  } catch {
2757
3396
  hash = "unreadable";
2758
3397
  }
@@ -2774,9 +3413,9 @@ function driftBetween(expected, current) {
2774
3413
  return { changed, deleted };
2775
3414
  }
2776
3415
  function loadTree(p) {
2777
- if (!p || !existsSync5(p)) return null;
3416
+ if (!p || !existsSync6(p)) return null;
2778
3417
  try {
2779
- return JSON.parse(readFileSync6(p, "utf8"));
3418
+ return JSON.parse(readFileSync7(p, "utf8"));
2780
3419
  } catch {
2781
3420
  return null;
2782
3421
  }
@@ -2784,7 +3423,7 @@ function loadTree(p) {
2784
3423
  function saveTree(p, tree) {
2785
3424
  if (!p) return;
2786
3425
  try {
2787
- mkdirSync3(join6(p, ".."), { recursive: true });
3426
+ mkdirSync3(join7(p, ".."), { recursive: true });
2788
3427
  writeFileSync3(p, JSON.stringify(tree));
2789
3428
  } catch {
2790
3429
  }
@@ -2803,15 +3442,15 @@ function saveTurnTree(cwd, sessionId, tree) {
2803
3442
  }
2804
3443
 
2805
3444
  // src/cli/watch.ts
2806
- import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync as statSync2, watch } from "node:fs";
2807
- 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";
2808
3447
  import { createHash as createHash3 } from "node:crypto";
2809
3448
  var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
2810
3449
  function snap(abs2) {
2811
3450
  try {
2812
3451
  const st = statSync2(abs2);
2813
3452
  if (!st.isFile()) return { mode: st.mode, size: null, hash: null };
2814
- 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);
2815
3454
  return { mode: st.mode, size: st.size, hash };
2816
3455
  } catch {
2817
3456
  return { mode: null, size: null, hash: null };
@@ -2819,7 +3458,7 @@ function snap(abs2) {
2819
3458
  }
2820
3459
  function defaultEventLog(cwd) {
2821
3460
  if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
2822
- return join7(gitDir(cwd) ?? join7(cwd, ".git"), "tamperward", "fsevents.jsonl");
3461
+ return join8(gitDir(cwd) ?? join8(cwd, ".git"), "tamperward", "fsevents.jsonl");
2823
3462
  }
2824
3463
  function watchTree(dir, cb) {
2825
3464
  if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
@@ -2836,12 +3475,12 @@ function watchTree(dir, cb) {
2836
3475
  if (watchers.has(rel) || SKIP2.test(rel + "/")) return;
2837
3476
  let w;
2838
3477
  try {
2839
- w = watch(rel ? join7(dir, rel) : dir, (kind, fname) => {
3478
+ w = watch(rel ? join8(dir, rel) : dir, (kind, fname) => {
2840
3479
  if (!fname) return;
2841
3480
  const child = rel ? `${rel}/${String(fname)}` : String(fname);
2842
3481
  cb(kind, child);
2843
3482
  try {
2844
- if (statSync2(join7(dir, child)).isDirectory()) addDir(child);
3483
+ if (statSync2(join8(dir, child)).isDirectory()) addDir(child);
2845
3484
  } catch {
2846
3485
  }
2847
3486
  });
@@ -2854,7 +3493,7 @@ function watchTree(dir, cb) {
2854
3493
  addDir(rel);
2855
3494
  let names = [];
2856
3495
  try {
2857
- names = readdirSync2(rel ? join7(dir, rel) : dir);
3496
+ names = readdirSync2(rel ? join8(dir, rel) : dir);
2858
3497
  } catch {
2859
3498
  return;
2860
3499
  }
@@ -2862,7 +3501,7 @@ function watchTree(dir, cb) {
2862
3501
  const child = rel ? `${rel}/${name}` : name;
2863
3502
  if (SKIP2.test(child + "/")) continue;
2864
3503
  try {
2865
- if (statSync2(join7(dir, child)).isDirectory()) walk(child);
3504
+ if (statSync2(join8(dir, child)).isDirectory()) walk(child);
2866
3505
  } catch {
2867
3506
  }
2868
3507
  }
@@ -2874,13 +3513,13 @@ function watchTree(dir, cb) {
2874
3513
  }
2875
3514
  function startWatcher(dir, log, policy) {
2876
3515
  try {
2877
- mkdirSync4(join7(log, ".."), { recursive: true });
3516
+ mkdirSync4(join8(log, ".."), { recursive: true });
2878
3517
  } catch {
2879
3518
  }
2880
3519
  const last = /* @__PURE__ */ new Map();
2881
3520
  return watchTree(dir, (kind, rel) => {
2882
3521
  if (SKIP2.test(rel) || !isProtected(rel, policy)) return;
2883
- const s = snap(join7(dir, rel));
3522
+ const s = snap(join8(dir, rel));
2884
3523
  const key2 = `${s.hash}:${s.mode}`;
2885
3524
  if (last.get(rel) === key2) return;
2886
3525
  last.set(rel, key2);
@@ -2907,7 +3546,7 @@ function runWatch(args) {
2907
3546
  }
2908
3547
 
2909
3548
  // src/detectors/fs-events.ts
2910
- import { readFileSync as readFileSync8, statSync as statSync3 } from "node:fs";
3549
+ import { readFileSync as readFileSync9, statSync as statSync3 } from "node:fs";
2911
3550
  var RULE11 = "transient-protected-mutation";
2912
3551
  function readEvents(log, offset) {
2913
3552
  let size = 0;
@@ -2917,7 +3556,7 @@ function readEvents(log, offset) {
2917
3556
  return { events: [], newOffset: offset };
2918
3557
  }
2919
3558
  if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
2920
- const raw = readFileSync8(log, "utf8").slice(offset);
3559
+ const raw = readFileSync9(log, "utf8").slice(offset);
2921
3560
  const events = [];
2922
3561
  for (const line of raw.split("\n")) {
2923
3562
  if (!line.trim()) continue;
@@ -2970,7 +3609,7 @@ var HookInputError = class extends Error {
2970
3609
  };
2971
3610
  function readStdin() {
2972
3611
  try {
2973
- return readFileSync9(0, "utf8");
3612
+ return readFileSync10(0, "utf8");
2974
3613
  } catch (e) {
2975
3614
  throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
2976
3615
  }
@@ -3029,7 +3668,7 @@ function fileChanges(changes) {
3029
3668
  }
3030
3669
  function hashOnDisk(cwd, rel) {
3031
3670
  try {
3032
- return contentHash(readFileSync9(join8(cwd, rel)));
3671
+ return contentHash(readFileSync10(join9(cwd, rel)));
3033
3672
  } catch {
3034
3673
  return null;
3035
3674
  }
@@ -3139,7 +3778,7 @@ function effectDriftBlocks(cwd, sessionId, policy) {
3139
3778
  (c) => c.kind === "file" && (drifted.has(c.path) || c.oldPath != null && drifted.has(c.oldPath))
3140
3779
  );
3141
3780
  const gap = uncoveredDrift(cwd, policy, expected, current, scoped, base, loadTurnTree(cwd, sessionId));
3142
- const blocks = evaluate([...scoped, ...gap.changes], policy, void 0, "turn").filter((f) => f.severity === "block").concat(gap.blocks);
3781
+ const blocks = evaluate([...scoped, ...gap.changes], policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(gap.blocks);
3143
3782
  if (blocks.length > 0) return blocks;
3144
3783
  savePtree(cwd, sessionId, current);
3145
3784
  return null;
@@ -3172,7 +3811,7 @@ function preToolUseVerdict(input) {
3172
3811
  const driftBlocks = effectDriftBlocks(cwd, input.session_id, policy);
3173
3812
  if (driftBlocks) return verdict(driftBlocks, "PreToolUse");
3174
3813
  const changes = changesFromClaudeHook(input, cwd);
3175
- 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");
3176
3815
  if (blocks.length === 0) sanctionPredictedWrites(cwd, input.session_id, policy, changes);
3177
3816
  return verdict(blocks, "PreToolUse");
3178
3817
  } catch (e) {
@@ -3188,10 +3827,10 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
3188
3827
  } };
3189
3828
  const log = defaultEventLog(cwd);
3190
3829
  const cp = cursorPath(cwd, sessionId);
3191
- if (!existsSync6(log)) return none;
3830
+ if (!existsSync7(log)) return none;
3192
3831
  let offset = 0;
3193
- if (cp && existsSync6(cp)) {
3194
- const n = Number(readFileSync9(cp, "utf8"));
3832
+ if (cp && existsSync7(cp)) {
3833
+ const n = Number(readFileSync10(cp, "utf8"));
3195
3834
  offset = Number.isFinite(n) ? n : 0;
3196
3835
  }
3197
3836
  const { events, newOffset } = readEvents(log, offset);
@@ -3205,7 +3844,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
3205
3844
  const finalHash = (path) => {
3206
3845
  try {
3207
3846
  if (!statSync4(`${cwd}/${path}`).isFile()) return null;
3208
- 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);
3209
3848
  } catch {
3210
3849
  return null;
3211
3850
  }
@@ -3249,7 +3888,7 @@ function stopVerdict(input) {
3249
3888
  changes = changes.concat(gap.changes);
3250
3889
  hiddenBlocks = gap.blocks;
3251
3890
  }
3252
- blocks = evaluate(changes, policy, void 0, "turn").filter((f) => f.severity === "block").concat(hiddenBlocks);
3891
+ blocks = evaluate(changes, policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(hiddenBlocks);
3253
3892
  const transient = turnTransientBlocks(cwd, input.session_id, policy, changes);
3254
3893
  blocks = blocks.concat(transient.blocks);
3255
3894
  commitCursor = transient.commit;
@@ -3313,7 +3952,7 @@ function runAllow(opts) {
3313
3952
  const policy = loadPolicy(cwd);
3314
3953
  let findings;
3315
3954
  try {
3316
- findings = evaluate(diffWorktree({ cwd }), policy, void 0, "worktree");
3955
+ findings = evaluate(diffWorktree({ cwd }), policy, void 0, "worktree", { cwd });
3317
3956
  } catch {
3318
3957
  process.stderr.write("tamperward: cannot read the working tree (not a git repo?).\n");
3319
3958
  return 2;
@@ -3348,8 +3987,8 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
3348
3987
  // src/cli/init.ts
3349
3988
  import { execFileSync as execFileSync4 } from "node:child_process";
3350
3989
  import { createHash as createHash5 } from "node:crypto";
3351
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
3352
- import { basename, dirname as dirname3, isAbsolute as isAbsolute4, join as join9, 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";
3353
3992
  import { fileURLToPath } from "node:url";
3354
3993
  var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
3355
3994
  var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -3414,7 +4053,7 @@ function shippedVersion() {
3414
4053
  const here = dirname3(fileURLToPath(import.meta.url));
3415
4054
  for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
3416
4055
  try {
3417
- const pkg = JSON.parse(readFileSync10(join9(here, rel), "utf8"));
4056
+ const pkg = JSON.parse(readFileSync11(join10(here, rel), "utf8"));
3418
4057
  if (pkg.name === "tamperward" && pkg.version) return pkg.version;
3419
4058
  } catch {
3420
4059
  }
@@ -3521,13 +4160,13 @@ function readWorkflowMark(src) {
3521
4160
  if (!m) return null;
3522
4161
  return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
3523
4162
  }
3524
- function toolSet(matcher2) {
4163
+ function toolSet2(matcher2) {
3525
4164
  const m = String(matcher2 ?? "").trim();
3526
4165
  if (m === "" || m === "*") return null;
3527
4166
  return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
3528
4167
  }
3529
4168
  function missingTools(matcher2) {
3530
- const have = toolSet(matcher2);
4169
+ const have = toolSet2(matcher2);
3531
4170
  if (have === null) return [];
3532
4171
  return PRE_MATCHER.split("|").filter((t) => !have.has(t));
3533
4172
  }
@@ -3547,8 +4186,8 @@ function hooksShapeError(hooks) {
3547
4186
  return null;
3548
4187
  }
3549
4188
  function planPolicy(cwd) {
3550
- const path = join9(cwd, POLICY_FILE);
3551
- if (existsSync7(path)) {
4189
+ const path = join10(cwd, POLICY_FILE);
4190
+ if (existsSync8(path)) {
3552
4191
  try {
3553
4192
  loadPolicy(cwd);
3554
4193
  } catch (e) {
@@ -3566,12 +4205,12 @@ function planPolicy(cwd) {
3566
4205
  }
3567
4206
  function planClaudeHooks(cwd) {
3568
4207
  const rel = ".claude/settings.json";
3569
- const path = join9(cwd, rel);
4208
+ const path = join10(cwd, rel);
3570
4209
  let settings = {};
3571
- if (existsSync7(path)) {
4210
+ if (existsSync8(path)) {
3572
4211
  let parsed;
3573
4212
  try {
3574
- parsed = JSON.parse(readFileSync10(path, "utf8"));
4213
+ parsed = JSON.parse(readFileSync11(path, "utf8"));
3575
4214
  } catch {
3576
4215
  return {
3577
4216
  item: "agent",
@@ -3605,7 +4244,7 @@ function planClaudeHooks(cwd) {
3605
4244
  return {
3606
4245
  item: "agent",
3607
4246
  path: rel,
3608
- status: existsSync7(path) ? "update" : "create",
4247
+ status: existsSync8(path) ? "update" : "create",
3609
4248
  detail: [
3610
4249
  needPre && "wire PreToolUse deny",
3611
4250
  needStop && "wire Stop sweep",
@@ -3624,7 +4263,7 @@ function planClaudeHooks(cwd) {
3624
4263
  });
3625
4264
  }
3626
4265
  for (const m of stale) {
3627
- 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("|");
3628
4267
  }
3629
4268
  if (needStop) {
3630
4269
  (hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
@@ -3664,7 +4303,7 @@ function hooksDir(cwd) {
3664
4303
  if (out2) return resolve2(cwd, out2);
3665
4304
  } catch {
3666
4305
  }
3667
- return existsSync7(join9(cwd, ".git")) ? join9(cwd, ".git", "hooks") : null;
4306
+ return existsSync8(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
3668
4307
  }
3669
4308
  function display(cwd, path) {
3670
4309
  const rel = relative2(cwd, path);
@@ -3691,30 +4330,30 @@ function planPreCommit(cwd) {
3691
4330
  const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
3692
4331
  let path;
3693
4332
  let note;
3694
- if (existsSync7(join9(cwd, ".husky"))) {
3695
- path = join9(cwd, ".husky", "pre-commit");
4333
+ if (existsSync8(join10(cwd, ".husky"))) {
4334
+ path = join10(cwd, ".husky", "pre-commit");
3696
4335
  note = "husky";
3697
4336
  } else {
3698
- const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync7(join9(cwd, c))));
4337
+ const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join10(cwd, c))));
3699
4338
  if (managed) {
3700
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}`);
3701
4340
  }
3702
4341
  const dir = hooksDir(cwd);
3703
4342
  if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
3704
4343
  if (basename(dir) === "_" && basename(dirname3(dir)) === ".husky") {
3705
- path = join9(dirname3(dir), "pre-commit");
4344
+ path = join10(dirname3(dir), "pre-commit");
3706
4345
  note = "husky";
3707
4346
  } else {
3708
- path = join9(dir, "pre-commit");
4347
+ path = join10(dir, "pre-commit");
3709
4348
  const rel2 = display(cwd, dir);
3710
- note = rel2 === join9(".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)`;
3711
4350
  }
3712
4351
  }
3713
4352
  const rel = display(cwd, path);
3714
- if (existsSync7(path) && !statSync5(path).isFile()) {
4353
+ if (existsSync8(path) && !statSync5(path).isFile()) {
3715
4354
  return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
3716
4355
  }
3717
- const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
4356
+ const existing = existsSync8(path) ? readFileSync11(path, "utf8") : null;
3718
4357
  if (existing === null) {
3719
4358
  return {
3720
4359
  item: "pre-commit",
@@ -3803,10 +4442,10 @@ ${block.join("\n")}
3803
4442
  function planCodeowners(cwd) {
3804
4443
  const rel = ".github/CODEOWNERS";
3805
4444
  const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
3806
- (r) => existsSync7(join9(cwd, r))
4445
+ (r) => existsSync8(join10(cwd, r))
3807
4446
  );
3808
- const path = join9(cwd, existingRel ?? rel);
3809
- const existing = existingRel ? readFileSync10(path, "utf8") : null;
4447
+ const path = join10(cwd, existingRel ?? rel);
4448
+ const existing = existingRel ? readFileSync11(path, "utf8") : null;
3810
4449
  const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
3811
4450
  if (missing.length === 0) {
3812
4451
  return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
@@ -3839,12 +4478,12 @@ ${CODEOWNERS_MARK}
3839
4478
  }
3840
4479
  function planWorkflow(cwd, force) {
3841
4480
  const rel = ".github/workflows/tamperward.yml";
3842
- const path = join9(cwd, rel);
4481
+ const path = join10(cwd, rel);
3843
4482
  const write = () => {
3844
4483
  mkdirSync5(dirname3(path), { recursive: true });
3845
4484
  writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
3846
4485
  };
3847
- if (!existsSync7(path)) {
4486
+ if (!existsSync8(path)) {
3848
4487
  return {
3849
4488
  item: "ci",
3850
4489
  path: rel,
@@ -3855,7 +4494,7 @@ function planWorkflow(cwd, force) {
3855
4494
  }
3856
4495
  let src;
3857
4496
  try {
3858
- src = readFileSync10(path, "utf8");
4497
+ src = readFileSync11(path, "utf8");
3859
4498
  } catch {
3860
4499
  return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
3861
4500
  }
@@ -3954,15 +4593,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
3954
4593
  // src/cli/verify.ts
3955
4594
  import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
3956
4595
  import { createHash as createHash8 } from "node:crypto";
3957
- 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";
3958
4597
  import { tmpdir as tmpdir2 } from "node:os";
3959
- import { dirname as dirname4, join as join12, resolve as resolve3 } from "node:path";
4598
+ import { dirname as dirname4, join as join13, resolve as resolve3 } from "node:path";
3960
4599
 
3961
4600
  // src/git/trusted.ts
3962
4601
  import { execFileSync as execFileSync5 } from "node:child_process";
3963
4602
  import { createHash as createHash6 } from "node:crypto";
3964
- import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
3965
- import { isAbsolute as isAbsolute5, join as join10 } 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";
3966
4605
  function trustedGitEnv() {
3967
4606
  return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
3968
4607
  }
@@ -3975,7 +4614,7 @@ function gitDirOf(cwd) {
3975
4614
  env: trustedGitEnv()
3976
4615
  }).trim();
3977
4616
  if (!d) return null;
3978
- return isAbsolute5(d) ? d : join10(cwd, d);
4617
+ return isAbsolute5(d) ? d : join11(cwd, d);
3979
4618
  } catch {
3980
4619
  return null;
3981
4620
  }
@@ -4000,9 +4639,9 @@ function objectRewriteState(cwd) {
4000
4639
  parts.push(`${rel}:<no-git-dir>`);
4001
4640
  continue;
4002
4641
  }
4003
- const p = join10(dir, rel);
4642
+ const p = join11(dir, rel);
4004
4643
  try {
4005
- parts.push(`${rel}:` + (existsSync8(p) ? readFileSync11(p, "utf8").trim() : ""));
4644
+ parts.push(`${rel}:` + (existsSync9(p) ? readFileSync12(p, "utf8").trim() : ""));
4006
4645
  } catch {
4007
4646
  parts.push(`${rel}:<unreadable>`);
4008
4647
  }
@@ -4013,8 +4652,8 @@ function objectRewriteState(cwd) {
4013
4652
  // src/fingerprint.ts
4014
4653
  import { execFileSync as execFileSync6 } from "node:child_process";
4015
4654
  import { createHash as createHash7 } from "node:crypto";
4016
- import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as statSync6 } from "node:fs";
4017
- import { join as join11 } 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";
4018
4657
  function treeFingerprint(cwd) {
4019
4658
  const files = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
4020
4659
  cwd,
@@ -4027,7 +4666,7 @@ function treeFingerprint(cwd) {
4027
4666
  h.update(rel);
4028
4667
  h.update("\0");
4029
4668
  try {
4030
- h.update(readFileSync12(join11(cwd, rel)));
4669
+ h.update(readFileSync13(join12(cwd, rel)));
4031
4670
  } catch {
4032
4671
  h.update("<unreadable>");
4033
4672
  }
@@ -4036,7 +4675,7 @@ function treeFingerprint(cwd) {
4036
4675
  return h.digest("hex");
4037
4676
  }
4038
4677
  function depsFingerprint(cwd) {
4039
- const root = join11(cwd, "node_modules");
4678
+ const root = join12(cwd, "node_modules");
4040
4679
  const h = createHash7("sha256");
4041
4680
  const walk = (dir) => {
4042
4681
  let entries;
@@ -4046,12 +4685,12 @@ function depsFingerprint(cwd) {
4046
4685
  return;
4047
4686
  }
4048
4687
  for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
4049
- const p = join11(dir, e.name);
4688
+ const p = join12(dir, e.name);
4050
4689
  if (e.isDirectory()) walk(p);
4051
4690
  else if (e.isFile()) {
4052
4691
  h.update(p);
4053
4692
  try {
4054
- h.update(readFileSync12(p));
4693
+ h.update(readFileSync13(p));
4055
4694
  h.update(String(statSync6(p).mode));
4056
4695
  } catch {
4057
4696
  h.update("<unreadable>");
@@ -4166,7 +4805,7 @@ function baseIsAncestorOfHead(base, cwd) {
4166
4805
  function materialize(cwd, dest) {
4167
4806
  const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
4168
4807
  for (const rel of listed) {
4169
- const src = join12(cwd, rel);
4808
+ const src = join13(cwd, rel);
4170
4809
  let st;
4171
4810
  try {
4172
4811
  st = statSync7(src);
@@ -4174,18 +4813,18 @@ function materialize(cwd, dest) {
4174
4813
  continue;
4175
4814
  }
4176
4815
  if (!st.isFile()) continue;
4177
- const out2 = join12(dest, rel);
4816
+ const out2 = join13(dest, rel);
4178
4817
  mkdirSync6(dirname4(out2), { recursive: true });
4179
4818
  dropSymlink(out2);
4180
4819
  if (lstatSync(src).isSymbolicLink()) {
4181
- writeFileSync6(out2, readFileSync13(src));
4820
+ writeFileSync6(out2, readFileSync14(src));
4182
4821
  } else {
4183
4822
  cpSync(src, out2, { dereference: true });
4184
4823
  }
4185
4824
  chmodSync2(out2, st.mode);
4186
4825
  }
4187
4826
  const nm = resolve3(cwd, "node_modules");
4188
- if (existsSync9(nm)) symlinkSync(nm, join12(dest, "node_modules"), "dir");
4827
+ if (existsSync10(nm)) symlinkSync(nm, join13(dest, "node_modules"), "dir");
4189
4828
  }
4190
4829
  function dropSymlink(p) {
4191
4830
  try {
@@ -4218,11 +4857,11 @@ function overlayDigest(dest, paths) {
4218
4857
  for (const rel of [...paths].sort()) {
4219
4858
  h.update(rel);
4220
4859
  h.update("\0");
4221
- const p = join12(dest, rel);
4860
+ const p = join13(dest, rel);
4222
4861
  try {
4223
4862
  const st = lstatSync(p);
4224
4863
  h.update(String(st.mode));
4225
- h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync13(p));
4864
+ h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync14(p));
4226
4865
  } catch {
4227
4866
  h.update("<gone>");
4228
4867
  }
@@ -4245,7 +4884,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
4245
4884
  throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
4246
4885
  }
4247
4886
  const content = execFileSync7("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
4248
- const out2 = join12(dest, e.path);
4887
+ const out2 = join13(dest, e.path);
4249
4888
  mkdirSync6(dirname4(out2), { recursive: true });
4250
4889
  rmSync2(out2, { force: true });
4251
4890
  if (e.mode === "120000") {
@@ -4260,7 +4899,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
4260
4899
  const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
4261
4900
  for (const rel of inCopy) {
4262
4901
  if (!isOverlay(rel) || baseProtected.has(rel)) continue;
4263
- rmSync2(join12(dest, rel), { force: true });
4902
+ rmSync2(join13(dest, rel), { force: true });
4264
4903
  removed++;
4265
4904
  }
4266
4905
  return { restored, removed };
@@ -4302,8 +4941,8 @@ function suiteEnv(scratch) {
4302
4941
  if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
4303
4942
  env[k] = v;
4304
4943
  }
4305
- const userRc = join12(scratch, "npmrc-user");
4306
- const globalRc = join12(scratch, "npmrc-global");
4944
+ const userRc = join13(scratch, "npmrc-user");
4945
+ const globalRc = join13(scratch, "npmrc-global");
4307
4946
  for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
4308
4947
  env.npm_config_userconfig = userRc;
4309
4948
  env.npm_config_globalconfig = globalRc;
@@ -4313,8 +4952,8 @@ function suiteEnv(scratch) {
4313
4952
  }
4314
4953
  function runSuite(dir, cmd, budgetSecs) {
4315
4954
  const t0 = Date.now();
4316
- const outDir = mkdtempSync2(join12(tmpdir2(), "tw-verify-run-"));
4317
- const outFile = join12(outDir, "result.json");
4955
+ const outDir = mkdtempSync2(join13(tmpdir2(), "tw-verify-run-"));
4956
+ const outFile = join13(outDir, "result.json");
4318
4957
  try {
4319
4958
  spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
4320
4959
  cwd: dir,
@@ -4327,7 +4966,7 @@ function runSuite(dir, cmd, budgetSecs) {
4327
4966
  const secs = Math.round((Date.now() - t0) / 1e3);
4328
4967
  let r;
4329
4968
  try {
4330
- r = JSON.parse(readFileSync13(outFile, "utf8"));
4969
+ r = JSON.parse(readFileSync14(outFile, "utf8"));
4331
4970
  } catch {
4332
4971
  return { exit: null, secs };
4333
4972
  }
@@ -4378,8 +5017,8 @@ function runVerify(opts) {
4378
5017
  out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
4379
5018
  return 2;
4380
5019
  }
4381
- const visRoot = mkdtempSync2(join12(tmpdir2(), "tw-verify-vis-"));
4382
- const visDir = join12(visRoot, "t");
5020
+ const visRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-vis-"));
5021
+ const visDir = join13(visRoot, "t");
4383
5022
  const cleanup = (dirs) => {
4384
5023
  if (opts.keep) return;
4385
5024
  for (const d of dirs) rmSync2(d, { recursive: true, force: true });
@@ -4407,8 +5046,8 @@ function runVerify(opts) {
4407
5046
  out2("pristine run executes through it too. Failing closed, not open.");
4408
5047
  return 2;
4409
5048
  }
4410
- const priRoot = mkdtempSync2(join12(tmpdir2(), "tw-verify-pri-"));
4411
- const priDir = join12(priRoot, "t");
5049
+ const priRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-pri-"));
5050
+ const priDir = join13(priRoot, "t");
4412
5051
  let restored = [];
4413
5052
  let removedAdded = 0;
4414
5053
  try {
@@ -4520,7 +5159,7 @@ function parseVerify(args) {
4520
5159
 
4521
5160
  // src/cli/run.ts
4522
5161
  import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
4523
- import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync } from "node:fs";
5162
+ import { readdirSync as readdirSync4, readFileSync as readFileSync15, realpathSync } from "node:fs";
4524
5163
  import { resolve as resolve4 } from "node:path";
4525
5164
  var out = (s) => process.stdout.write(s + "\n");
4526
5165
  var err = (s) => process.stderr.write(s + "\n");
@@ -4529,7 +5168,7 @@ function git3(args, cwd) {
4529
5168
  }
4530
5169
  function nowTicks() {
4531
5170
  try {
4532
- return Math.floor(parseFloat(readFileSync14("/proc/uptime", "utf8").split(" ")[0]) * 100);
5171
+ return Math.floor(parseFloat(readFileSync15("/proc/uptime", "utf8").split(" ")[0]) * 100);
4533
5172
  } catch {
4534
5173
  return Number.POSITIVE_INFINITY;
4535
5174
  }
@@ -4559,7 +5198,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
4559
5198
  }
4560
5199
  if (held !== real && !held.startsWith(real + "/")) continue;
4561
5200
  try {
4562
- const stat = readFileSync14(`/proc/${pid}/stat`, "utf8");
5201
+ const stat = readFileSync15(`/proc/${pid}/stat`, "utf8");
4563
5202
  const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
4564
5203
  if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
4565
5204
  } catch {