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