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.
- package/dist/cli/index.js +811 -172
- 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-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
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
|
|
1483
|
-
const
|
|
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 (
|
|
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
|
-
|
|
1496
|
-
if (op === "="
|
|
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)
|
|
1511
|
-
|
|
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)
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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
|
|
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
|
-
|
|
1567
|
-
|
|
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
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
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
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
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
|
|
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 ((
|
|
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
|
|
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
|
|
2008
|
-
import { isAbsolute, join as
|
|
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 =
|
|
2133
|
-
if (!
|
|
2134
|
-
return parseOrThrow(
|
|
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
|
|
2145
|
-
import { dirname, join as
|
|
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
|
|
2795
|
+
return join4(cwd, rel);
|
|
2157
2796
|
}
|
|
2158
2797
|
function readLedger(cwd, policy) {
|
|
2159
2798
|
const p = ledgerPath(cwd, policy);
|
|
2160
|
-
if (!
|
|
2799
|
+
if (!existsSync3(p)) return [];
|
|
2161
2800
|
const out2 = [];
|
|
2162
|
-
for (const line of
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
3206
|
+
const dir = mkdtempSync(join5(tmpdir(), "hf-"));
|
|
2568
3207
|
let raw = "";
|
|
2569
3208
|
try {
|
|
2570
|
-
const a =
|
|
2571
|
-
const 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
|
|
2657
|
-
import { dirname as dirname2, join as
|
|
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
|
|
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 (
|
|
2671
|
-
const v =
|
|
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
|
|
2698
|
-
import { isAbsolute as isAbsolute3, join as
|
|
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 :
|
|
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 =
|
|
2720
|
-
return
|
|
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 ?
|
|
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(
|
|
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(
|
|
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 || !
|
|
3416
|
+
if (!p || !existsSync6(p)) return null;
|
|
2778
3417
|
try {
|
|
2779
|
-
return JSON.parse(
|
|
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(
|
|
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
|
|
2807
|
-
import { join as
|
|
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(
|
|
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
|
|
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 ?
|
|
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(
|
|
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 ?
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
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 (!
|
|
3830
|
+
if (!existsSync7(log)) return none;
|
|
3192
3831
|
let offset = 0;
|
|
3193
|
-
if (cp &&
|
|
3194
|
-
const n = Number(
|
|
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(
|
|
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
|
|
3352
|
-
import { basename, dirname as dirname3, isAbsolute as isAbsolute4, join as
|
|
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(
|
|
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
|
|
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 =
|
|
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 =
|
|
3551
|
-
if (
|
|
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 =
|
|
4208
|
+
const path = join10(cwd, rel);
|
|
3570
4209
|
let settings = {};
|
|
3571
|
-
if (
|
|
4210
|
+
if (existsSync8(path)) {
|
|
3572
4211
|
let parsed;
|
|
3573
4212
|
try {
|
|
3574
|
-
parsed = JSON.parse(
|
|
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:
|
|
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 = [...
|
|
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
|
|
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 (
|
|
3695
|
-
path =
|
|
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) =>
|
|
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 =
|
|
4344
|
+
path = join10(dirname3(dir), "pre-commit");
|
|
3706
4345
|
note = "husky";
|
|
3707
4346
|
} else {
|
|
3708
|
-
path =
|
|
4347
|
+
path = join10(dir, "pre-commit");
|
|
3709
4348
|
const rel2 = display(cwd, dir);
|
|
3710
|
-
note = rel2 ===
|
|
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 (
|
|
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 =
|
|
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) =>
|
|
4445
|
+
(r) => existsSync8(join10(cwd, r))
|
|
3807
4446
|
);
|
|
3808
|
-
const path =
|
|
3809
|
-
const existing = existingRel ?
|
|
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 =
|
|
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 (!
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
3965
|
-
import { isAbsolute as isAbsolute5, join as
|
|
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 :
|
|
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 =
|
|
4642
|
+
const p = join11(dir, rel);
|
|
4004
4643
|
try {
|
|
4005
|
-
parts.push(`${rel}:` + (
|
|
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
|
|
4017
|
-
import { join as
|
|
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(
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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,
|
|
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 (
|
|
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 =
|
|
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) :
|
|
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 =
|
|
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(
|
|
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 =
|
|
4306
|
-
const globalRc =
|
|
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(
|
|
4317
|
-
const outFile =
|
|
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(
|
|
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(
|
|
4382
|
-
const visDir =
|
|
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(
|
|
4411
|
-
const priDir =
|
|
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
|
|
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(
|
|
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 =
|
|
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 {
|