tamperward 2.6.0 → 2.7.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 +1604 -400
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -415,7 +415,41 @@ function makeFinding(rule, policy, input) {
|
|
|
415
415
|
|
|
416
416
|
// src/detectors/command.ts
|
|
417
417
|
function segments(raw) {
|
|
418
|
-
|
|
418
|
+
const out2 = [];
|
|
419
|
+
let single = false;
|
|
420
|
+
let double = false;
|
|
421
|
+
let last = 0;
|
|
422
|
+
for (let i = 0; i < raw.length; i++) {
|
|
423
|
+
const ch = raw[i];
|
|
424
|
+
if (ch === "\\" && !single) {
|
|
425
|
+
i++;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (ch === "'" && !double) {
|
|
429
|
+
single = !single;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (ch === '"' && !single) {
|
|
433
|
+
double = !double;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (single || double) continue;
|
|
437
|
+
const prev = raw[i - 1];
|
|
438
|
+
const next = raw[i + 1];
|
|
439
|
+
let len = 0;
|
|
440
|
+
if (ch === "&" && next === "&") len = 2;
|
|
441
|
+
else if (ch === "|" && next === "|") len = 2;
|
|
442
|
+
else if (ch === "|" && next === "&") len = 2;
|
|
443
|
+
else if (ch === ";" || ch === "\n") len = 1;
|
|
444
|
+
else if (ch === "|" && prev !== ">") len = 1;
|
|
445
|
+
else if (ch === "&" && prev !== ">" && next !== ">") len = 1;
|
|
446
|
+
if (!len) continue;
|
|
447
|
+
out2.push(raw.slice(last, i));
|
|
448
|
+
i += len - 1;
|
|
449
|
+
last = i + 1;
|
|
450
|
+
}
|
|
451
|
+
out2.push(raw.slice(last));
|
|
452
|
+
return out2.map((s) => s.trim()).filter(Boolean);
|
|
419
453
|
}
|
|
420
454
|
function tokens(seg) {
|
|
421
455
|
return seg.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
|
|
@@ -693,6 +727,9 @@ function defaultPolicy(version = 1) {
|
|
|
693
727
|
hooks: [
|
|
694
728
|
".husky/**",
|
|
695
729
|
"**/lefthook.*",
|
|
730
|
+
// lefthook merges `lefthook-local.yml` OVER `lefthook.yml`: a `skip: true`
|
|
731
|
+
// there switches the gate off without touching the file init pointed at.
|
|
732
|
+
"**/lefthook-local.*",
|
|
696
733
|
".tamperward.yml",
|
|
697
734
|
"**/.tamperward.yml",
|
|
698
735
|
".claude/settings.json",
|
|
@@ -1149,6 +1186,50 @@ function trackedFiles(ctx) {
|
|
|
1149
1186
|
return files;
|
|
1150
1187
|
}
|
|
1151
1188
|
var branchCache = /* @__PURE__ */ new Map();
|
|
1189
|
+
var refsCache = /* @__PURE__ */ new Map();
|
|
1190
|
+
function branchRefs(ctx) {
|
|
1191
|
+
if (!ctx?.cwd) return null;
|
|
1192
|
+
const hit = refsCache.get(ctx.cwd);
|
|
1193
|
+
if (hit !== void 0) return hit;
|
|
1194
|
+
let refs = null;
|
|
1195
|
+
try {
|
|
1196
|
+
const out2 = execFileSync2("git", ["for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes"], {
|
|
1197
|
+
cwd: ctx.cwd,
|
|
1198
|
+
encoding: "utf8",
|
|
1199
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1200
|
+
});
|
|
1201
|
+
refs = new Set(out2.split("\n").map((l) => l.trim()).filter(Boolean));
|
|
1202
|
+
} catch {
|
|
1203
|
+
refs = null;
|
|
1204
|
+
}
|
|
1205
|
+
refsCache.set(ctx.cwd, refs);
|
|
1206
|
+
return refs;
|
|
1207
|
+
}
|
|
1208
|
+
function branchExists(name, ctx) {
|
|
1209
|
+
const refs = branchRefs(ctx);
|
|
1210
|
+
if (!refs) return null;
|
|
1211
|
+
if (refs.has(name)) return true;
|
|
1212
|
+
for (const r of refs) if (r.endsWith("/" + name)) return true;
|
|
1213
|
+
return false;
|
|
1214
|
+
}
|
|
1215
|
+
function revIsHead(rev, ctx) {
|
|
1216
|
+
if (!ctx?.cwd || rev.startsWith("-")) return null;
|
|
1217
|
+
const parse3 = (r) => {
|
|
1218
|
+
try {
|
|
1219
|
+
return execFileSync2("git", ["rev-parse", "--verify", "--quiet", "--end-of-options", `${r}^{commit}`], {
|
|
1220
|
+
cwd: ctx.cwd,
|
|
1221
|
+
encoding: "utf8",
|
|
1222
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1223
|
+
}).trim();
|
|
1224
|
+
} catch {
|
|
1225
|
+
return null;
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
const head = parse3("HEAD");
|
|
1229
|
+
const target = parse3(rev);
|
|
1230
|
+
if (!head || !target) return null;
|
|
1231
|
+
return head === target;
|
|
1232
|
+
}
|
|
1152
1233
|
function defaultBranch(ctx) {
|
|
1153
1234
|
if (!ctx?.cwd) return null;
|
|
1154
1235
|
const hit = branchCache.get(ctx.cwd);
|
|
@@ -1168,15 +1249,16 @@ function defaultBranch(ctx) {
|
|
|
1168
1249
|
return name;
|
|
1169
1250
|
}
|
|
1170
1251
|
var dirOf = (t) => t.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
1171
|
-
function containsProtected(token, policy, category, ctx) {
|
|
1252
|
+
function containsProtected(token, policy, category, ctx, except) {
|
|
1172
1253
|
const dir = dirOf(token);
|
|
1173
1254
|
if (!dir || dir === "." || dir === "/" || dir.startsWith("-")) return false;
|
|
1255
|
+
const counts = (p) => isProtected(p, policy, category) && !(except && isProtected(p, policy, except));
|
|
1174
1256
|
const files = trackedFiles(ctx);
|
|
1175
1257
|
if (files) {
|
|
1176
1258
|
const prefix = dir + "/";
|
|
1177
|
-
return files.some((f) => f.startsWith(prefix) &&
|
|
1259
|
+
return files.some((f) => f.startsWith(prefix) && counts(f));
|
|
1178
1260
|
}
|
|
1179
|
-
if (
|
|
1261
|
+
if (counts(dir + "/probe")) return true;
|
|
1180
1262
|
const base = dir.split("/").pop() ?? "";
|
|
1181
1263
|
return category === "tests" && /^(?:test|tests|__tests__|spec|specs)$/i.test(base);
|
|
1182
1264
|
}
|
|
@@ -1279,6 +1361,10 @@ function parseThresholds(src) {
|
|
|
1279
1361
|
if (!ts2.isPropertyAssignment(p)) continue;
|
|
1280
1362
|
const k = keyName(p.name);
|
|
1281
1363
|
if (k === null || isMetric(k)) continue;
|
|
1364
|
+
if (key2 === "thresholds" && k === "100" && p.initializer.kind === ts2.SyntaxKind.TrueKeyword) {
|
|
1365
|
+
res.global = merge(res.global, { values: { branches: 100, functions: 100, lines: 100, statements: 100 }, opaque: false });
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1282
1368
|
const m = metricsOf(p.initializer, resolve5);
|
|
1283
1369
|
if (!m) continue;
|
|
1284
1370
|
if (k === "global") res.global = merge(res.global, m);
|
|
@@ -1372,7 +1458,7 @@ function sectionText(src, key2) {
|
|
|
1372
1458
|
return "";
|
|
1373
1459
|
}
|
|
1374
1460
|
}
|
|
1375
|
-
var BENIGN_EXEMPTION = /node_modules|(?:^|[/!])(?:dist|build|out|coverage|tmp|temp|vendor|test|tests|__tests__|__mocks__|__fixtures__|fixtures?|mocks?|spec|specs|e2e|cypress|playwright|storybook|scripts?|examples?|docs?|bin|types?|typings)(?:\/|$|\*)|\.(?:test|spec|stories|d)\.|\.(?:config|setup)
|
|
1461
|
+
var BENIGN_EXEMPTION = /node_modules|(?:^|[/!])(?:dist|build|out|coverage|tmp|temp|vendor|test|tests|__tests__|__mocks__|__fixtures__|fixtures?|mocks?|spec|specs|e2e|cypress|playwright|storybook|scripts?|examples?|docs?|bin|types?|typings|generated|__generated__|codegen|migrations?)(?:\/|$|\*)|\.(?:test|spec|stories|d|gen|generated|pb|e2e-spec)\.|\.(?:config|setup)\.|(?:^|[/!*])(?:manage|wsgi|asgi|__init__)\.py$|(?:^|[/!*])main\.[jt]sx?$|\.git\b|\.cache|\.next|\.nuxt|\.output|\.idea|\.d\.ts|\.json$|\.md$|\.css$|\.s[ac]ss$/i;
|
|
1376
1462
|
function compareMetrics(scope, before, after, out2) {
|
|
1377
1463
|
if (!before) return;
|
|
1378
1464
|
for (const m of METRICS) {
|
|
@@ -1516,20 +1602,28 @@ var SIMPLE_FILES = [
|
|
|
1516
1602
|
];
|
|
1517
1603
|
var lessThan = (a, b) => a === "auto" ? b !== "auto" : b === "auto" ? false : a < b;
|
|
1518
1604
|
var numOf = (s) => s === "auto" ? "auto" : Number(s);
|
|
1519
|
-
function
|
|
1520
|
-
const out2 = [];
|
|
1605
|
+
function* scopedLines(src) {
|
|
1521
1606
|
const stack = [];
|
|
1522
1607
|
for (const line of src.split("\n")) {
|
|
1523
|
-
|
|
1524
|
-
if (
|
|
1525
|
-
const
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
while (stack.length && stack[stack.length - 1].ind >= ind) stack.pop();
|
|
1529
|
-
stack.push({ ind, key: m[2] });
|
|
1530
|
-
}
|
|
1531
|
-
scope = [...stack.slice(0, -1)].reverse().find((s) => /^(?:project|patch|changes)$/.test(s.key))?.key ?? "";
|
|
1608
|
+
const m = line.match(/^(\s*)([\w-]+):/);
|
|
1609
|
+
if (m) {
|
|
1610
|
+
const ind = m[1].length;
|
|
1611
|
+
while (stack.length && stack[stack.length - 1].ind >= ind) stack.pop();
|
|
1612
|
+
stack.push({ ind, key: m[2] });
|
|
1532
1613
|
}
|
|
1614
|
+
const scope = [...stack.slice(0, -1)].reverse().find((s) => /^(?:project|patch|changes)$/.test(s.key))?.key ?? "";
|
|
1615
|
+
yield [line, scope];
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
function scopedKeys(src, key2) {
|
|
1619
|
+
const out2 = [];
|
|
1620
|
+
for (const [line, scope] of scopedLines(src)) if (key2.test(line)) out2.push(scope);
|
|
1621
|
+
return out2;
|
|
1622
|
+
}
|
|
1623
|
+
function numbersOf(src, key2, scoped = false) {
|
|
1624
|
+
const out2 = [];
|
|
1625
|
+
for (const [line, inScope] of scopedLines(src)) {
|
|
1626
|
+
const scope = scoped ? inScope : "";
|
|
1533
1627
|
if (key2.global) {
|
|
1534
1628
|
for (const m of line.matchAll(key2)) out2.push({ v: numOf(m[1]), scope });
|
|
1535
1629
|
} else {
|
|
@@ -1605,9 +1699,12 @@ function simpleNarrowings(path, before, after) {
|
|
|
1605
1699
|
for (const e of listEntries(after, key2)) if (!had.has(e) && !BENIGN_EXEMPTION.test(e)) out2.push(`${what} ${JSON.stringify(e)}`);
|
|
1606
1700
|
}
|
|
1607
1701
|
if (CODECOV.test(path)) {
|
|
1608
|
-
const
|
|
1609
|
-
const had =
|
|
1610
|
-
for (const s of
|
|
1702
|
+
const off2 = (s) => new Set(s.split("\n").map((l) => l.match(/^\s*(project|patch):\s*off\b/)?.[1]).filter((x) => !!x));
|
|
1703
|
+
const had = off2(before);
|
|
1704
|
+
for (const s of off2(after)) if (!had.has(s)) out2.push(`codecov status.${s} switched off`);
|
|
1705
|
+
const informational = (s) => new Set(scopedKeys(s, /^\s*informational:\s*true\b/).filter((scope) => scope !== "patch" && scope !== "changes"));
|
|
1706
|
+
const was = informational(before);
|
|
1707
|
+
for (const scope of informational(after)) if (!was.has(scope)) out2.push(`codecov ${scope || "project"} status informational: true added \u2014 the status can no longer fail`);
|
|
1611
1708
|
}
|
|
1612
1709
|
return out2;
|
|
1613
1710
|
}
|
|
@@ -1651,13 +1748,14 @@ var SWITCHES = [
|
|
|
1651
1748
|
re: /"?check-coverage"?\s*[:=]\s*false\b/,
|
|
1652
1749
|
message: "check-coverage: false \u2014 nyc will no longer fail on the thresholds.",
|
|
1653
1750
|
remediation: "Keep check-coverage on; raise real coverage instead."
|
|
1654
|
-
},
|
|
1655
|
-
{
|
|
1656
|
-
re: /^\s*informational:\s*true\b/,
|
|
1657
|
-
message: "informational: true added \u2014 the codecov status can no longer fail.",
|
|
1658
|
-
remediation: "Remove informational; a status that cannot fail is not a gate."
|
|
1659
1751
|
}
|
|
1660
1752
|
];
|
|
1753
|
+
function dirHasSpec(configPath, policy, ctx) {
|
|
1754
|
+
const files = trackedFiles(ctx);
|
|
1755
|
+
if (!files) return null;
|
|
1756
|
+
const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
|
|
1757
|
+
return files.some((f) => f.startsWith(root) && isProtected(f, policy, "tests"));
|
|
1758
|
+
}
|
|
1661
1759
|
var isJsConfig = (path) => /\.(?:[cm]?[jt]sx?|json)$/.test(path);
|
|
1662
1760
|
function gateMovedElsewhere(c, changes, policy) {
|
|
1663
1761
|
const before = c.before ?? "";
|
|
@@ -1750,6 +1848,7 @@ var coverageLowering = {
|
|
|
1750
1848
|
for (const s of SWITCHES) {
|
|
1751
1849
|
if (!s.re.test(l.content)) continue;
|
|
1752
1850
|
if (removed.some((r) => s.re.test(r.content))) continue;
|
|
1851
|
+
if (/passWithNoTests/.test(s.re.source) && dirHasSpec(c.path, policy, ctx) === false) continue;
|
|
1753
1852
|
out2.push(
|
|
1754
1853
|
makeFinding(RULE4, policy, {
|
|
1755
1854
|
file: c.path,
|
|
@@ -1801,7 +1900,7 @@ var RULE5 = "ci-tampering";
|
|
|
1801
1900
|
var USES = /^\s*-\s*uses:/;
|
|
1802
1901
|
var CHECK = /\b(test|tests|lint|typecheck|type-check|tsc|eslint|jest|vitest|playwright|coverage|tamperward)\b/i;
|
|
1803
1902
|
var YAML_KEY = /^\s*-?\s*[A-Za-z_][\w-]*:\s*(?:$|\S)/;
|
|
1804
|
-
var INVOKES_TOOL = /(?:^\s*|[;&|`]\s*|\$\(\s*|\b(?:npx|yarn|pnpm|bunx?)\s+)(?:jest|vitest|eslint|tsc|playwright|pytest|tamperward)\b/;
|
|
1903
|
+
var INVOKES_TOOL = /(?:^\s*|[;&|`]\s*|\$\(\s*|\b(?:npx|yarn|pnpm|bunx?)\s+)(?:jest|vitest|eslint|tsc|playwright|pytest|tamperward|mocha|ava|oxlint|tsgo|mypy|golangci-lint|node\s+--test|biome\s+(?:ci|check|lint)|deno\s+(?:test|lint|check)|ruff\s+check)\b/;
|
|
1805
1904
|
var INVOKES_SCRIPT = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|tests|lint|typecheck|type-check|coverage)\b|\b(?:make|cargo|go)\s+test\b|\bgradle\w*\s+(?:test|check)\b/;
|
|
1806
1905
|
function isActiveWorkflow(path) {
|
|
1807
1906
|
return /(^|\/)\.github\/workflows\/[^/]+\.ya?ml$/.test(path);
|
|
@@ -1810,8 +1909,19 @@ var invokesCheck = (line) => INVOKES_TOOL.test(line) || INVOKES_SCRIPT.test(line
|
|
|
1810
1909
|
function commandCore(line) {
|
|
1811
1910
|
return line.trim().replace(/^-\s*/, "").replace(/^(?:run|uses):\s*/, "").replace(/\s+/g, " ").trim();
|
|
1812
1911
|
}
|
|
1912
|
+
function foldExpressions(s) {
|
|
1913
|
+
return s.replace(/\$\{\{([^}]*)\}\}/g, (_m, e) => {
|
|
1914
|
+
const v = foldConst(e.trim());
|
|
1915
|
+
if (v !== void 0 && v !== TRUTHY) return String(v);
|
|
1916
|
+
return /\b(?:matrix|strategy|inputs)\./.test(e) ? MATRIX_TOKEN : EXPR_TOKEN;
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
var EXPR_TOKEN = "__expr__";
|
|
1920
|
+
var MATRIX_TOKEN = "__matrix__";
|
|
1921
|
+
var MATRIX_FLAG = new RegExp(`(?:^|\\s)-{1,2}[\\w-]+(?:=|\\s+)${MATRIX_TOKEN}(?:/\\S*)?(?=\\s|$)`, "g");
|
|
1813
1922
|
function canonical(core) {
|
|
1814
|
-
let s = core.replace(/^(['"])(.*)\1$/, "$2").trim();
|
|
1923
|
+
let s = foldExpressions(core.replace(/^(['"])(.*)\1$/, "$2").trim()).replace(MATRIX_FLAG, " ").replace(/\s+/g, " ").trim();
|
|
1924
|
+
s = s.replace(/^(?:cd|pushd)\s+\S+\s*&&\s*/, "");
|
|
1815
1925
|
s = s.replace(/^(?:npx|pnpm\s+(?:exec|dlx)|yarn\s+(?:exec|dlx)|bunx|bun\s+x)\s+/, "exec ");
|
|
1816
1926
|
s = s.replace(/^(?:npm|pnpm|yarn|bun)\s+(?:run(?:-script)?\s+)?/, "exec ");
|
|
1817
1927
|
s = s.replace(/^exec\s+(?:t|tst)(?=\s|$)/, "exec test");
|
|
@@ -1841,8 +1951,8 @@ function checkKind(core) {
|
|
|
1841
1951
|
if (!invokesCheck(core)) return null;
|
|
1842
1952
|
const s = canonical(core);
|
|
1843
1953
|
if (/\btamperward\b/.test(s)) return "gate";
|
|
1844
|
-
if (/\b(?:lint|eslint)\b/i.test(s)) return "lint";
|
|
1845
|
-
if (/\b(?:typecheck|type-check|tsc)\b/i.test(s)) return "types";
|
|
1954
|
+
if (/\b(?:lint|eslint|biome|oxlint|golangci-lint|ruff)\b/i.test(s)) return "lint";
|
|
1955
|
+
if (/\b(?:typecheck|type-check|tsc|tsgo|mypy)\b|\bdeno\s+check\b/i.test(s)) return "types";
|
|
1846
1956
|
return "test";
|
|
1847
1957
|
}
|
|
1848
1958
|
function survives(removedCore, isUses, afterCores, addedCores) {
|
|
@@ -1851,13 +1961,15 @@ function survives(removedCore, isUses, afterCores, addedCores) {
|
|
|
1851
1961
|
return afterCores.some((a) => usesRef(a) === ref) ? { state: "kept" } : { state: "gone" };
|
|
1852
1962
|
}
|
|
1853
1963
|
const r = canonical(removedCore);
|
|
1964
|
+
const kind = checkKind(removedCore);
|
|
1965
|
+
const narrowed = (args) => kind === "test" && narrowedByPositional(args);
|
|
1854
1966
|
let neutralised;
|
|
1855
1967
|
for (const raw of afterCores) {
|
|
1856
1968
|
const a = canonical(raw);
|
|
1857
1969
|
if (a === r) return { state: "kept" };
|
|
1858
1970
|
if (a.startsWith(r + " ")) {
|
|
1859
1971
|
const rest = a.slice(r.length);
|
|
1860
|
-
if (NEUTRALISING_SUFFIX.test(rest) ||
|
|
1972
|
+
if (NEUTRALISING_SUFFIX.test(rest) || narrowed(rest)) neutralised ??= raw;
|
|
1861
1973
|
else return { state: "kept" };
|
|
1862
1974
|
} else if (r.startsWith(a + " ") && invokesCheck(a)) {
|
|
1863
1975
|
return { state: "kept" };
|
|
@@ -1866,7 +1978,6 @@ function survives(removedCore, isUses, afterCores, addedCores) {
|
|
|
1866
1978
|
if (w && (canonical(w[1]) === r || canonical(w[1]).startsWith(r + " "))) neutralised ??= raw;
|
|
1867
1979
|
}
|
|
1868
1980
|
}
|
|
1869
|
-
const kind = checkKind(removedCore);
|
|
1870
1981
|
let respelled = false;
|
|
1871
1982
|
for (const raw of addedCores) {
|
|
1872
1983
|
if (!kind || checkKind(raw) !== kind) continue;
|
|
@@ -1876,7 +1987,7 @@ function survives(removedCore, isUses, afterCores, addedCores) {
|
|
|
1876
1987
|
continue;
|
|
1877
1988
|
}
|
|
1878
1989
|
const args = a.slice(a.indexOf(" ") + 1 || a.length);
|
|
1879
|
-
if (a.includes(" ") && (NEUTRALISING_SUFFIX.test(" " + args) ||
|
|
1990
|
+
if (a.includes(" ") && (NEUTRALISING_SUFFIX.test(" " + args) || narrowed(args))) neutralised ??= raw;
|
|
1880
1991
|
else respelled = true;
|
|
1881
1992
|
}
|
|
1882
1993
|
if (respelled) return { state: "kept" };
|
|
@@ -2048,6 +2159,7 @@ function isTruthy(raw) {
|
|
|
2048
2159
|
const f = foldConst(m[1]);
|
|
2049
2160
|
return f !== void 0 && truthy(f);
|
|
2050
2161
|
}
|
|
2162
|
+
var SHELL_ERREXIT = /(?:^|\s)-[a-zA-Z]*e[a-zA-Z]*(?:\s|$)|-o\s+errexit\b/;
|
|
2051
2163
|
var indentOf = (l) => l.match(/^\s*/)[0].length;
|
|
2052
2164
|
var STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
|
|
2053
2165
|
function stepOf(lines, i) {
|
|
@@ -2073,8 +2185,10 @@ function jobOf(lines, i) {
|
|
|
2073
2185
|
while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > hi)) e++;
|
|
2074
2186
|
return [h, e];
|
|
2075
2187
|
}
|
|
2076
|
-
var
|
|
2077
|
-
var
|
|
2188
|
+
var REPORTER_ACTION = /^\s*-?\s*uses:\s*\S*?(?:[\w-]*-reporter|[\w-]*-results?[\w-]*|upload-[\w-]*|codecov[\w-]*|coveralls[\w-]*)(?:@|\s|$)/i;
|
|
2189
|
+
var usesCheck = (l) => USES.test(l) && CHECK.test(l) && !REPORTER_ACTION.test(l);
|
|
2190
|
+
var stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || usesCheck(l));
|
|
2191
|
+
var jobHasCheck = (lines, range) => lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || usesCheck(l) || /^\s*uses:\s*\S/.test(l));
|
|
2078
2192
|
function runBlocks(lines) {
|
|
2079
2193
|
const out2 = [];
|
|
2080
2194
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -2121,8 +2235,8 @@ function parseTriggers(src) {
|
|
|
2121
2235
|
const head = lines[i].replace(/^(?:on|"on"|'on'|true):\s*/, "");
|
|
2122
2236
|
const inline = uncommented(head);
|
|
2123
2237
|
if (inline) {
|
|
2124
|
-
const
|
|
2125
|
-
for (const e of
|
|
2238
|
+
const list2 = flowList(inline);
|
|
2239
|
+
for (const e of list2 ?? [inline]) t.events.add(e);
|
|
2126
2240
|
return t;
|
|
2127
2241
|
}
|
|
2128
2242
|
let event = null;
|
|
@@ -2153,8 +2267,8 @@ function parseTriggers(src) {
|
|
|
2153
2267
|
if (event && key2) {
|
|
2154
2268
|
listKey = key2;
|
|
2155
2269
|
listInd = ind;
|
|
2156
|
-
const
|
|
2157
|
-
t[listKey].set(event,
|
|
2270
|
+
const list2 = flowList(kv[2]);
|
|
2271
|
+
t[listKey].set(event, list2 ?? []);
|
|
2158
2272
|
} else listKey = null;
|
|
2159
2273
|
}
|
|
2160
2274
|
return t;
|
|
@@ -2195,31 +2309,32 @@ function triggerNarrowings(before, after, opts) {
|
|
|
2195
2309
|
for (const e of CODE_EVENTS) {
|
|
2196
2310
|
if (before.events.has(e) && !after.events.has(e)) out2.push(`the ${e} trigger was removed \u2014 the workflow no longer runs on it`);
|
|
2197
2311
|
}
|
|
2198
|
-
for (const [e,
|
|
2312
|
+
for (const [e, list2] of after.pathsIgnore) {
|
|
2199
2313
|
const had = before.pathsIgnore.get(e) ?? [];
|
|
2200
|
-
if (
|
|
2314
|
+
if (list2.some((g) => ALL(g) && !had.includes(g))) out2.push(`on.${e}.paths-ignore now ignores every path \u2014 the workflow never runs on ${e}`);
|
|
2201
2315
|
}
|
|
2202
|
-
for (const [e,
|
|
2316
|
+
for (const [e, list2] of after.paths) {
|
|
2203
2317
|
if (!CODE_EVENTS.includes(e) || !after.events.has(e)) continue;
|
|
2204
2318
|
const had = before.paths.get(e);
|
|
2205
2319
|
if (had && !pathsMatchSource(had, opts.sources)) continue;
|
|
2206
|
-
if (!pathsMatchSource(
|
|
2320
|
+
if (!pathsMatchSource(list2, opts.sources)) out2.push(`on.${e}.paths matches no source file (now [${list2.join(", ")}]) \u2014 the workflow never runs on code`);
|
|
2207
2321
|
}
|
|
2208
|
-
for (const [e,
|
|
2322
|
+
for (const [e, list2] of after.types) {
|
|
2209
2323
|
if (!/^pull_request(?:_target)?$/.test(e) || !after.events.has(e)) continue;
|
|
2210
2324
|
const had = before.types.get(e);
|
|
2211
2325
|
if (had && !had.some((t) => PR_CODE_TYPES.test(t))) continue;
|
|
2212
|
-
if (!
|
|
2326
|
+
if (!list2.some((t) => PR_CODE_TYPES.test(t))) out2.push(`on.${e}.types no longer includes opened/synchronize (now [${list2.join(", ")}]) \u2014 the workflow does not run on the pull request's code`);
|
|
2213
2327
|
}
|
|
2214
|
-
for (const [e,
|
|
2328
|
+
for (const [e, list2] of after.branchesIgnore) {
|
|
2215
2329
|
const had = before.branchesIgnore.get(e) ?? [];
|
|
2216
|
-
if (
|
|
2330
|
+
if (list2.some((g) => (ALL(g) || isDefault(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
|
|
2217
2331
|
}
|
|
2218
2332
|
for (const [e, was] of before.branches) {
|
|
2219
2333
|
const now = after.branches.get(e);
|
|
2220
2334
|
if (!now || !after.events.has(e)) continue;
|
|
2221
2335
|
if (now.some(ALL)) continue;
|
|
2222
|
-
const
|
|
2336
|
+
const renamed = (b) => DEFAULT_BRANCH.test(b) && opts.hasBranch(b) !== false;
|
|
2337
|
+
const lostDefault = namesDefault(was) && !namesDefault(now) && !now.some(renamed);
|
|
2223
2338
|
const replaced = !namesDefault(was) && !namesDefault(now) && was.length > 0 && !was.some((b) => now.includes(b));
|
|
2224
2339
|
if (lostDefault || replaced) out2.push(`on.${e}.branches no longer names ${was.filter((b) => !now.includes(b)).join(", ")} (now [${now.join(", ")}])`);
|
|
2225
2340
|
}
|
|
@@ -2305,6 +2420,7 @@ var ciTampering = {
|
|
|
2305
2420
|
}
|
|
2306
2421
|
for (const l of addedLines(c)) {
|
|
2307
2422
|
if (!/^\s*-?\s*shell:\s*.*\{0\}/.test(l.content) || l.newLine == null) continue;
|
|
2423
|
+
if (SHELL_ERREXIT.test(uncommented(l.content.replace(/^\s*-?\s*shell:\s*/, "")))) continue;
|
|
2308
2424
|
const step = stepOf(afterLines, l.newLine - 1);
|
|
2309
2425
|
if (step && !stepHasCheck(afterLines, step)) continue;
|
|
2310
2426
|
out2.push(
|
|
@@ -2318,7 +2434,7 @@ var ciTampering = {
|
|
|
2318
2434
|
);
|
|
2319
2435
|
}
|
|
2320
2436
|
if (c.before != null) {
|
|
2321
|
-
triggerOpts ??= { defaultBranch: defaultBranch(ctx), sources: sourceProbes(ctx) };
|
|
2437
|
+
triggerOpts ??= { defaultBranch: defaultBranch(ctx), sources: sourceProbes(ctx), hasBranch: (b) => branchExists(b, ctx) };
|
|
2322
2438
|
for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after), triggerOpts)) {
|
|
2323
2439
|
out2.push(
|
|
2324
2440
|
makeFinding(RULE5, policy, {
|
|
@@ -2343,7 +2459,7 @@ var ciTampering = {
|
|
|
2343
2459
|
}
|
|
2344
2460
|
for (const l of removedLines(c)) {
|
|
2345
2461
|
if (/^\s*#/.test(l.content)) continue;
|
|
2346
|
-
const isUsesLine =
|
|
2462
|
+
const isUsesLine = usesCheck(l.content);
|
|
2347
2463
|
const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? /^\s*-?\s*run:/.test(l.content) : true) && invokesCheck(l.content);
|
|
2348
2464
|
if (!isUsesLine && !isCheckCommand) continue;
|
|
2349
2465
|
const s = survives(commandCore(l.content), isUsesLine, afterCores, addedCores);
|
|
@@ -2374,12 +2490,59 @@ var ciTampering = {
|
|
|
2374
2490
|
};
|
|
2375
2491
|
|
|
2376
2492
|
// src/detectors/hook-tampering.ts
|
|
2377
|
-
import { existsSync, readFileSync as
|
|
2378
|
-
import { isAbsolute, join as
|
|
2493
|
+
import { existsSync, readFileSync as readFileSync3 } from "node:fs";
|
|
2494
|
+
import { isAbsolute, join as join3, relative } from "node:path";
|
|
2495
|
+
|
|
2496
|
+
// src/wiring.ts
|
|
2497
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
2498
|
+
import { dirname, join as join2 } from "node:path";
|
|
2499
|
+
import { fileURLToPath } from "node:url";
|
|
2500
|
+
function shippedVersion() {
|
|
2501
|
+
try {
|
|
2502
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
2503
|
+
for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
|
|
2504
|
+
try {
|
|
2505
|
+
const pkg = JSON.parse(readFileSync2(join2(here, rel), "utf8"));
|
|
2506
|
+
if (pkg.name === "tamperward" && pkg.version) return pkg.version;
|
|
2507
|
+
} catch {
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
} catch {
|
|
2511
|
+
}
|
|
2512
|
+
return "latest";
|
|
2513
|
+
}
|
|
2514
|
+
var TW_VERSION = shippedVersion();
|
|
2515
|
+
var PRE_TOOLS = ["Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"];
|
|
2516
|
+
var PRE_MATCHER = PRE_TOOLS.join("|");
|
|
2517
|
+
var MARKER = "# tamperward: block agent shortcuts before they land";
|
|
2518
|
+
var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
|
|
2519
|
+
var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
|
|
2520
|
+
var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
|
|
2521
|
+
var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
|
|
2522
|
+
var PLAIN_SEMVER = /^\d+\.\d+\.\d+$/;
|
|
2523
|
+
function compareVersions(a, b) {
|
|
2524
|
+
if (!PLAIN_SEMVER.test(a) || !PLAIN_SEMVER.test(b)) return null;
|
|
2525
|
+
const pa = a.split(".").map(Number);
|
|
2526
|
+
const pb = b.split(".").map(Number);
|
|
2527
|
+
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
|
|
2528
|
+
return 0;
|
|
2529
|
+
}
|
|
2530
|
+
function pinNotBelow(now, floor) {
|
|
2531
|
+
if (!now) return false;
|
|
2532
|
+
const c = compareVersions(now, floor);
|
|
2533
|
+
return c !== null && c >= 0;
|
|
2534
|
+
}
|
|
2535
|
+
var INIT_SCRIPT = /^#!\/bin\/sh\n# tamperward: block agent shortcuts before they land\nnpx --yes tamperward(?:@(\S+))? check --staged\n?$/;
|
|
2536
|
+
function initScriptPin(src) {
|
|
2537
|
+
const m = src.match(INIT_SCRIPT);
|
|
2538
|
+
return m ? m[1] ?? "" : null;
|
|
2539
|
+
}
|
|
2540
|
+
var CLAUDE_SETTINGS = /(?:^|\/)(?:\.claude\/settings(?:\.local)?\.json|managed-settings\.json)$/;
|
|
2541
|
+
var isClaudeSettings = (path) => CLAUDE_SETTINGS.test(path);
|
|
2379
2542
|
|
|
2380
2543
|
// src/detectors/policy-diff.ts
|
|
2381
2544
|
import { parse } from "yaml";
|
|
2382
|
-
var globs2 = (
|
|
2545
|
+
var globs2 = (list2) => Array.isArray(list2) ? list2.filter((g) => typeof g === "string").map(normalizeGlob) : [];
|
|
2383
2546
|
function safeParse(src) {
|
|
2384
2547
|
try {
|
|
2385
2548
|
const v = parse(src);
|
|
@@ -2398,7 +2561,7 @@ function effective(raw) {
|
|
|
2398
2561
|
const over = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg : {};
|
|
2399
2562
|
rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude: globs2(over.exclude) } : {} };
|
|
2400
2563
|
}
|
|
2401
|
-
const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat,
|
|
2564
|
+
const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list2]) => [cat, globs2(list2)])) : void 0;
|
|
2402
2565
|
return {
|
|
2403
2566
|
version,
|
|
2404
2567
|
rules,
|
|
@@ -2519,7 +2682,7 @@ var RUNNER = /^(?:npx|pnpm|yarn|bunx|bun|npm)\s+(?:(?:exec|dlx|x|run)\s+)?(?:(?:
|
|
|
2519
2682
|
var TOOL = /^(tamperward|jest|vitest|eslint|tsc|lint-staged|lefthook|husky|pytest|make|cargo|go|pre-commit|mypy|ruff|rubocop|prettier|gradlew?)\b/;
|
|
2520
2683
|
var SCRIPT = /^(test|tests|lint|typecheck|type-check|coverage|check)\b/;
|
|
2521
2684
|
var SUBCOMMAND_TOOLS = /* @__PURE__ */ new Set(["tamperward", "make", "cargo", "go", "lefthook", "husky", "pre-commit"]);
|
|
2522
|
-
var PREFIX = /^(?:(?:then|do|else|\{|\(
|
|
2685
|
+
var PREFIX = /^(?:(?:then|do|else|\{|\(|exec|command|time|nice|env|sudo|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"(?=\s|$)|'[^']*'(?=\s|$)|[^\s"']\S*|(?=\s)))\s+)*/;
|
|
2523
2686
|
function checkIdentity(segment) {
|
|
2524
2687
|
const core = segment.trim().replace(PREFIX, "").replace(/\s+/g, " ");
|
|
2525
2688
|
const viaRunner = RUNNER.test(core);
|
|
@@ -2530,20 +2693,49 @@ function checkIdentity(segment) {
|
|
|
2530
2693
|
if (tool === "tamperward") {
|
|
2531
2694
|
const sub = toks[1];
|
|
2532
2695
|
if (!sub || sub.startsWith("-")) return null;
|
|
2533
|
-
|
|
2696
|
+
const cwd = rest.match(/\s--cwd(?:=|\s+)(\S+)/)?.[1];
|
|
2697
|
+
return cwd ? `tamperward ${sub} --cwd ${cwd}` : `tamperward ${sub}`;
|
|
2534
2698
|
}
|
|
2535
2699
|
if (SUBCOMMAND_TOOLS.has(tool) && toks[1] && !toks[1].startsWith("-")) return `${tool} ${toks[1]}`;
|
|
2536
2700
|
return tool;
|
|
2537
2701
|
}
|
|
2702
|
+
function pinOfSegment(segment) {
|
|
2703
|
+
const m = segment.trim().replace(PREFIX, "").replace(RUNNER, "").match(/^tamperward(?:@(\S+))?\b/);
|
|
2704
|
+
return m ? m[1] ?? "" : null;
|
|
2705
|
+
}
|
|
2538
2706
|
function split(stmt) {
|
|
2539
2707
|
const segs = [];
|
|
2540
2708
|
const ops = [];
|
|
2541
|
-
|
|
2709
|
+
let depth = 0;
|
|
2710
|
+
let single = false;
|
|
2711
|
+
let double = false;
|
|
2542
2712
|
let last = 0;
|
|
2543
|
-
for (
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2713
|
+
for (let i = 0; i < stmt.length; i++) {
|
|
2714
|
+
const ch = stmt[i];
|
|
2715
|
+
if (ch === "'" && !double) {
|
|
2716
|
+
single = !single;
|
|
2717
|
+
continue;
|
|
2718
|
+
}
|
|
2719
|
+
if (ch === '"' && !single) {
|
|
2720
|
+
double = !double;
|
|
2721
|
+
continue;
|
|
2722
|
+
}
|
|
2723
|
+
if (single || double) continue;
|
|
2724
|
+
if (ch === "(" || ch === "{" && /(?:^|\s)$/.test(stmt.slice(0, i)) && /^(?:\s|$)/.test(stmt.slice(i + 1))) {
|
|
2725
|
+
depth++;
|
|
2726
|
+
continue;
|
|
2727
|
+
}
|
|
2728
|
+
if (ch === ")" || ch === "}" && depth > 0 && /(?:^|[\s;])$/.test(stmt.slice(0, i))) {
|
|
2729
|
+
depth = Math.max(0, depth - 1);
|
|
2730
|
+
continue;
|
|
2731
|
+
}
|
|
2732
|
+
if (depth > 0) continue;
|
|
2733
|
+
const op = stmt.startsWith("&&", i) ? "&&" : stmt.startsWith("||", i) ? "||" : ch === ";" ? ";" : ch === "|" ? "|" : null;
|
|
2734
|
+
if (!op) continue;
|
|
2735
|
+
segs.push(stmt.slice(last, i));
|
|
2736
|
+
ops.push(op);
|
|
2737
|
+
i += op.length - 1;
|
|
2738
|
+
last = i + 1;
|
|
2547
2739
|
}
|
|
2548
2740
|
segs.push(stmt.slice(last));
|
|
2549
2741
|
return { segs, ops };
|
|
@@ -2592,106 +2784,602 @@ function alwaysFalse(cond) {
|
|
|
2592
2784
|
if (/^(?:""|'')$/.test(inner)) return true;
|
|
2593
2785
|
return false;
|
|
2594
2786
|
}
|
|
2595
|
-
var alwaysTrue = (cond) =>
|
|
2787
|
+
var alwaysTrue = (cond) => {
|
|
2788
|
+
const c = cond.trim().replace(/;$/, "").trim();
|
|
2789
|
+
if (/^(?:true|:|!\s*false|\(\s*exit\s+0*\s*\))$/.test(c)) return true;
|
|
2790
|
+
const m = c.match(/^(?:\[\[\s+(.+?)\s+\]\]|\[\s+(.+?)\s+\]|test\s+(.+))$/);
|
|
2791
|
+
if (!m) return false;
|
|
2792
|
+
const inner = (m[1] ?? m[2] ?? m[3]).trim();
|
|
2793
|
+
if (inner.includes("$")) return false;
|
|
2794
|
+
const parts = inner.match(/^(\S+)\s+(-eq|-ne|-lt|-le|-gt|-ge|=|==|!=)\s+(\S+)$/);
|
|
2795
|
+
if (parts) {
|
|
2796
|
+
const ints = /^-?\d+$/.test(unq(parts[1])) && /^-?\d+$/.test(unq(parts[3]));
|
|
2797
|
+
if (parts[2].startsWith("-") && !ints) return false;
|
|
2798
|
+
return !alwaysFalse(c);
|
|
2799
|
+
}
|
|
2800
|
+
if (/^(?:-n|-z)\s+\S+$/.test(inner)) return !alwaysFalse(c);
|
|
2801
|
+
if (/^\S+$/.test(inner)) return unq(inner) !== "";
|
|
2802
|
+
return false;
|
|
2803
|
+
};
|
|
2596
2804
|
var EXIT_ZERO = /^(?:exit|return)(?:\s+0+)?$/;
|
|
2597
2805
|
var EXIT_ANY = /^(?:exit|return)(?:\s+\S+)?$/;
|
|
2598
|
-
var FAILS_LOUDLY = /\b(?:exit|return)\s+(?:[1-9]\d*|"?\$\?"?|"?\$\{?[A-Za-z_]\w*\}?"?)(?=[\s;})]|$)|\bfalse\b|\bkill\b|\bexit\s*$|\breturn\s*$/;
|
|
2599
2806
|
function insertsPassingExit(line) {
|
|
2600
2807
|
if (isComment(line)) return false;
|
|
2601
|
-
return split(stripComment(line)).segs.some((s) => EXIT_ZERO.test(s.trim().replace(PREFIX, "")));
|
|
2808
|
+
return split(stripComment(line)).segs.some((s) => EXIT_ZERO.test(s.trim().replace(/^!\s+/, "").replace(PREFIX, "")));
|
|
2602
2809
|
}
|
|
2603
2810
|
function insertsDeadGuard(line) {
|
|
2604
2811
|
if (isComment(line)) return false;
|
|
2605
2812
|
const m = stripComment(line).trim().match(/^(?:if|elif)\s+(.+?)\s*(?:;\s*then)?\s*$/);
|
|
2606
2813
|
return !!m && alwaysFalse(m[1]);
|
|
2607
2814
|
}
|
|
2608
|
-
function
|
|
2815
|
+
function joinContinued(lines) {
|
|
2609
2816
|
const out2 = [];
|
|
2610
|
-
|
|
2611
|
-
const
|
|
2817
|
+
let cur = null;
|
|
2818
|
+
for (const l of lines) {
|
|
2819
|
+
cur = cur === null ? l : cur + " " + l.trimStart();
|
|
2820
|
+
const m = cur.match(/(\\+)$/);
|
|
2821
|
+
if (m && m[1].length % 2 === 1 && !isComment(cur)) {
|
|
2822
|
+
cur = cur.slice(0, -1);
|
|
2823
|
+
continue;
|
|
2824
|
+
}
|
|
2825
|
+
out2.push(cur);
|
|
2826
|
+
cur = null;
|
|
2827
|
+
}
|
|
2828
|
+
if (cur !== null) out2.push(cur);
|
|
2829
|
+
return out2;
|
|
2830
|
+
}
|
|
2831
|
+
function stripHeredocs(lines) {
|
|
2832
|
+
const out2 = [];
|
|
2833
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2834
|
+
const l = lines[i];
|
|
2835
|
+
out2.push(l);
|
|
2836
|
+
if (isComment(l)) continue;
|
|
2837
|
+
const m = stripComment(l).match(/<<(-?)\s*(?:'([^']+)'|"([^"]+)"|\\?([A-Za-z_]\w*))/);
|
|
2838
|
+
if (!m) continue;
|
|
2839
|
+
const word = m[2] ?? m[3] ?? m[4];
|
|
2840
|
+
const dash = m[1] === "-";
|
|
2841
|
+
for (i++; i < lines.length; i++) {
|
|
2842
|
+
const t = dash ? lines[i].replace(/^\t+/, "") : lines[i];
|
|
2843
|
+
if (t === word) break;
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
return out2;
|
|
2847
|
+
}
|
|
2848
|
+
function explodeKeywords(line) {
|
|
2849
|
+
if (isComment(line)) return [line];
|
|
2850
|
+
let s = stripComment(line);
|
|
2851
|
+
s = s.replace(/;;/g, "\n;;");
|
|
2852
|
+
s = s.replace(/;\s*(then|do|fi|done|esac|else|elif)\b/g, "\n$1");
|
|
2853
|
+
s = s.replace(/^(\s*case\s+\S+\s+in)\s+(?=\S)/, "$1\n");
|
|
2854
|
+
s = s.replace(/^(\s*)(then|do|else)\s+(?=\S)/, "$1$2\n");
|
|
2855
|
+
s = s.replace(/\n(then|do|else)\s+(?=\S)/g, "\n$1\n");
|
|
2856
|
+
return s.split("\n");
|
|
2857
|
+
}
|
|
2858
|
+
function closingBrace(line, depth) {
|
|
2859
|
+
let single = false;
|
|
2860
|
+
let double = false;
|
|
2861
|
+
let expansion = 0;
|
|
2862
|
+
for (let i = 0; i < line.length; i++) {
|
|
2863
|
+
const ch = line[i];
|
|
2864
|
+
if (ch === "'" && !double) single = !single;
|
|
2865
|
+
else if (ch === '"' && !single) double = !double;
|
|
2866
|
+
else if (single) continue;
|
|
2867
|
+
else if (ch === "$" && line[i + 1] === "{") {
|
|
2868
|
+
expansion++;
|
|
2869
|
+
i++;
|
|
2870
|
+
} else if (ch === "}" && expansion > 0) expansion--;
|
|
2871
|
+
else if (ch === "{" && !double) depth++;
|
|
2872
|
+
else if (ch === "}" && !double && --depth === 0) return i;
|
|
2873
|
+
}
|
|
2874
|
+
return -1;
|
|
2875
|
+
}
|
|
2876
|
+
function braceDelta(line) {
|
|
2877
|
+
let d = 0;
|
|
2878
|
+
let single = false;
|
|
2879
|
+
let double = false;
|
|
2880
|
+
let expansion = 0;
|
|
2881
|
+
for (let i = 0; i < line.length; i++) {
|
|
2882
|
+
const ch = line[i];
|
|
2883
|
+
if (ch === "'" && !double) single = !single;
|
|
2884
|
+
else if (ch === '"' && !single) double = !double;
|
|
2885
|
+
else if (single) continue;
|
|
2886
|
+
else if (ch === "$" && line[i + 1] === "{") {
|
|
2887
|
+
expansion++;
|
|
2888
|
+
i++;
|
|
2889
|
+
} else if (ch === "}" && expansion > 0) expansion--;
|
|
2890
|
+
else if (ch === "{" && !double) d++;
|
|
2891
|
+
else if (ch === "}" && !double) d--;
|
|
2892
|
+
}
|
|
2893
|
+
return d;
|
|
2894
|
+
}
|
|
2895
|
+
var FN_DEF = /^\s*(?:function\s+)?([A-Za-z_][\w-]*)\s*\(\s*\)\s*(.*)$|^\s*function\s+([A-Za-z_][\w-]*)\s*(\{.*)?$/;
|
|
2896
|
+
function prepare(lines, opts) {
|
|
2897
|
+
const flat = stripHeredocs(joinContinued(lines)).flatMap(explodeKeywords);
|
|
2898
|
+
const top = [];
|
|
2899
|
+
const fns = /* @__PURE__ */ new Map();
|
|
2900
|
+
const shadowed = /* @__PURE__ */ new Set();
|
|
2901
|
+
let errexit = opts.errexit ?? false;
|
|
2902
|
+
let pipefail = false;
|
|
2903
|
+
if (flat[0]?.startsWith("#!")) {
|
|
2904
|
+
const sb = flat[0];
|
|
2905
|
+
if (/\s-[a-zA-Z]*e[a-zA-Z]*(?:\s|$)/.test(sb)) errexit = true;
|
|
2906
|
+
if (/-[a-zA-Z]*o\s+pipefail\b/.test(sb)) pipefail = true;
|
|
2907
|
+
}
|
|
2908
|
+
for (let i = 0; i < flat.length; i++) {
|
|
2909
|
+
const raw = flat[i];
|
|
2910
|
+
if (isComment(raw)) {
|
|
2911
|
+
top.push(raw);
|
|
2912
|
+
continue;
|
|
2913
|
+
}
|
|
2914
|
+
const stmt = stripComment(raw).trim();
|
|
2915
|
+
const al = stmt.match(/^alias\s+([A-Za-z_][\w-]*)=/);
|
|
2916
|
+
if (al) shadowed.add(al[1]);
|
|
2917
|
+
const def = stmt.match(FN_DEF);
|
|
2918
|
+
if (!def) {
|
|
2919
|
+
top.push(raw);
|
|
2920
|
+
continue;
|
|
2921
|
+
}
|
|
2922
|
+
const name = def[1] ?? def[3];
|
|
2923
|
+
shadowed.add(name);
|
|
2924
|
+
let rest = (def[2] ?? def[4] ?? "").trim();
|
|
2925
|
+
if (rest === "" && /^\s*\{/.test(flat[i + 1] ?? "")) {
|
|
2926
|
+
i++;
|
|
2927
|
+
rest = flat[i].trim();
|
|
2928
|
+
}
|
|
2929
|
+
const body = [];
|
|
2930
|
+
if (!rest.startsWith("{")) {
|
|
2931
|
+
fns.set(name, body);
|
|
2932
|
+
continue;
|
|
2933
|
+
}
|
|
2934
|
+
rest = rest.slice(1);
|
|
2935
|
+
const after = (line, close2) => {
|
|
2936
|
+
const t = line.slice(0, close2);
|
|
2937
|
+
if (t.trim()) body.push(t);
|
|
2938
|
+
const remainder = line.slice(close2 + 1).replace(/^\s*;?\s*/, "");
|
|
2939
|
+
if (remainder.trim()) top.push(remainder);
|
|
2940
|
+
};
|
|
2941
|
+
let close = closingBrace(rest, 1);
|
|
2942
|
+
if (close !== -1) {
|
|
2943
|
+
after(rest, close);
|
|
2944
|
+
} else {
|
|
2945
|
+
if (rest.trim()) body.push(rest);
|
|
2946
|
+
let depth = 1 + braceDelta(rest);
|
|
2947
|
+
for (i++; i < flat.length; i++) {
|
|
2948
|
+
const line = isComment(flat[i]) ? "" : stripComment(flat[i]);
|
|
2949
|
+
close = closingBrace(line, depth);
|
|
2950
|
+
if (close !== -1) {
|
|
2951
|
+
after(line, close);
|
|
2952
|
+
break;
|
|
2953
|
+
}
|
|
2954
|
+
depth += braceDelta(line);
|
|
2955
|
+
body.push(flat[i]);
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
fns.set(name, body);
|
|
2959
|
+
}
|
|
2960
|
+
return { top, fns, shadowed, pipefail, errexit };
|
|
2961
|
+
}
|
|
2962
|
+
var TRANSPARENT = /^(?:fi|done|esac|\}|\)|;;|then|do|else|exit\s+"?\$\??"?|exit\s+"?\$\{\?\}"?)\s*;?$/;
|
|
2963
|
+
function statusAfterFailure(seg, vars) {
|
|
2964
|
+
const inner = seg.trim().replace(/^[{(]\s*/, "").replace(/\s*[})]\s*;?\s*$/, "");
|
|
2965
|
+
let status = "fail";
|
|
2966
|
+
for (const piece of inner.split(/;|&&|\|\||\|/)) {
|
|
2967
|
+
const w = piece.trim().replace(PREFIX, "").split(/\s+/);
|
|
2968
|
+
switch (w[0]) {
|
|
2969
|
+
case "":
|
|
2970
|
+
break;
|
|
2971
|
+
case "true":
|
|
2972
|
+
case ":":
|
|
2973
|
+
status = "ok";
|
|
2974
|
+
break;
|
|
2975
|
+
case "false":
|
|
2976
|
+
status = "fail";
|
|
2977
|
+
break;
|
|
2978
|
+
case "kill":
|
|
2979
|
+
status = w[1] === "-0" ? "ok" : "exit";
|
|
2980
|
+
break;
|
|
2981
|
+
case "exit":
|
|
2982
|
+
case "return": {
|
|
2983
|
+
const arg = unq(w[1] ?? "");
|
|
2984
|
+
if (arg === "" || arg === "$?" || arg === "${?}") return status === "ok" ? "ok" : "exit";
|
|
2985
|
+
if (/^0+$/.test(arg)) return "ok";
|
|
2986
|
+
if (/^[1-9]\d*$/.test(arg)) return "exit";
|
|
2987
|
+
const v = arg.match(/^\$\{?([A-Za-z_]\w*)\}?$/);
|
|
2988
|
+
const known = v ? vars.get(v[1]) : void 0;
|
|
2989
|
+
return known !== void 0 && /^0+$/.test(known) ? "ok" : "exit";
|
|
2990
|
+
}
|
|
2991
|
+
default:
|
|
2992
|
+
status = "ok";
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
return status;
|
|
2996
|
+
}
|
|
2997
|
+
function constStatus(seg) {
|
|
2998
|
+
const s = seg.trim().replace(/^!\s+/, "");
|
|
2999
|
+
if (alwaysTrue(s)) return "ok";
|
|
3000
|
+
if (alwaysFalse(s)) return "fail";
|
|
3001
|
+
return "unknown";
|
|
3002
|
+
}
|
|
3003
|
+
function reaches(segs, ops, i) {
|
|
3004
|
+
let status = "ok";
|
|
3005
|
+
let certain = true;
|
|
3006
|
+
for (let j = 0; j < i; j++) {
|
|
3007
|
+
const runs = j === 0 ? true : ops[j - 1] === "&&" ? status !== "fail" : ops[j - 1] === "||" ? status !== "ok" : true;
|
|
3008
|
+
if (!runs) continue;
|
|
3009
|
+
const s = constStatus(segs[j]);
|
|
3010
|
+
if (s === "unknown" && (ops[j] === "&&" || ops[j] === "||")) certain = false;
|
|
3011
|
+
if (ops[j] === ";" || ops[j] === "|") {
|
|
3012
|
+
status = "ok";
|
|
3013
|
+
certain = certain && true;
|
|
3014
|
+
continue;
|
|
3015
|
+
}
|
|
3016
|
+
status = s;
|
|
3017
|
+
}
|
|
3018
|
+
const gate = i === 0 ? true : ops[i - 1] === "&&" ? status !== "fail" : ops[i - 1] === "||" ? status !== "ok" : true;
|
|
3019
|
+
if (!gate) return "no";
|
|
3020
|
+
if (i === 0 || ops[i - 1] === ";" || ops[i - 1] === "|") return "yes";
|
|
3021
|
+
if (!certain || status === "unknown") return "maybe";
|
|
3022
|
+
return "yes";
|
|
3023
|
+
}
|
|
3024
|
+
function literal(arg, vars) {
|
|
3025
|
+
const a = unq(arg.trim());
|
|
3026
|
+
if (/[`]|\$\(/.test(a)) return null;
|
|
3027
|
+
let unknown = false;
|
|
3028
|
+
const r = a.replace(/\$\{?([A-Za-z_]\w*)\}?/g, (_, n) => {
|
|
3029
|
+
const v = vars.get(n);
|
|
3030
|
+
if (v === void 0) unknown = true;
|
|
3031
|
+
return v ?? "";
|
|
3032
|
+
});
|
|
3033
|
+
return unknown ? null : r;
|
|
3034
|
+
}
|
|
3035
|
+
function cdStaysHome(arg, vars) {
|
|
3036
|
+
const a = arg.trim();
|
|
3037
|
+
if (/rev-parse\s+--show-toplevel/.test(a)) return true;
|
|
3038
|
+
const lit = literal(a, vars);
|
|
3039
|
+
if (lit === null) return null;
|
|
3040
|
+
return lit === "" || lit === "." || lit === "./";
|
|
3041
|
+
}
|
|
3042
|
+
function walk(lines, st, topIdx, depth) {
|
|
3043
|
+
const frames = [{ dead: false, exhausted: false, kind: "base" }];
|
|
2612
3044
|
const dead = () => frames.some((f) => f.dead);
|
|
2613
|
-
|
|
3045
|
+
const conditional = () => frames.length > 1;
|
|
3046
|
+
let exits = false;
|
|
3047
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3048
|
+
const raw = lines[i];
|
|
3049
|
+
const idx = topIdx(i);
|
|
2614
3050
|
if (isComment(raw)) {
|
|
2615
3051
|
const id = checkIdentity(raw.replace(/^\s*#+\s*/, ""));
|
|
2616
|
-
if (id)
|
|
3052
|
+
if (id) st.out.push({ identity: id, line: raw, state: "comment", idx, errexit: st.errexit, passthrough: false });
|
|
2617
3053
|
continue;
|
|
2618
3054
|
}
|
|
2619
|
-
|
|
3055
|
+
let stmt = stripComment(raw).trim();
|
|
2620
3056
|
if (!stmt) continue;
|
|
2621
|
-
const ifm = stmt.match(/^(if|elif)\s+(
|
|
3057
|
+
const ifm = stmt.match(/^(if|elif)\s+(.+)$/);
|
|
2622
3058
|
if (ifm) {
|
|
2623
|
-
|
|
2624
|
-
|
|
3059
|
+
const cond = ifm[2].replace(/;\s*$/, "");
|
|
3060
|
+
const cid = checkIdentity(cond.replace(/^!\s+/, ""));
|
|
3061
|
+
if (cid) st.out.push({ identity: cid, line: raw, state: dead() ? "unreachable" : "neutered", why: "used as an `if` condition \u2014 its failure selects a branch instead of failing the hook", idx, errexit: st.errexit, passthrough: false });
|
|
3062
|
+
if (ifm[1] === "if") frames.push({ dead: alwaysFalse(cond), exhausted: alwaysTrue(cond), kind: "if" });
|
|
3063
|
+
else if (frames.length > 1) {
|
|
3064
|
+
const f = frames[frames.length - 1];
|
|
3065
|
+
f.dead = f.exhausted || alwaysFalse(cond);
|
|
3066
|
+
f.exhausted = f.exhausted || alwaysTrue(cond);
|
|
3067
|
+
}
|
|
2625
3068
|
continue;
|
|
2626
3069
|
}
|
|
2627
3070
|
if (/^else\b/.test(stmt)) {
|
|
2628
|
-
if (frames.length > 1)
|
|
3071
|
+
if (frames.length > 1) {
|
|
3072
|
+
const f = frames[frames.length - 1];
|
|
3073
|
+
f.dead = f.exhausted;
|
|
3074
|
+
}
|
|
2629
3075
|
continue;
|
|
2630
3076
|
}
|
|
2631
|
-
if (/^fi\b/.test(stmt)) {
|
|
3077
|
+
if (/^(?:fi|done|esac)\b/.test(stmt)) {
|
|
2632
3078
|
if (frames.length > 1) frames.pop();
|
|
2633
3079
|
continue;
|
|
2634
3080
|
}
|
|
3081
|
+
if (/^(?:then|do)\b/.test(stmt)) continue;
|
|
3082
|
+
const loop = stmt.match(/^(while|until)\s+(.+)$/);
|
|
3083
|
+
if (loop) {
|
|
3084
|
+
const cond = loop[2].replace(/;\s*$/, "");
|
|
3085
|
+
frames.push({ dead: loop[1] === "while" ? alwaysFalse(cond) : alwaysTrue(cond), exhausted: false, kind: "loop" });
|
|
3086
|
+
continue;
|
|
3087
|
+
}
|
|
3088
|
+
const forl = stmt.match(/^for\s+[A-Za-z_]\w*(?:\s+in\b(.*))?$/);
|
|
3089
|
+
if (forl) {
|
|
3090
|
+
const list2 = (forl[1] ?? "$@").replace(/;\s*$/, "").trim();
|
|
3091
|
+
frames.push({ dead: forl[1] !== void 0 && (list2 === "" || /^(?:""|'')$/.test(list2)), exhausted: false, kind: "loop" });
|
|
3092
|
+
continue;
|
|
3093
|
+
}
|
|
3094
|
+
const casem = stmt.match(/^case\s+(\S+)\s+in\b/);
|
|
3095
|
+
if (casem) {
|
|
3096
|
+
frames.push({ dead: false, exhausted: false, kind: "case", word: unq(casem[1]) });
|
|
3097
|
+
continue;
|
|
3098
|
+
}
|
|
3099
|
+
const pat = frames[frames.length - 1].kind === "case" ? stmt.match(/^\(?([^()]+)\)\s*(.*)$/) : null;
|
|
3100
|
+
if (pat) {
|
|
3101
|
+
const f = frames[frames.length - 1];
|
|
3102
|
+
const word = f.word ?? "";
|
|
3103
|
+
const patterns = pat[1].split("|").map((p) => unq(p.trim()));
|
|
3104
|
+
const may = /[$`]/.test(word) || patterns.some((p) => p === "*" || /[$`*?[]/.test(p) || p === word);
|
|
3105
|
+
f.dead = !may;
|
|
3106
|
+
stmt = pat[2].trim();
|
|
3107
|
+
if (!stmt) continue;
|
|
3108
|
+
}
|
|
3109
|
+
if (/^;;$/.test(stmt)) continue;
|
|
3110
|
+
if (/^[{}()]$/.test(stmt)) continue;
|
|
3111
|
+
const bg = /(?<!&)&\s*$/.test(stmt) && !/&&\s*$/.test(stmt);
|
|
3112
|
+
if (bg) stmt = stmt.replace(/\s*&\s*$/, "");
|
|
2635
3113
|
const { segs, ops } = split(stmt);
|
|
2636
|
-
for (let
|
|
2637
|
-
|
|
3114
|
+
for (let s = 0; s < segs.length; s++) {
|
|
3115
|
+
let seg = segs[s].trim();
|
|
3116
|
+
const inverted = /^!\s+/.test(seg);
|
|
3117
|
+
seg = seg.replace(/^!\s+/, "");
|
|
3118
|
+
const execs = /^exec\s+/.test(seg) && !/^exec\s+(?:\d*[<>]|&>)/.test(seg);
|
|
3119
|
+
seg = seg.replace(PREFIX, "");
|
|
3120
|
+
const reach = dead() ? "no" : reaches(segs, ops, s);
|
|
3121
|
+
const first = seg.split(/\s+/)[0] ?? "";
|
|
3122
|
+
if (reach !== "no") {
|
|
3123
|
+
const certain = reach === "yes" && !conditional();
|
|
3124
|
+
const set = seg.match(/^set\s+(.+)$/);
|
|
3125
|
+
if (set) {
|
|
3126
|
+
const toks = set[1].split(/\s+/);
|
|
3127
|
+
for (let t = 0; t < toks.length; t++) {
|
|
3128
|
+
const m = toks[t].match(/^([-+])([a-zA-Z]+)$/);
|
|
3129
|
+
if (!m) continue;
|
|
3130
|
+
const on = m[1] === "-";
|
|
3131
|
+
const flip = (cur) => on ? certain ? true : cur : false;
|
|
3132
|
+
if (m[2].includes("e")) st.errexit = flip(st.errexit);
|
|
3133
|
+
if (m[2].includes("o")) {
|
|
3134
|
+
const name = toks[++t];
|
|
3135
|
+
if (name === "errexit") st.errexit = flip(st.errexit);
|
|
3136
|
+
if (name === "pipefail") st.pipefail = flip(st.pipefail);
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
const trap = seg.match(/^trap\s+(?:'([^']*)'|"([^"]*)"|(\S+))\s+(.+)$/);
|
|
3141
|
+
if (trap) {
|
|
3142
|
+
const action = trap[1] ?? trap[2] ?? trap[3];
|
|
3143
|
+
const sigs = trap[4].split(/\s+/);
|
|
3144
|
+
if (sigs.some((x) => /^(?:EXIT|ERR|0|SIGERR)$/i.test(x)) && /(?:^|[;\s])(?:exit(?:\s+0+)?|true|:)\s*(?:;|$)/.test(action)) st.traps.push(idx);
|
|
3145
|
+
}
|
|
3146
|
+
const asg = seg.match(/^(?:export\s+|readonly\s+)?([A-Za-z_]\w*)=(.*)$/);
|
|
3147
|
+
if (asg) {
|
|
3148
|
+
const v = certain ? literal(asg[2], st.vars) : null;
|
|
3149
|
+
if (v === null) st.vars.delete(asg[1]);
|
|
3150
|
+
else st.vars.set(asg[1], v);
|
|
3151
|
+
}
|
|
3152
|
+
if (first === "cd") {
|
|
3153
|
+
const home = cdStaysHome(seg.slice(2), st.vars);
|
|
3154
|
+
if (home === false) st.cdAway = true;
|
|
3155
|
+
else if (home === true && certain) st.cdAway = false;
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
2638
3158
|
const id = checkIdentity(seg);
|
|
2639
3159
|
if (!id) {
|
|
2640
|
-
if (
|
|
3160
|
+
if (reach === "yes") {
|
|
3161
|
+
if (EXIT_ANY.test(seg)) {
|
|
3162
|
+
if (!conditional()) {
|
|
3163
|
+
frames[0].dead = true;
|
|
3164
|
+
if (/^exit\b/.test(seg)) exits = true;
|
|
3165
|
+
} else if (/^exit\b/.test(seg) && depth > 0) {
|
|
3166
|
+
}
|
|
3167
|
+
} else if (execs && !conditional()) {
|
|
3168
|
+
frames[0].dead = true;
|
|
3169
|
+
} else if (st.fns.has(first) && reach === "yes") {
|
|
3170
|
+
const start = st.out.length;
|
|
3171
|
+
const body = st.fns.get(first) ?? [];
|
|
3172
|
+
const sub = { ...st, out: st.out };
|
|
3173
|
+
const fnExits = depth < 6 && walk(body, sub, () => idx, depth + 1);
|
|
3174
|
+
st.errexit = sub.errexit;
|
|
3175
|
+
st.pipefail = sub.pipefail;
|
|
3176
|
+
st.cdAway = sub.cdAway;
|
|
3177
|
+
const verdict2 = chainVerdict(segs, ops, s, st, inverted, bg);
|
|
3178
|
+
for (let k = start; k < st.out.length; k++) {
|
|
3179
|
+
const inv = st.out[k];
|
|
3180
|
+
if (inv.state === "live" && verdict2.state !== "live") {
|
|
3181
|
+
inv.state = verdict2.state;
|
|
3182
|
+
inv.why = verdict2.why;
|
|
3183
|
+
}
|
|
3184
|
+
if (inv.state === "live") inv.passthrough = inv.passthrough && verdict2.passthrough;
|
|
3185
|
+
}
|
|
3186
|
+
if (fnExits && !conditional()) {
|
|
3187
|
+
frames[0].dead = true;
|
|
3188
|
+
exits = true;
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
} else if (reach === "maybe" && st.fns.has(first)) {
|
|
3192
|
+
const start = st.out.length;
|
|
3193
|
+
const sub = { ...st, out: st.out };
|
|
3194
|
+
if (depth < 6) walk(st.fns.get(first) ?? [], sub, () => idx, depth + 1);
|
|
3195
|
+
const verdict2 = chainVerdict(segs, ops, s, st, inverted, bg);
|
|
3196
|
+
for (let k = start; k < st.out.length; k++) {
|
|
3197
|
+
const inv = st.out[k];
|
|
3198
|
+
if (inv.state === "live" && verdict2.state !== "live") {
|
|
3199
|
+
inv.state = verdict2.state;
|
|
3200
|
+
inv.why = verdict2.why;
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
2641
3204
|
continue;
|
|
2642
3205
|
}
|
|
2643
|
-
let state =
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
3206
|
+
let state = "live";
|
|
3207
|
+
let why;
|
|
3208
|
+
let passthrough = false;
|
|
3209
|
+
if (reach === "no") {
|
|
3210
|
+
state = "unreachable";
|
|
3211
|
+
why = dead() ? "behind an always-false guard, an early exit, or a function body that is never called" : "behind a constant condition";
|
|
3212
|
+
} else {
|
|
3213
|
+
const v = chainVerdict(segs, ops, s, st, inverted, bg);
|
|
3214
|
+
state = v.state;
|
|
3215
|
+
why = v.why;
|
|
3216
|
+
passthrough = v.passthrough;
|
|
3217
|
+
if (state === "live" && /^tamperward\b/.test(id) && st.cdAway) {
|
|
3218
|
+
state = "neutered";
|
|
3219
|
+
why = "runs from another directory after a `cd` away from the repository root";
|
|
3220
|
+
}
|
|
3221
|
+
if (state === "live") {
|
|
3222
|
+
const words2 = [first, "tamperward"].filter((w) => w && st.shadowed.has(w));
|
|
3223
|
+
if (words2.length) {
|
|
3224
|
+
state = "neutered";
|
|
3225
|
+
why = `\`${words2[0]}\` is redefined by a function or alias in this script`;
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
2653
3228
|
}
|
|
2654
|
-
|
|
3229
|
+
const pin = pinOfSegment(seg);
|
|
3230
|
+
st.out.push({ identity: id, line: raw, state, why, idx, errexit: st.errexit, passthrough, ...pin !== null ? { pin } : {} });
|
|
2655
3231
|
}
|
|
2656
3232
|
}
|
|
2657
|
-
return
|
|
3233
|
+
return exits;
|
|
3234
|
+
}
|
|
3235
|
+
function chainVerdict(segs, ops, i, st, inverted, bg) {
|
|
3236
|
+
if (inverted) return { state: "neutered", why: "its status is inverted by `!`", passthrough: false };
|
|
3237
|
+
if (bg) return { state: "neutered", why: "run in the background \u2014 the hook does not wait for its status", passthrough: false };
|
|
3238
|
+
let cur = "fail";
|
|
3239
|
+
for (let j = i; j < ops.length; j++) {
|
|
3240
|
+
const op = ops[j];
|
|
3241
|
+
const next = segs[j + 1];
|
|
3242
|
+
if (op === "|") {
|
|
3243
|
+
if (!st.pipefail) return { state: "neutered", why: "piped into another command without `pipefail`", passthrough: false };
|
|
3244
|
+
continue;
|
|
3245
|
+
}
|
|
3246
|
+
if (cur === "exit") break;
|
|
3247
|
+
if (op === "||") {
|
|
3248
|
+
if (cur === "fail") cur = statusAfterFailure(next, st.vars);
|
|
3249
|
+
} else if (op === "&&") {
|
|
3250
|
+
if (cur === "ok") cur = statusAfterFailure(next, st.vars);
|
|
3251
|
+
} else if (op === ";") {
|
|
3252
|
+
const rest = segs.slice(j + 1).map((x) => x.trim());
|
|
3253
|
+
if (st.errexit && cur === "fail") {
|
|
3254
|
+
if (rest.some((x) => /^(?:true|:)$/.test(x) || EXIT_ZERO.test(x))) return { state: "neutered", why: "followed by a passing statement in the same line", passthrough: false };
|
|
3255
|
+
return { state: "live", passthrough: false };
|
|
3256
|
+
}
|
|
3257
|
+
if (rest.every((x) => TRANSPARENT.test(x))) return { state: cur === "fail" ? "live" : "neutered", why: cur === "fail" ? void 0 : "the `||` chain after it ends in success", passthrough: cur === "fail" };
|
|
3258
|
+
return { state: "neutered", why: "followed by other statements whose status replaces it (no `set -e`)", passthrough: false };
|
|
3259
|
+
}
|
|
3260
|
+
}
|
|
3261
|
+
if (cur === "ok") return { state: "neutered", why: "the `||` chain after it ends in success", passthrough: false };
|
|
3262
|
+
if (cur === "exit") return { state: "live", passthrough: false };
|
|
3263
|
+
return { state: "live", passthrough: i === ops.length || ops.slice(i).every((o) => o === "&&" || o === "||") };
|
|
3264
|
+
}
|
|
3265
|
+
function invocations(lines, opts = {}) {
|
|
3266
|
+
const prep = prepare(lines, opts);
|
|
3267
|
+
const st = {
|
|
3268
|
+
errexit: prep.errexit,
|
|
3269
|
+
pipefail: prep.pipefail,
|
|
3270
|
+
cdAway: false,
|
|
3271
|
+
vars: /* @__PURE__ */ new Map(),
|
|
3272
|
+
fns: prep.fns,
|
|
3273
|
+
shadowed: prep.shadowed,
|
|
3274
|
+
traps: [],
|
|
3275
|
+
out: [],
|
|
3276
|
+
lastReal: -1
|
|
3277
|
+
};
|
|
3278
|
+
for (let i = 0; i < prep.top.length; i++) {
|
|
3279
|
+
const t = stripComment(prep.top[i]).trim();
|
|
3280
|
+
if (t && !isComment(prep.top[i]) && !TRANSPARENT.test(t)) st.lastReal = i;
|
|
3281
|
+
}
|
|
3282
|
+
walk(prep.top, st, (i) => i, 0);
|
|
3283
|
+
const seen = new Set(st.out.map((o) => o.line));
|
|
3284
|
+
for (const [, body] of prep.fns) {
|
|
3285
|
+
const sub = { ...st, out: [], traps: [] };
|
|
3286
|
+
walk(body, sub, () => -1, 1);
|
|
3287
|
+
for (const inv of sub.out) if (!seen.has(inv.line) && inv.state !== "comment") st.out.push({ ...inv, state: "unreachable", why: "defined in a function that is never called" });
|
|
3288
|
+
}
|
|
3289
|
+
return st.out.map((inv) => {
|
|
3290
|
+
if (inv.state !== "live") return strip(inv);
|
|
3291
|
+
if (st.traps.some((t) => t < inv.idx) || !inv.errexit && st.traps.some((t) => t > inv.idx)) {
|
|
3292
|
+
return strip({ ...inv, state: "neutered", why: "a `trap` on EXIT/ERR exits 0 whatever the gate returned" });
|
|
3293
|
+
}
|
|
3294
|
+
if (inv.passthrough && !inv.errexit && inv.idx !== -1 && inv.idx < st.lastReal) {
|
|
3295
|
+
return strip({ ...inv, state: "neutered", why: "without `set -e` its failure does not fail the hook \u2014 other statements run after it" });
|
|
3296
|
+
}
|
|
3297
|
+
return strip(inv);
|
|
3298
|
+
});
|
|
2658
3299
|
}
|
|
2659
|
-
|
|
3300
|
+
var strip = (inv) => {
|
|
3301
|
+
const { idx: _i, errexit: _e, passthrough: _p, ...rest } = inv;
|
|
3302
|
+
return rest;
|
|
3303
|
+
};
|
|
3304
|
+
function scriptWeakening(before, after, opts = {}) {
|
|
3305
|
+
const beforeInv = invocations(before, opts);
|
|
3306
|
+
const afterInv = invocations(after, opts);
|
|
2660
3307
|
const live = (inv) => new Set(inv.filter((i) => i.state === "live").map((i) => i.identity));
|
|
2661
|
-
const bl = live(
|
|
2662
|
-
const afterInv = invocations(after);
|
|
3308
|
+
const bl = live(beforeInv);
|
|
2663
3309
|
const al = live(afterInv);
|
|
2664
3310
|
const out2 = [];
|
|
2665
3311
|
for (const id of bl) {
|
|
2666
3312
|
if (al.has(id)) continue;
|
|
2667
|
-
const trace = afterInv.find((i) => i.identity === id);
|
|
2668
|
-
const how = trace?.state === "neutered" ?
|
|
2669
|
-
const evidence = (trace?.line ?? before.find((l) => checkIdentity(stripComment(l).trim().replace(PREFIX, "")) === id) ?? id).trim();
|
|
3313
|
+
const trace = afterInv.find((i) => i.identity === id) ?? afterInv.find((i) => i.identity.split(" --cwd ")[0] === id.split(" --cwd ")[0]);
|
|
3314
|
+
const how = trace?.state === "neutered" ? `neutralised in place (${trace.why ?? "its failure can no longer fail the hook"})` : trace?.state === "unreachable" ? `made unreachable (${trace.why ?? "behind an always-false guard or an early exit"})` : trace?.state === "comment" ? "commented out" : trace && trace.identity !== id ? `replaced by \`${trace.identity}\`` : "removed";
|
|
3315
|
+
const evidence = (trace?.line ?? before.find((l) => checkIdentity(stripComment(l).trim().replace(/^!\s+/, "").replace(PREFIX, "")) === id) ?? id).trim();
|
|
2670
3316
|
out2.push({ reason: `the check invocation \`${id}\` was ${how}`, evidence });
|
|
2671
3317
|
}
|
|
3318
|
+
const floor = beforeInv.filter((i) => i.state === "live" && i.pin && PLAIN_SEMVER.test(i.pin)).map((i) => i.pin).sort((a, b) => compareVersions(a, b) ?? 0)[0];
|
|
3319
|
+
if (floor) {
|
|
3320
|
+
for (const inv of afterInv) {
|
|
3321
|
+
if (inv.state !== "live" || !inv.pin || !PLAIN_SEMVER.test(inv.pin)) continue;
|
|
3322
|
+
if ((compareVersions(inv.pin, floor) ?? 0) < 0) out2.push({ reason: `the gate's pin was lowered from ${floor} to ${inv.pin}`, evidence: inv.line.trim() });
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
2672
3325
|
return out2;
|
|
2673
3326
|
}
|
|
3327
|
+
function shebangProblem(src) {
|
|
3328
|
+
const first = src.split("\n")[0] ?? "";
|
|
3329
|
+
if (!first.startsWith("#!")) return null;
|
|
3330
|
+
const body = first.slice(2).trim();
|
|
3331
|
+
const toks = body.split(/\s+/);
|
|
3332
|
+
let i = 0;
|
|
3333
|
+
if (/(?:^|\/)env$/.test(toks[0] ?? "")) {
|
|
3334
|
+
i = 1;
|
|
3335
|
+
while (i < toks.length && toks[i].startsWith("-")) i++;
|
|
3336
|
+
}
|
|
3337
|
+
const interp = (toks[i] ?? "").replace(/^.*\//, "");
|
|
3338
|
+
if (!/^(?:sh|bash|dash|zsh|ksh|ash|busybox)$/.test(interp)) return `the shebang runs \`${body}\`, not a shell`;
|
|
3339
|
+
if (toks.slice(i + 1).some((t) => /^-[a-zA-Z]*c/.test(t))) return `the shebang passes \`-c\` \u2014 the interpreter runs the argument, not the script`;
|
|
3340
|
+
return null;
|
|
3341
|
+
}
|
|
2674
3342
|
var isCodeowners = (path) => /(?:^|\/)CODEOWNERS$/.test(path);
|
|
2675
3343
|
var GATE_CRITICAL = [
|
|
2676
|
-
"/.github/workflows/",
|
|
3344
|
+
"/.github/workflows/tamperward.yml",
|
|
2677
3345
|
"/.tamperward.yml",
|
|
2678
3346
|
"/.github/CODEOWNERS",
|
|
2679
3347
|
"/CODEOWNERS",
|
|
2680
3348
|
"/docs/CODEOWNERS",
|
|
2681
|
-
"/.husky/",
|
|
3349
|
+
"/.husky/pre-commit",
|
|
2682
3350
|
"/.claude/settings.json",
|
|
2683
3351
|
"/.claude/settings.local.json",
|
|
2684
3352
|
"/.pre-commit-config.yaml",
|
|
2685
3353
|
"/lefthook.yml",
|
|
2686
3354
|
"/lefthook.yaml"
|
|
2687
3355
|
];
|
|
2688
|
-
function
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
if (p
|
|
2693
|
-
|
|
2694
|
-
|
|
3356
|
+
function codeownersMatcher(pattern) {
|
|
3357
|
+
let p = pattern.trim();
|
|
3358
|
+
if (p === "*" || p === "**") return () => true;
|
|
3359
|
+
let anchored = p.startsWith("/");
|
|
3360
|
+
if (anchored) p = p.slice(1);
|
|
3361
|
+
const dir = p.endsWith("/");
|
|
3362
|
+
p = p.replace(/\/+$/, "");
|
|
3363
|
+
if (p.includes("/")) anchored = true;
|
|
3364
|
+
if (p.startsWith("**/")) {
|
|
3365
|
+
anchored = false;
|
|
3366
|
+
p = p.slice(3);
|
|
3367
|
+
}
|
|
3368
|
+
let re = "";
|
|
3369
|
+
for (let i = 0; i < p.length; i++) {
|
|
3370
|
+
const ch = p[i];
|
|
3371
|
+
if (ch === "*") {
|
|
3372
|
+
if (p[i + 1] === "*") {
|
|
3373
|
+
re += ".*";
|
|
3374
|
+
i++;
|
|
3375
|
+
} else re += "[^/]*";
|
|
3376
|
+
} else if (ch === "?") re += "[^/]";
|
|
3377
|
+
else re += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
3378
|
+
}
|
|
3379
|
+
const body = (anchored ? "^/" : "(?:^|/)") + re;
|
|
3380
|
+
const exact = new RegExp(body + "$");
|
|
3381
|
+
const under = new RegExp(body + "/");
|
|
3382
|
+
return (file) => !dir && exact.test(file) || under.test(file);
|
|
2695
3383
|
}
|
|
2696
3384
|
function ownersOf(content, critical) {
|
|
2697
3385
|
let owners = null;
|
|
@@ -2699,13 +3387,20 @@ function ownersOf(content, critical) {
|
|
|
2699
3387
|
const t = line.trim();
|
|
2700
3388
|
if (!t || t.startsWith("#")) continue;
|
|
2701
3389
|
const [pattern, ...rest] = t.split(/\s+/);
|
|
2702
|
-
|
|
3390
|
+
let matches;
|
|
3391
|
+
try {
|
|
3392
|
+
matches = codeownersMatcher(pattern)(critical);
|
|
3393
|
+
} catch {
|
|
3394
|
+
matches = false;
|
|
3395
|
+
}
|
|
3396
|
+
if (matches) owners = rest.filter((o) => !o.startsWith("#"));
|
|
2703
3397
|
}
|
|
2704
3398
|
return owners;
|
|
2705
3399
|
}
|
|
2706
|
-
function codeownersWeakening(before, after) {
|
|
3400
|
+
function codeownersWeakening(before, after, extra = []) {
|
|
2707
3401
|
const out2 = [];
|
|
2708
|
-
|
|
3402
|
+
const files = [.../* @__PURE__ */ new Set([...GATE_CRITICAL, ...extra.map((f) => f.startsWith("/") ? f : "/" + f)])];
|
|
3403
|
+
for (const critical of files) {
|
|
2709
3404
|
const was = ownersOf(before, critical);
|
|
2710
3405
|
if (!was || was.length === 0) continue;
|
|
2711
3406
|
const now = ownersOf(after, critical);
|
|
@@ -2724,25 +3419,47 @@ function parseDoc(src) {
|
|
|
2724
3419
|
}
|
|
2725
3420
|
}
|
|
2726
3421
|
var isLefthook = (path) => /(?:^|\/)\.?lefthook(?:-local)?\.(?:ya?ml|toml|json)$/.test(path);
|
|
3422
|
+
var isLefthookLocal = (path) => /(?:^|\/)\.?lefthook-local\.(?:ya?ml|toml|json)$/.test(path);
|
|
2727
3423
|
var isPreCommitConfig = (path) => /(?:^|\/)\.pre-commit-config\.ya?ml$/.test(path);
|
|
2728
3424
|
var isPackageJson = (path) => /(?:^|\/)package\.json$/.test(path);
|
|
2729
3425
|
var GATE = /\btamperward\b/;
|
|
2730
3426
|
var str = (v) => typeof v === "string" ? v : Array.isArray(v) ? v.map(String).join(" ") : v == null ? "" : JSON.stringify(v);
|
|
3427
|
+
var list = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" ? v.split(/[\s,]+/).filter(Boolean) : [];
|
|
3428
|
+
function runsGate(command) {
|
|
3429
|
+
return invocations([command], { errexit: true }).some((i) => i.state === "live" && /^tamperward check\b/.test(i.identity));
|
|
3430
|
+
}
|
|
3431
|
+
function mergeDocs(base, over) {
|
|
3432
|
+
const out2 = { ...base };
|
|
3433
|
+
for (const [k, v] of Object.entries(over)) {
|
|
3434
|
+
const b = out2[k];
|
|
3435
|
+
out2[k] = isDoc(b) && isDoc(v) ? mergeDocs(b, v) : v;
|
|
3436
|
+
}
|
|
3437
|
+
return out2;
|
|
3438
|
+
}
|
|
3439
|
+
var off = (v) => v === void 0 || v === null || v === false;
|
|
2731
3440
|
function lefthookEntries(doc) {
|
|
2732
3441
|
const out2 = /* @__PURE__ */ new Map();
|
|
2733
3442
|
for (const [section, v] of Object.entries(doc)) {
|
|
2734
3443
|
if (!isDoc(v)) continue;
|
|
3444
|
+
const sectionExclude = list(v.exclude_tags);
|
|
2735
3445
|
for (const group of ["commands", "scripts"]) {
|
|
2736
3446
|
const g = v[group];
|
|
2737
3447
|
if (!isDoc(g)) continue;
|
|
2738
3448
|
for (const [name, cfg] of Object.entries(g)) {
|
|
2739
|
-
if (!isDoc(cfg)
|
|
3449
|
+
if (!isDoc(cfg)) continue;
|
|
3450
|
+
const run = cfg.run ?? cfg.runner;
|
|
3451
|
+
const named = GATE.test(name);
|
|
3452
|
+
if (!GATE.test(str(run)) && !named) continue;
|
|
3453
|
+
const tags = list(cfg.tags);
|
|
2740
3454
|
out2.set(`${section}.${group}.${name}`, {
|
|
3455
|
+
live: run === void 0 ? named : runsGate(str(run)),
|
|
2741
3456
|
skip: cfg.skip,
|
|
3457
|
+
only: cfg.only,
|
|
2742
3458
|
sectionSkip: v.skip,
|
|
3459
|
+
sectionOnly: v.only,
|
|
2743
3460
|
glob: str(cfg.glob),
|
|
2744
3461
|
exclude: str(cfg.exclude),
|
|
2745
|
-
|
|
3462
|
+
excludedByTag: tags.filter((t) => sectionExclude.includes(t))
|
|
2746
3463
|
});
|
|
2747
3464
|
}
|
|
2748
3465
|
}
|
|
@@ -2754,33 +3471,46 @@ function lefthookWeakening(before, after) {
|
|
|
2754
3471
|
const b = lefthookEntries(before);
|
|
2755
3472
|
const a = lefthookEntries(after);
|
|
2756
3473
|
for (const [key2, was] of b) {
|
|
3474
|
+
if (!was.live) continue;
|
|
2757
3475
|
const now = a.get(key2);
|
|
2758
3476
|
if (!now) {
|
|
2759
3477
|
out2.push(`lefthook entry ${key2} (the gate) was removed`);
|
|
2760
3478
|
continue;
|
|
2761
3479
|
}
|
|
2762
|
-
if (now.
|
|
2763
|
-
if (now.
|
|
3480
|
+
if (!now.live) out2.push(`lefthook entry ${key2} no longer runs \`tamperward check\` live`);
|
|
3481
|
+
if (!off(now.skip) && off(was.skip)) out2.push(`lefthook entry ${key2} (the gate) is now \`skip: ${str(now.skip)}\``);
|
|
3482
|
+
if (!off(now.sectionSkip) && off(was.sectionSkip)) out2.push(`the lefthook hook carrying ${key2} (the gate) is now \`skip: ${str(now.sectionSkip)}\``);
|
|
3483
|
+
if (!off(now.only) && str(now.only) !== str(was.only)) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`only\` (${str(now.only)})`);
|
|
3484
|
+
if (!off(now.sectionOnly) && str(now.sectionOnly) !== str(was.sectionOnly)) out2.push(`the lefthook hook carrying ${key2} (the gate) gained or changed \`only\` (${str(now.sectionOnly)})`);
|
|
2764
3485
|
if (now.glob !== was.glob && now.glob) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`glob\` (${now.glob}) \u2014 it no longer runs on every commit`);
|
|
2765
3486
|
if (now.exclude !== was.exclude && now.exclude) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`exclude\` (${now.exclude})`);
|
|
2766
|
-
if (now.
|
|
3487
|
+
if (now.excludedByTag.length && !was.excludedByTag.length) out2.push(`lefthook entry ${key2} (the gate) is tagged ${now.excludedByTag.join(", ")}, which the hook's \`exclude_tags\` switches off`);
|
|
2767
3488
|
}
|
|
2768
3489
|
return out2;
|
|
2769
3490
|
}
|
|
2770
|
-
var
|
|
3491
|
+
var STAGE_ALIAS = { commit: "pre-commit", push: "pre-push", merge_commit: "pre-merge-commit" };
|
|
3492
|
+
var GATE_STAGES = /* @__PURE__ */ new Set(["pre-commit", "pre-push"]);
|
|
3493
|
+
var ALL_STAGES = ["pre-commit", "pre-merge-commit", "pre-push", "prepare-commit-msg", "commit-msg", "post-checkout", "post-commit", "post-merge", "post-rewrite", "manual"];
|
|
3494
|
+
var normStage = (s) => STAGE_ALIAS[s] ?? s;
|
|
2771
3495
|
function preCommitEntries(doc) {
|
|
2772
3496
|
const out2 = /* @__PURE__ */ new Map();
|
|
2773
3497
|
const repos = Array.isArray(doc.repos) ? doc.repos : [];
|
|
3498
|
+
const defaults = Array.isArray(doc.default_stages) ? doc.default_stages.map(String).map(normStage) : ALL_STAGES;
|
|
2774
3499
|
for (const repo of repos) {
|
|
2775
3500
|
if (!isDoc(repo)) continue;
|
|
2776
3501
|
const hooks = Array.isArray(repo.hooks) ? repo.hooks : [];
|
|
2777
3502
|
for (const h of hooks) {
|
|
2778
3503
|
if (!isDoc(h)) continue;
|
|
2779
3504
|
if (!GATE.test(`${str(repo.repo)} ${str(h.id)} ${str(h.entry)} ${str(h.name)}`)) continue;
|
|
3505
|
+
const live = h.entry !== void 0 ? runsGate(str(h.entry)) : GATE.test(`${str(repo.repo)} ${str(h.id)}`);
|
|
2780
3506
|
out2.set(`${str(repo.repo)}:${str(h.id)}`, {
|
|
2781
|
-
|
|
3507
|
+
live,
|
|
3508
|
+
stages: Array.isArray(h.stages) ? h.stages.map(String).map(normStage) : defaults,
|
|
3509
|
+
explicitStages: Array.isArray(h.stages),
|
|
2782
3510
|
exclude: str(h.exclude),
|
|
2783
|
-
files: str(h.files)
|
|
3511
|
+
files: str(h.files),
|
|
3512
|
+
types: [str(h.types), str(h.types_or), str(h.exclude_types)].join(" ").trim(),
|
|
3513
|
+
alwaysRun: h.always_run === true
|
|
2784
3514
|
});
|
|
2785
3515
|
}
|
|
2786
3516
|
}
|
|
@@ -2791,28 +3521,45 @@ function preCommitWeakening(before, after) {
|
|
|
2791
3521
|
const b = preCommitEntries(before);
|
|
2792
3522
|
const a = preCommitEntries(after);
|
|
2793
3523
|
for (const [key2, was] of b) {
|
|
3524
|
+
if (!was.live) continue;
|
|
2794
3525
|
const now = a.get(key2);
|
|
2795
3526
|
if (!now) {
|
|
2796
3527
|
out2.push(`pre-commit hook ${key2} (the gate) was removed`);
|
|
2797
3528
|
continue;
|
|
2798
3529
|
}
|
|
2799
|
-
|
|
2800
|
-
|
|
3530
|
+
if (!now.live) out2.push(`pre-commit hook ${key2} no longer runs \`tamperward check\` live`);
|
|
3531
|
+
const lost = was.stages.filter((s) => GATE_STAGES.has(s) && !now.stages.includes(s));
|
|
3532
|
+
if (lost.length && !now.stages.some((s) => GATE_STAGES.has(s))) {
|
|
2801
3533
|
out2.push(`pre-commit hook ${key2} (the gate) now runs only at stages [${now.stages.join(", ")}] \u2014 no commit or push triggers it`);
|
|
2802
|
-
} else if (
|
|
2803
|
-
|
|
2804
|
-
if (lost.length) out2.push(`pre-commit hook ${key2} (the gate) no longer runs at stage(s) ${lost.join(", ")}`);
|
|
3534
|
+
} else if (lost.length) {
|
|
3535
|
+
out2.push(`pre-commit hook ${key2} (the gate) no longer runs at stage(s) ${lost.join(", ")}${now.explicitStages ? "" : " (default_stages changed)"}`);
|
|
2805
3536
|
}
|
|
2806
3537
|
if (now.exclude !== was.exclude && now.exclude) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`exclude\` (${now.exclude})`);
|
|
2807
3538
|
if (now.files !== was.files && now.files) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`files\` (${now.files})`);
|
|
3539
|
+
if (now.types !== was.types && now.types && !now.alwaysRun) out2.push(`pre-commit hook ${key2} (the gate) is scoped by file type (${now.types}) without \`always_run\``);
|
|
2808
3540
|
}
|
|
2809
3541
|
const topB = str(before.exclude);
|
|
2810
3542
|
const topA = str(after.exclude);
|
|
2811
3543
|
if (topA && topA !== topB && b.size > 0) out2.push(`a top-level pre-commit \`exclude\` was added or changed (${topA}) \u2014 it scopes every hook, the gate included`);
|
|
2812
3544
|
return out2;
|
|
2813
3545
|
}
|
|
2814
|
-
var INSTALLER = /\b(?:husky|lefthook|simple-git-hooks|pre-commit)\b/;
|
|
2815
3546
|
var INSTALL_SCRIPTS = ["prepare", "postinstall", "install", "preinstall"];
|
|
3547
|
+
function installerOf(script) {
|
|
3548
|
+
for (const seg of script.split(/&&|\|\||;|\n/)) {
|
|
3549
|
+
const raw = seg.trim();
|
|
3550
|
+
if (!raw) continue;
|
|
3551
|
+
if (/^(?:HUSKY|LEFTHOOK)=0\b/.test(raw)) continue;
|
|
3552
|
+
const core = raw.replace(PREFIX, "").replace(RUNNER, "").replace(/\s+/g, " ");
|
|
3553
|
+
const m = core.match(/^(?:node\s+)?(?:\S*\/)?(husky|lefthook|simple-git-hooks|pre-commit)(?:\.js)?(?:\s+(\S+))?/);
|
|
3554
|
+
if (!m) continue;
|
|
3555
|
+
const [, tool, sub] = m;
|
|
3556
|
+
if (tool === "husky" && (sub === void 0 || sub === "install" || sub.startsWith("-"))) return sub === "install" ? "husky install" : "husky";
|
|
3557
|
+
if (tool === "lefthook" && sub === "install") return "lefthook install";
|
|
3558
|
+
if (tool === "pre-commit" && sub === "install") return "pre-commit install";
|
|
3559
|
+
if (tool === "simple-git-hooks" && (sub === void 0 || sub.startsWith("-"))) return "simple-git-hooks";
|
|
3560
|
+
}
|
|
3561
|
+
return null;
|
|
3562
|
+
}
|
|
2816
3563
|
function packageJsonWeakening(before, after) {
|
|
2817
3564
|
let b;
|
|
2818
3565
|
let a;
|
|
@@ -2823,13 +3570,14 @@ function packageJsonWeakening(before, after) {
|
|
|
2823
3570
|
return [];
|
|
2824
3571
|
}
|
|
2825
3572
|
if (!isDoc(b) || !isDoc(a)) return [];
|
|
2826
|
-
const
|
|
3573
|
+
const installers = (d) => {
|
|
2827
3574
|
const sc = d.scripts;
|
|
2828
|
-
|
|
3575
|
+
if (!isDoc(sc)) return [];
|
|
3576
|
+
return INSTALL_SCRIPTS.map((k) => installerOf(str(sc[k]))).filter((x) => x !== null);
|
|
2829
3577
|
};
|
|
2830
|
-
const was =
|
|
3578
|
+
const was = installers(b)[0];
|
|
2831
3579
|
if (!was) return [];
|
|
2832
|
-
if (
|
|
3580
|
+
if (installers(a).length) return [];
|
|
2833
3581
|
return [`the install script that wires the git hooks (\`${was}\`) was removed from package.json scripts \u2014 a fresh checkout installs no hooks`];
|
|
2834
3582
|
}
|
|
2835
3583
|
var GIT_HOOKS = /* @__PURE__ */ new Set([
|
|
@@ -2856,7 +3604,7 @@ var GIT_HOOKS = /* @__PURE__ */ new Set([
|
|
|
2856
3604
|
]);
|
|
2857
3605
|
function hookIdentity(path) {
|
|
2858
3606
|
const base = path.split("/").pop() ?? "";
|
|
2859
|
-
if (isLefthook(path)) return "lefthook";
|
|
3607
|
+
if (isLefthook(path)) return isLefthookLocal(path) ? "lefthook-local" : "lefthook";
|
|
2860
3608
|
if (isPreCommitConfig(path)) return "pre-commit-config";
|
|
2861
3609
|
if (base === "CODEOWNERS") return "codeowners";
|
|
2862
3610
|
const husky = path.match(/(?:^|\/)\.husky\/([^/]+)$/);
|
|
@@ -2864,7 +3612,7 @@ function hookIdentity(path) {
|
|
|
2864
3612
|
return null;
|
|
2865
3613
|
}
|
|
2866
3614
|
var ownerExec = (mode) => (parseInt(mode.slice(-3).padStart(3, "0"), 8) & 64) !== 0;
|
|
2867
|
-
var SYMBOLIC = /^[ugoa]*([-+=]
|
|
3615
|
+
var SYMBOLIC = /^([ugoa]*)((?:[-+=][rwxXstugo]*)+)$/;
|
|
2868
3616
|
function chmodDropsExec(toks) {
|
|
2869
3617
|
const at = toks.findIndex((t) => t === "chmod" || t.endsWith("/chmod"));
|
|
2870
3618
|
if (at === -1) return false;
|
|
@@ -2877,31 +3625,113 @@ function chmodDropsExec(toks) {
|
|
|
2877
3625
|
for (const clause of tok.split(",")) {
|
|
2878
3626
|
const m = clause.match(SYMBOLIC);
|
|
2879
3627
|
if (!m) continue;
|
|
2880
|
-
const [,
|
|
2881
|
-
if (
|
|
2882
|
-
|
|
3628
|
+
const [, who, opsText] = m;
|
|
3629
|
+
if (who && !/[ua]/.test(who)) continue;
|
|
3630
|
+
let exec = null;
|
|
3631
|
+
for (const g of opsText.match(/[-+=][rwxXstugo]*/g) ?? []) {
|
|
3632
|
+
const op = g[0];
|
|
3633
|
+
const perms = g.slice(1);
|
|
3634
|
+
if (/[ugo]/.test(perms)) {
|
|
3635
|
+
exec = false;
|
|
3636
|
+
continue;
|
|
3637
|
+
}
|
|
3638
|
+
if (op === "-" && /[xX]/.test(perms)) exec = false;
|
|
3639
|
+
else if (op === "+" && /[xX]/.test(perms)) exec = true;
|
|
3640
|
+
else if (op === "=") exec = /[xX]/.test(perms);
|
|
3641
|
+
}
|
|
3642
|
+
if (exec === false) return true;
|
|
2883
3643
|
}
|
|
2884
3644
|
}
|
|
2885
3645
|
return false;
|
|
2886
3646
|
}
|
|
3647
|
+
function setfaclDropsExec(toks) {
|
|
3648
|
+
if (!toks.some((t) => /^-[a-zA-Z]*m|^--modify/.test(t))) return false;
|
|
3649
|
+
return toks.some((t) => t.split(",").some((e) => {
|
|
3650
|
+
const m = e.match(/^(?:u|user)::([rwxX-]*)$/);
|
|
3651
|
+
return !!m && !/[xX]/.test(m[1]);
|
|
3652
|
+
}));
|
|
3653
|
+
}
|
|
2887
3654
|
var WRAPPERS = /^(?:sudo|command|exec|time|nice|env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/;
|
|
2888
|
-
var
|
|
2889
|
-
var
|
|
2890
|
-
|
|
3655
|
+
var INTERPRETERS = /* @__PURE__ */ new Set(["python", "python2", "python3", "node", "perl", "ruby", "php", "deno", "bun"]);
|
|
3656
|
+
var INLINE_FLAG = /^-(?:c|e|r|E|p|pe|pi|i|ne|ni|le)$|^--eval$|^--print$|^--exec$/;
|
|
3657
|
+
var EDITORS = /* @__PURE__ */ new Set(["ex", "ed", "vim", "vi", "nvim"]);
|
|
3658
|
+
var HOOK_BASENAMES = ["pre-commit", "pre-push", "commit-msg", "settings.json", "settings.local.json", ".tamperward.yml", "lefthook.yml", "lefthook.yaml", "lefthook-local.yml", ".pre-commit-config.yaml", "CODEOWNERS"];
|
|
3659
|
+
function nameGlob(pattern) {
|
|
3660
|
+
const re = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
|
|
3661
|
+
return (n) => re.test(n);
|
|
3662
|
+
}
|
|
3663
|
+
function destinations(cmd, args, isDir) {
|
|
3664
|
+
const positional = [];
|
|
3665
|
+
let target = null;
|
|
3666
|
+
for (let i = 0; i < args.length; i++) {
|
|
3667
|
+
const a = args[i];
|
|
3668
|
+
if (a === "--") {
|
|
3669
|
+
positional.push(...args.slice(i + 1));
|
|
3670
|
+
break;
|
|
3671
|
+
}
|
|
3672
|
+
if (a === "-t" || a === "--target-directory") {
|
|
3673
|
+
target = args[++i] ?? null;
|
|
3674
|
+
continue;
|
|
3675
|
+
}
|
|
3676
|
+
const eq = a.match(/^--target-directory=(.*)$/);
|
|
3677
|
+
if (eq) {
|
|
3678
|
+
target = eq[1];
|
|
3679
|
+
continue;
|
|
3680
|
+
}
|
|
3681
|
+
if (cmd === "install" && /^-[mogS]$/.test(a) || cmd === "install" && /^--(?:mode|owner|group|suffix)$/.test(a) || cmd === "rsync" && /^(?:-e|--rsh|--exclude|--include|--files-from)$/.test(a)) {
|
|
3682
|
+
i++;
|
|
3683
|
+
continue;
|
|
3684
|
+
}
|
|
3685
|
+
if (a.startsWith("-")) continue;
|
|
3686
|
+
positional.push(a);
|
|
3687
|
+
}
|
|
3688
|
+
const base = (p) => p.replace(/\/+$/, "").split("/").pop() ?? p;
|
|
3689
|
+
if (target !== null) return positional.map((p) => `${target.replace(/\/+$/, "")}/${base(p)}`);
|
|
3690
|
+
if (positional.length < 2) return [];
|
|
3691
|
+
const dest = positional[positional.length - 1];
|
|
3692
|
+
const srcs = positional.slice(0, -1);
|
|
3693
|
+
if (dest.endsWith("/") || srcs.length > 1 || isDir(dest)) return [dest, ...srcs.map((s) => `${dest.replace(/\/+$/, "")}/${base(s)}`)];
|
|
3694
|
+
return [dest];
|
|
3695
|
+
}
|
|
3696
|
+
var REDIRECT_TARGET = /(?:^|\s)(?:\d*>{1,2}\|?|&>{1,2})\s*(\S+)/g;
|
|
3697
|
+
function hookTests(policy, ctx) {
|
|
3698
|
+
const rel = (t) => {
|
|
3699
|
+
let p = t.replace(/^["']|["']$/g, "");
|
|
3700
|
+
if (ctx?.cwd && p.startsWith(ctx.cwd + "/")) p = p.slice(ctx.cwd.length + 1);
|
|
3701
|
+
return p.replace(/^\.\/(?=.)/, "");
|
|
3702
|
+
};
|
|
3703
|
+
const hook = (t) => isProtected(rel(t), policy, "hooks");
|
|
3704
|
+
const whole = (t) => /^(?:\.|\.\/|:\/|\*)$/.test(rel(t)) || ctx?.cwd !== void 0 && rel(t) === ctx.cwd;
|
|
3705
|
+
const holds = (t) => !t.startsWith("-") && (whole(t) || containsProtected(rel(t), policy, "hooks", ctx));
|
|
3706
|
+
const hookish = (t) => hook(t) || holds(t);
|
|
3707
|
+
return { rel, hook, whole, holds, hookish };
|
|
3708
|
+
}
|
|
3709
|
+
function shellHookTarget(seg, toks, policy, ctx) {
|
|
3710
|
+
const { rel, hook, whole, holds } = hookTests(policy, ctx);
|
|
2891
3711
|
for (const m of seg.matchAll(REDIRECT_TARGET)) {
|
|
2892
|
-
|
|
2893
|
-
if (isProtected(t, policy, "hooks")) return t;
|
|
3712
|
+
if (hook(m[1])) return rel(m[1]);
|
|
2894
3713
|
}
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
3714
|
+
const args = toks.map((t) => t.replace(/^(?:of=|--(?:include|exclude|directory)=)/, ""));
|
|
3715
|
+
const fileLike = (p) => {
|
|
3716
|
+
const b = p.replace(/\/+$/, "").split("/").pop() ?? "";
|
|
3717
|
+
return HOOK_BASENAMES.includes(b) || /\.[A-Za-z0-9]+$/.test(b.replace(/^\.+/, "")) || (trackedFiles(ctx)?.includes(p) ?? false);
|
|
3718
|
+
};
|
|
3719
|
+
for (const t of args) if (hook(t) && fileLike(rel(t))) return rel(t);
|
|
3720
|
+
for (const t of args) {
|
|
3721
|
+
if (t.startsWith("-") || !holds(t)) continue;
|
|
3722
|
+
const dir = rel(t).replace(/\/+$/, "");
|
|
3723
|
+
const under = whole(t) ? "" : dir + "/";
|
|
3724
|
+
const listed = trackedFiles(ctx)?.find((f) => f.startsWith(under) && isProtected(f, policy, "hooks"));
|
|
3725
|
+
if (listed) return listed;
|
|
3726
|
+
const guess = under + "pre-commit";
|
|
3727
|
+
return isProtected(guess, policy, "hooks") ? guess : whole(t) ? ".tamperward.yml" : dir;
|
|
2898
3728
|
}
|
|
2899
3729
|
return null;
|
|
2900
3730
|
}
|
|
2901
|
-
function shellWritesHook(seg, toks, policy) {
|
|
2902
|
-
const hook
|
|
3731
|
+
function shellWritesHook(seg, toks, policy, ctx) {
|
|
3732
|
+
const { rel, hook, whole, holds, hookish } = hookTests(policy, ctx);
|
|
2903
3733
|
for (const m of seg.matchAll(REDIRECT_TARGET)) {
|
|
2904
|
-
if (hook(m[1]
|
|
3734
|
+
if (hook(m[1])) return "a redirect empties or rewrites a protected hook";
|
|
2905
3735
|
}
|
|
2906
3736
|
let at = 0;
|
|
2907
3737
|
while (at < toks.length && WRAPPERS.test(toks[at])) at++;
|
|
@@ -2911,21 +3741,28 @@ function shellWritesHook(seg, toks, policy) {
|
|
|
2911
3741
|
const last = positional[positional.length - 1] ?? "";
|
|
2912
3742
|
const anyHook = args.some(hook);
|
|
2913
3743
|
const inPlace = args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a));
|
|
3744
|
+
const scripts = args.filter((_, i) => i > 0 && INLINE_FLAG.test(args[i - 1]));
|
|
3745
|
+
const namesHookInText = () => scripts.some((a) => (a.match(/[\w.~/-]+/g) ?? []).some(hook));
|
|
2914
3746
|
switch (cmd) {
|
|
2915
3747
|
case "rm":
|
|
2916
3748
|
case "unlink":
|
|
2917
3749
|
case "shred":
|
|
2918
|
-
return anyHook ? "rm deletes a protected hook" : null;
|
|
3750
|
+
return anyHook || positional.some(holds) ? "rm deletes a protected hook" : null;
|
|
2919
3751
|
case "truncate":
|
|
2920
3752
|
return anyHook ? "truncate empties a protected hook" : null;
|
|
2921
3753
|
case "chmod":
|
|
2922
|
-
return anyHook && chmodDropsExec(toks) ? "chmod removes execute permission from a hook \u2014 git will skip it" : null;
|
|
3754
|
+
return (anyHook || positional.some(holds)) && chmodDropsExec(toks) ? "chmod removes execute permission from a hook \u2014 git will skip it" : null;
|
|
3755
|
+
case "setfacl":
|
|
3756
|
+
return anyHook && setfaclDropsExec(args) ? "setfacl removes the owner execute permission from a hook \u2014 git will skip it" : null;
|
|
2923
3757
|
case "mv":
|
|
2924
|
-
return anyHook ? "mv moves a protected hook out of place or overwrites it" : null;
|
|
3758
|
+
return anyHook || positional.some(holds) ? "mv moves a protected hook out of place or overwrites it" : null;
|
|
2925
3759
|
case "cp":
|
|
2926
3760
|
case "install":
|
|
2927
3761
|
case "ln":
|
|
2928
|
-
|
|
3762
|
+
case "rsync": {
|
|
3763
|
+
const dests = destinations(cmd, args, holds);
|
|
3764
|
+
return dests.some(hook) ? `${cmd} replaces a protected hook in place` : null;
|
|
3765
|
+
}
|
|
2929
3766
|
case "tee":
|
|
2930
3767
|
case "sponge":
|
|
2931
3768
|
return anyHook ? `${cmd} rewrites a protected hook in place` : null;
|
|
@@ -2933,20 +3770,45 @@ function shellWritesHook(seg, toks, policy) {
|
|
|
2933
3770
|
return args.some((a) => a.startsWith("of=") && hook(a.slice(3))) ? "dd rewrites a protected hook in place" : null;
|
|
2934
3771
|
case "sed":
|
|
2935
3772
|
case "perl":
|
|
2936
|
-
|
|
3773
|
+
if (anyHook && inPlace) return `an in-place ${cmd} rewrites a protected hook`;
|
|
3774
|
+
if (cmd === "perl" && args.some((a) => INLINE_FLAG.test(a)) && namesHookInText()) return "an inline perl script names a protected hook";
|
|
3775
|
+
return null;
|
|
2937
3776
|
case "awk":
|
|
2938
3777
|
case "gawk":
|
|
2939
3778
|
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;
|
|
3779
|
+
case "patch":
|
|
3780
|
+
return anyHook ? "patch rewrites a protected hook" : null;
|
|
3781
|
+
case "xargs":
|
|
3782
|
+
return null;
|
|
3783
|
+
// judged by the caller, which sees the segments feeding it
|
|
3784
|
+
case "find": {
|
|
3785
|
+
const mutates = /\s(?:-delete\b|-exec(?:dir)?\s+(?:rm|unlink|chmod|truncate|mv|sed|tee|shred)\b)/.test(seg);
|
|
3786
|
+
if (!mutates) return null;
|
|
3787
|
+
if (/-exec(?:dir)?\s+chmod\b/.test(seg) && !chmodDropsExec(toks)) return null;
|
|
3788
|
+
const dirs = positional.filter((p) => !/^-/.test(p) && !["{}", ";", "+", "rm", "chmod", "unlink", "truncate", "mv", "sed", "tee", "shred", "inplace"].includes(p));
|
|
3789
|
+
const names = args.filter((a, i) => /^-i?(?:name|path|regex)$/.test(args[i - 1] ?? ""));
|
|
3790
|
+
const nameHits = names.length === 0 || names.some((n) => {
|
|
3791
|
+
try {
|
|
3792
|
+
const g = nameGlob(n.replace(/^.*\//, ""));
|
|
3793
|
+
return HOOK_BASENAMES.some(g);
|
|
3794
|
+
} catch {
|
|
3795
|
+
return true;
|
|
3796
|
+
}
|
|
3797
|
+
});
|
|
3798
|
+
const reachesHook = dirs.some((d) => hook(d) || holds(d) || whole(d) && nameHits) || dirs.length === 0 && nameHits;
|
|
3799
|
+
return reachesHook && nameHits ? "find rewrites or removes protected hooks" : null;
|
|
3800
|
+
}
|
|
2940
3801
|
case "git": {
|
|
2941
3802
|
let i = 0;
|
|
2942
3803
|
while (i < args.length && args[i].startsWith("-")) i += args[i] === "-C" || args[i] === "-c" ? 2 : 1;
|
|
2943
3804
|
const sub = args[i];
|
|
2944
3805
|
const rest = args.slice(i + 1);
|
|
2945
|
-
if (
|
|
3806
|
+
if (sub === "apply" || sub === "am") return rest.some((a) => hook(a.replace(/^--(?:include|exclude|directory)=/, ""))) ? `git ${sub} applies a patch that names a protected hook` : null;
|
|
3807
|
+
if (!rest.some(hookish)) return null;
|
|
2946
3808
|
if (sub === "checkout") {
|
|
2947
3809
|
const dd = rest.indexOf("--");
|
|
2948
3810
|
const before = dd === -1 ? rest : rest.slice(0, dd);
|
|
2949
|
-
const revs = before.filter((p) => !p.startsWith("-") && !
|
|
3811
|
+
const revs = before.filter((p) => !p.startsWith("-") && !hookish(p));
|
|
2950
3812
|
return revs.length > 0 ? "git checkout <rev> -- <hook> puts an older version of a protected hook back" : null;
|
|
2951
3813
|
}
|
|
2952
3814
|
if (sub === "restore" && rest.some((a) => /^--source(?:=|$)/.test(a) || a === "-s")) {
|
|
@@ -2955,108 +3817,290 @@ function shellWritesHook(seg, toks, policy) {
|
|
|
2955
3817
|
return null;
|
|
2956
3818
|
}
|
|
2957
3819
|
default:
|
|
3820
|
+
if (INTERPRETERS.has(cmd) && inPlace && anyHook) return `an in-place ${cmd} rewrites a protected hook`;
|
|
3821
|
+
if (INTERPRETERS.has(cmd) && args.some((a) => INLINE_FLAG.test(a)) && namesHookInText()) return `an inline ${cmd} script names a protected hook`;
|
|
3822
|
+
if (EDITORS.has(cmd) && (anyHook || args.some((a) => /^-[a-zA-Z]*[cs]/.test(a) || a === "--cmd") && namesHookInText())) return `${cmd} rewrites a protected hook`;
|
|
2958
3823
|
return null;
|
|
2959
3824
|
}
|
|
2960
3825
|
}
|
|
3826
|
+
function xargsWritesHook(toks, fed, policy, ctx) {
|
|
3827
|
+
const at = toks.findIndex((t) => t === "xargs" || t.endsWith("/xargs"));
|
|
3828
|
+
if (at === -1) return null;
|
|
3829
|
+
const rel = (t) => (ctx?.cwd && t.startsWith(ctx.cwd + "/") ? t.slice(ctx.cwd.length + 1) : t).replace(/^\.\//, "");
|
|
3830
|
+
const fedHook = fed.some((t) => isProtected(rel(t), policy, "hooks") || !t.startsWith("-") && containsProtected(rel(t), policy, "hooks", ctx));
|
|
3831
|
+
if (!fedHook) return null;
|
|
3832
|
+
let i = at + 1;
|
|
3833
|
+
while (i < toks.length && toks[i].startsWith("-")) i += /^-(?:I|n|L|P|d|a|s)$/.test(toks[i]) ? 2 : 1;
|
|
3834
|
+
const cmd = (toks[i] ?? "").replace(/^.*\//, "");
|
|
3835
|
+
const args = toks.slice(i + 1);
|
|
3836
|
+
if (["rm", "unlink", "shred", "truncate", "mv", "tee", "sponge"].includes(cmd)) return `xargs ${cmd} rewrites or removes a protected hook named earlier in the line`;
|
|
3837
|
+
if (cmd === "chmod" && chmodDropsExec(toks.slice(i))) return "xargs chmod removes execute permission from a hook named earlier in the line";
|
|
3838
|
+
if ((cmd === "sed" || cmd === "perl") && args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a))) return `xargs ${cmd} -i rewrites a protected hook named earlier in the line`;
|
|
3839
|
+
return null;
|
|
3840
|
+
}
|
|
2961
3841
|
|
|
2962
3842
|
// src/detectors/hook-tampering.ts
|
|
2963
3843
|
var RULE6 = "hook-tampering";
|
|
2964
3844
|
var ownerExec2 = (mode) => (parseInt(mode.slice(-3), 8) & 64) !== 0;
|
|
2965
|
-
var CLAUDE_SETTINGS = /(?:^|\/)\.claude\/settings(?:\.local)?\.json$/;
|
|
2966
|
-
var isClaudeSettings = (p) => CLAUDE_SETTINGS.test(p);
|
|
2967
|
-
function siblingSettings(p) {
|
|
2968
|
-
return p.endsWith("settings.local.json") ? p.replace(/settings\.local\.json$/, "settings.json") : p.replace(/settings\.json$/, "settings.local.json");
|
|
2969
|
-
}
|
|
2970
|
-
var TW_INVOCATION = /\btamperward(?:@\S+)?\s+(?:hook|sweep)\s+claude\b/;
|
|
2971
3845
|
var EVENTS = ["PreToolUse", "Stop"];
|
|
3846
|
+
var isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
2972
3847
|
function parseSettings(src) {
|
|
2973
3848
|
try {
|
|
2974
3849
|
const v = JSON.parse(src);
|
|
2975
|
-
return
|
|
3850
|
+
return isObj(v) ? v : null;
|
|
2976
3851
|
} catch {
|
|
2977
3852
|
return null;
|
|
2978
3853
|
}
|
|
2979
3854
|
}
|
|
2980
|
-
function toolSet(matcher2) {
|
|
2981
|
-
const m = typeof matcher2 === "string" ? matcher2.trim() : "";
|
|
2982
|
-
if (m === "" || m === "*") return null;
|
|
2983
|
-
return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
|
|
2984
|
-
}
|
|
2985
3855
|
function eventEntries(s, event) {
|
|
2986
3856
|
const hooks = s.hooks;
|
|
2987
|
-
if (
|
|
3857
|
+
if (!isObj(hooks)) return null;
|
|
2988
3858
|
const arr = hooks[event];
|
|
2989
3859
|
return Array.isArray(arr) ? arr : null;
|
|
2990
3860
|
}
|
|
2991
3861
|
var declares = (s, event) => eventEntries(s, event) !== null;
|
|
2992
|
-
|
|
3862
|
+
var GATE_KEYS = /* @__PURE__ */ new Set(["type", "command", "timeout"]);
|
|
3863
|
+
var CANONICAL_CMD = /^npx --yes tamperward@(\S+) (hook|sweep) claude$/;
|
|
3864
|
+
var MENTIONS_GATE = /\btamperward\b.*\b(?:hook|sweep)\b/;
|
|
3865
|
+
var FORGERY = /hookSpecificOutput|permissionDecision|updatedInput|"decision"|"continue"/;
|
|
3866
|
+
var SENSITIVE_ENV = /^(?:PATH|NODE_OPTIONS|NODE_PATH|HOME|npm_config_.*|NPM_CONFIG_.*)$/;
|
|
3867
|
+
function analyseGateCommand(cmd, event) {
|
|
3868
|
+
const want = event === "PreToolUse" ? "hook" : "sweep";
|
|
3869
|
+
const problems = [];
|
|
3870
|
+
let runs = true;
|
|
3871
|
+
const canonical2 = cmd.match(CANONICAL_CMD);
|
|
3872
|
+
if (canonical2) {
|
|
3873
|
+
if (canonical2[2] !== want) {
|
|
3874
|
+
problems.push(`runs \`${canonical2[2]} claude\` under ${event}`);
|
|
3875
|
+
runs = false;
|
|
3876
|
+
}
|
|
3877
|
+
if (!PLAIN_SEMVER.test(canonical2[1])) {
|
|
3878
|
+
problems.push(`the pin \`${canonical2[1]}\` is not a plain version`);
|
|
3879
|
+
runs = false;
|
|
3880
|
+
}
|
|
3881
|
+
return { runs, problems, pin: canonical2[1] };
|
|
3882
|
+
}
|
|
3883
|
+
const inv = cmd.match(/\btamperward(?:@(\S+))?\s+(hook|sweep)\s+(\S+)/);
|
|
3884
|
+
if (!inv) return { runs: false, problems: ["the command does not invoke the gate"], pin: null };
|
|
3885
|
+
if (inv[2] !== want || inv[3] !== "claude") {
|
|
3886
|
+
problems.push(`runs \`${inv[2]} ${inv[3]}\` under ${event}`);
|
|
3887
|
+
runs = false;
|
|
3888
|
+
}
|
|
3889
|
+
if (inv[1] !== void 0 && !PLAIN_SEMVER.test(inv[1])) {
|
|
3890
|
+
problems.push(`the pin \`${inv[1]}\` is not a plain version`);
|
|
3891
|
+
runs = false;
|
|
3892
|
+
}
|
|
3893
|
+
if (/[|;&<>`]|\$\(/.test(cmd)) {
|
|
3894
|
+
problems.push("text around the invocation (a redirect, pipe or chain)");
|
|
3895
|
+
runs = false;
|
|
3896
|
+
}
|
|
3897
|
+
if (/^\s*[A-Za-z_]\w*=/.test(cmd)) {
|
|
3898
|
+
problems.push("an environment assignment in front of the gate");
|
|
3899
|
+
runs = false;
|
|
3900
|
+
}
|
|
3901
|
+
if (/\b(?:sh|bash|zsh|dash)\s+-[a-zA-Z]*c\b/.test(cmd)) {
|
|
3902
|
+
problems.push("wrapped in `sh -c`");
|
|
3903
|
+
runs = false;
|
|
3904
|
+
}
|
|
3905
|
+
if (/(?:^|\s)(?:-p|--package)(?:=|\s)/.test(cmd)) {
|
|
3906
|
+
problems.push("`-p`/`--package` points npx at another package");
|
|
3907
|
+
runs = false;
|
|
3908
|
+
}
|
|
3909
|
+
const at = cmd.indexOf(inv[0]);
|
|
3910
|
+
const trailing = cmd.slice(at + inv[0].length).trim();
|
|
3911
|
+
if (trailing) {
|
|
3912
|
+
problems.push(`extra text after the invocation (\`${trailing}\`)`);
|
|
3913
|
+
runs = false;
|
|
3914
|
+
}
|
|
3915
|
+
const head = cmd.slice(0, at).trim();
|
|
3916
|
+
if (head !== "npx --yes") problems.push(`the launcher \`${head || "(none)"}\` is not \`npx --yes\``);
|
|
3917
|
+
return { runs, problems, pin: inv[1] ?? "" };
|
|
3918
|
+
}
|
|
3919
|
+
function matcherSelection(m) {
|
|
3920
|
+
if (m === void 0) return { tools: null, problem: null };
|
|
3921
|
+
if (typeof m !== "string") return { tools: /* @__PURE__ */ new Set(), problem: `the matcher ${JSON.stringify(m)} is not a string` };
|
|
3922
|
+
if (m === "" || m === "*") return { tools: null, problem: null };
|
|
3923
|
+
let problem = null;
|
|
3924
|
+
if (/[^\x00-\x7f]/.test(m)) problem = "the matcher contains non-ASCII characters that look like tool names but are not";
|
|
3925
|
+
if (/^[A-Za-z0-9_ |-]+$/.test(m)) return { tools: new Set(m.split("|").map((t) => t.trim()).filter(Boolean)), problem };
|
|
3926
|
+
let re;
|
|
3927
|
+
try {
|
|
3928
|
+
re = new RegExp(m);
|
|
3929
|
+
} catch {
|
|
3930
|
+
return { tools: /* @__PURE__ */ new Set(), problem: `the matcher \`${m}\` is not a valid pattern \u2014 it selects nothing` };
|
|
3931
|
+
}
|
|
3932
|
+
return { tools: new Set(PRE_TOOLS.filter((t) => re.test(t))), problem };
|
|
3933
|
+
}
|
|
3934
|
+
function gateCandidates(s, event) {
|
|
2993
3935
|
const out2 = [];
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
if (
|
|
3002
|
-
|
|
3003
|
-
|
|
3936
|
+
const mentions = (v) => /\btamperward\b/.test(JSON.stringify(v) ?? "");
|
|
3937
|
+
for (const group of eventEntries(s, event) ?? []) {
|
|
3938
|
+
if (!isObj(group)) {
|
|
3939
|
+
if (mentions(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["the matcher entry is not an object"], pin: null, tools: null });
|
|
3940
|
+
continue;
|
|
3941
|
+
}
|
|
3942
|
+
if (!Array.isArray(group.hooks)) {
|
|
3943
|
+
if (mentions(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["`hooks` is not a list"], pin: null, tools: null });
|
|
3944
|
+
continue;
|
|
3945
|
+
}
|
|
3946
|
+
for (const h of group.hooks) {
|
|
3947
|
+
if (!isObj(h)) {
|
|
3948
|
+
if (mentions(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the hook entry is not an object"], pin: null, tools: null });
|
|
3949
|
+
continue;
|
|
3950
|
+
}
|
|
3951
|
+
const cmd = typeof h.command === "string" ? h.command : null;
|
|
3952
|
+
if (cmd === null) {
|
|
3953
|
+
if (mentions(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the gate is not in a `command` string"], pin: null, tools: null });
|
|
3954
|
+
continue;
|
|
3955
|
+
}
|
|
3956
|
+
if (!MENTIONS_GATE.test(cmd)) continue;
|
|
3957
|
+
const a = analyseGateCommand(cmd, event);
|
|
3958
|
+
const problems = [...a.problems];
|
|
3959
|
+
let runs = a.runs;
|
|
3960
|
+
if (h.type !== "command") {
|
|
3961
|
+
problems.push(h.type === void 0 ? "no `type`" : `\`type\` is ${JSON.stringify(h.type)}`);
|
|
3962
|
+
if (h.type !== void 0) runs = false;
|
|
3963
|
+
}
|
|
3964
|
+
for (const k of Object.keys(h)) {
|
|
3965
|
+
if (GATE_KEYS.has(k)) continue;
|
|
3966
|
+
problems.push(`carries \`${k}\``);
|
|
3967
|
+
runs = false;
|
|
3968
|
+
}
|
|
3969
|
+
if ("timeout" in h) {
|
|
3970
|
+
const t = h.timeout;
|
|
3971
|
+
if (typeof t !== "number" || !Number.isInteger(t) || t < 120) {
|
|
3972
|
+
problems.push(`\`timeout\` ${JSON.stringify(t)} \u2014 the gate is cut off before it answers`);
|
|
3973
|
+
runs = false;
|
|
3974
|
+
}
|
|
3975
|
+
}
|
|
3976
|
+
let tools = null;
|
|
3977
|
+
if (event === "PreToolUse") {
|
|
3978
|
+
const sel = matcherSelection(group.matcher);
|
|
3979
|
+
tools = sel.tools;
|
|
3980
|
+
if (sel.problem) {
|
|
3981
|
+
problems.push(sel.problem);
|
|
3982
|
+
}
|
|
3983
|
+
} else if (group.matcher !== void 0 && !(typeof group.matcher === "string" && (group.matcher === "" || group.matcher === "*"))) {
|
|
3984
|
+
problems.push(`a \`matcher\` (${JSON.stringify(group.matcher)}) on the Stop entry`);
|
|
3985
|
+
runs = false;
|
|
3986
|
+
}
|
|
3987
|
+
out2.push({ key: JSON.stringify({ m: group.matcher, h }), runs, problems, pin: a.pin, tools });
|
|
3004
3988
|
}
|
|
3005
3989
|
}
|
|
3006
3990
|
return out2;
|
|
3007
3991
|
}
|
|
3008
|
-
function
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3992
|
+
function otherCommands(s, event) {
|
|
3993
|
+
const out2 = [];
|
|
3994
|
+
for (const group of eventEntries(s, event) ?? []) {
|
|
3995
|
+
if (!isObj(group) || !Array.isArray(group.hooks)) continue;
|
|
3996
|
+
for (const h of group.hooks) {
|
|
3997
|
+
if (!isObj(h) || typeof h.command !== "string" || MENTIONS_GATE.test(h.command)) continue;
|
|
3998
|
+
out2.push(h.command);
|
|
3999
|
+
}
|
|
4000
|
+
}
|
|
4001
|
+
return out2;
|
|
4002
|
+
}
|
|
4003
|
+
function coverage(cs) {
|
|
4004
|
+
const live = cs.filter((c) => c.runs);
|
|
4005
|
+
if (live.length === 0) return "none";
|
|
4006
|
+
if (live.some((c) => c.tools === null)) return null;
|
|
4007
|
+
return new Set(live.flatMap((c) => [...c.tools]));
|
|
4008
|
+
}
|
|
4009
|
+
function duplicateJsonKeys(src) {
|
|
4010
|
+
const dups = [];
|
|
4011
|
+
const stack = [];
|
|
4012
|
+
let expectKey = false;
|
|
4013
|
+
for (let i = 0; i < src.length; i++) {
|
|
4014
|
+
const ch = src[i];
|
|
4015
|
+
if (ch === '"') {
|
|
4016
|
+
let j = i + 1;
|
|
4017
|
+
let s = "";
|
|
4018
|
+
while (j < src.length && src[j] !== '"') {
|
|
4019
|
+
if (src[j] === "\\") {
|
|
4020
|
+
s += src[j + 1] ?? "";
|
|
4021
|
+
j += 2;
|
|
4022
|
+
} else {
|
|
4023
|
+
s += src[j];
|
|
4024
|
+
j++;
|
|
4025
|
+
}
|
|
4026
|
+
}
|
|
4027
|
+
if (expectKey) {
|
|
4028
|
+
const set = stack[stack.length - 1];
|
|
4029
|
+
if (set) {
|
|
4030
|
+
if (set.has(s)) dups.push(s);
|
|
4031
|
+
set.add(s);
|
|
4032
|
+
}
|
|
4033
|
+
expectKey = false;
|
|
4034
|
+
}
|
|
4035
|
+
i = j;
|
|
4036
|
+
continue;
|
|
4037
|
+
}
|
|
4038
|
+
if (ch === "{") {
|
|
4039
|
+
stack.push(/* @__PURE__ */ new Set());
|
|
4040
|
+
expectKey = true;
|
|
4041
|
+
} else if (ch === "[") {
|
|
4042
|
+
stack.push(null);
|
|
4043
|
+
expectKey = false;
|
|
4044
|
+
} else if (ch === "}" || ch === "]") {
|
|
4045
|
+
stack.pop();
|
|
4046
|
+
expectKey = false;
|
|
4047
|
+
} else if (ch === ",") expectKey = stack[stack.length - 1] !== null;
|
|
4048
|
+
}
|
|
4049
|
+
return [...new Set(dups)];
|
|
3012
4050
|
}
|
|
3013
|
-
|
|
3014
|
-
function settingsWeakening(before, after, sibling) {
|
|
4051
|
+
function settingsWeakening(before, after, local) {
|
|
3015
4052
|
const reasons = [];
|
|
3016
|
-
|
|
3017
|
-
|
|
4053
|
+
const dis = after.disableAllHooks;
|
|
4054
|
+
if (dis !== void 0 && dis !== false && !(before !== null && before.disableAllHooks === dis)) {
|
|
4055
|
+
reasons.push(`\`disableAllHooks: ${JSON.stringify(dis)}\` switches every hook off, the gate included`);
|
|
4056
|
+
}
|
|
4057
|
+
if (isObj(after.env)) {
|
|
4058
|
+
const was = before !== null && isObj(before.env) ? before.env : {};
|
|
4059
|
+
for (const [k, v] of Object.entries(after.env)) {
|
|
4060
|
+
if (SENSITIVE_ENV.test(k) && JSON.stringify(was[k]) !== JSON.stringify(v)) {
|
|
4061
|
+
reasons.push(`a top-level \`env\` sets ${k} \u2014 the gate command resolves through it`);
|
|
4062
|
+
}
|
|
4063
|
+
}
|
|
3018
4064
|
}
|
|
3019
4065
|
for (const event of EVENTS) {
|
|
3020
|
-
const
|
|
3021
|
-
const
|
|
4066
|
+
const bc = before !== null ? gateCandidates(before, event) : [];
|
|
4067
|
+
const ac = gateCandidates(after, event);
|
|
4068
|
+
const was = coverage(bc);
|
|
4069
|
+
const now = coverage(ac);
|
|
3022
4070
|
if (was !== "none") {
|
|
3023
4071
|
if (now === "none") {
|
|
3024
4072
|
reasons.push(`the tamperward ${event} hook entry was removed`);
|
|
3025
4073
|
} else if (event === "PreToolUse") {
|
|
3026
4074
|
if (was === null && now !== null) {
|
|
3027
|
-
reasons.push(`the PreToolUse matcher for the gate was narrowed from every tool to ${[...now].join("|")}`);
|
|
4075
|
+
reasons.push(`the PreToolUse matcher for the gate was narrowed from every tool to ${[...now].join("|") || "(nothing)"}`);
|
|
3028
4076
|
} else if (was !== null && now !== null) {
|
|
3029
4077
|
const lost = [...was].filter((t) => !now.has(t));
|
|
3030
4078
|
if (lost.length) reasons.push(`the PreToolUse matcher for the gate no longer covers ${lost.join(", ")}`);
|
|
3031
4079
|
}
|
|
3032
4080
|
}
|
|
3033
|
-
|
|
4081
|
+
} else if (now === "none" && declares(after, event) && !(before !== null && declares(before, event)) && (eventEntries(after, event) ?? []).length === 0) {
|
|
4082
|
+
reasons.push(`${event} hooks declared empty in ${local ? "an override file" : "the settings file"}`);
|
|
4083
|
+
}
|
|
4084
|
+
const had = new Set(bc.map((c) => c.key));
|
|
4085
|
+
const fresh = ac.filter((c) => !had.has(c.key));
|
|
4086
|
+
for (const c of fresh) {
|
|
4087
|
+
if (c.problems.length) reasons.push(`the ${event} gate entry does not match the shape \`tamperward init\` writes: ${c.problems.join("; ")}`);
|
|
4088
|
+
}
|
|
4089
|
+
const pins = bc.map((c) => c.pin).filter((p) => !!p && PLAIN_SEMVER.test(p)).sort((a, b) => compareVersions(b, a) ?? 0);
|
|
4090
|
+
const floor = pins[0] ?? (before === null && PLAIN_SEMVER.test(TW_VERSION) ? TW_VERSION : null);
|
|
4091
|
+
if (floor) {
|
|
4092
|
+
for (const c of fresh) {
|
|
4093
|
+
if (c.pin && PLAIN_SEMVER.test(c.pin) && !pinNotBelow(c.pin, floor)) reasons.push(`the ${event} gate is pinned to tamperward@${c.pin}, below ${floor}`);
|
|
4094
|
+
}
|
|
3034
4095
|
}
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
const shadowed = sibling !== null && gateWiring(sibling, event).length > 0;
|
|
3039
|
-
if (empty2 || shadowed) {
|
|
3040
|
-
reasons.push(
|
|
3041
|
-
`${event} hooks declared ${empty2 ? "empty" : "without the tamperward entry"} in an override file` + (shadowed ? " \u2014 this shadows the gate the sibling settings file wires" : "")
|
|
3042
|
-
);
|
|
4096
|
+
const hadOther = new Set(before !== null ? otherCommands(before, event) : []);
|
|
4097
|
+
for (const cmd of otherCommands(after, event)) {
|
|
4098
|
+
if (!hadOther.has(cmd) && FORGERY.test(cmd)) reasons.push(`a ${event} hook entry beside the gate emits a hook decision of its own (\`${cmd.slice(0, 60)}\`)`);
|
|
3043
4099
|
}
|
|
3044
4100
|
}
|
|
3045
4101
|
return reasons;
|
|
3046
4102
|
}
|
|
3047
|
-
function
|
|
3048
|
-
const path = siblingSettings(c.path);
|
|
3049
|
-
const inSet = changes.find((o) => o.kind === "file" && o.path === path);
|
|
3050
|
-
if (inSet) return inSet.after != null ? parseSettings(inSet.after) : null;
|
|
3051
|
-
if (!ctx?.cwd) return null;
|
|
3052
|
-
try {
|
|
3053
|
-
const p = join2(ctx.cwd, path);
|
|
3054
|
-
return existsSync(p) ? parseSettings(readFileSync2(p, "utf8")) : null;
|
|
3055
|
-
} catch {
|
|
3056
|
-
return null;
|
|
3057
|
-
}
|
|
3058
|
-
}
|
|
3059
|
-
function claudeSettingsFindings(c, changes, policy, ctx) {
|
|
4103
|
+
function claudeSettingsFindings(c, policy) {
|
|
3060
4104
|
if (c.after == null) return null;
|
|
3061
4105
|
const after = parseSettings(c.after);
|
|
3062
4106
|
const before = c.before != null ? parseSettings(c.before) : null;
|
|
@@ -3064,14 +4108,19 @@ function claudeSettingsFindings(c, changes, policy, ctx) {
|
|
|
3064
4108
|
file: c.path,
|
|
3065
4109
|
message: `The Claude Code hook wiring was weakened: ${reason}.`,
|
|
3066
4110
|
evidence: reason,
|
|
3067
|
-
remediation: "Leave the PreToolUse and Stop entries that run tamperward, and the matcher that covers every editing tool, exactly as `tamperward init` wrote them. Editing the wiring is editing the gate."
|
|
4111
|
+
remediation: "Leave the PreToolUse and Stop entries that run tamperward, and the matcher that covers every editing tool, exactly as `tamperward init` wrote them. Editing the wiring is editing the gate: re-run `tamperward init` to restore it, or sign off."
|
|
3068
4112
|
});
|
|
3069
4113
|
if (c.before != null && before === null) return null;
|
|
3070
4114
|
if (after === null) {
|
|
3071
4115
|
if (c.before == null) return [];
|
|
3072
4116
|
return [finding("the settings file no longer parses as JSON \u2014 the runtime ignores it, hooks and all")];
|
|
3073
4117
|
}
|
|
3074
|
-
|
|
4118
|
+
const reasons = settingsWeakening(before, after, /settings\.local\.json$/.test(c.path));
|
|
4119
|
+
const had = new Set(c.before != null ? duplicateJsonKeys(c.before) : []);
|
|
4120
|
+
for (const k of duplicateJsonKeys(c.after)) {
|
|
4121
|
+
if (!had.has(k)) reasons.push(`the key "${k}" appears twice \u2014 which reading the runtime keeps is not something the gate can vouch for`);
|
|
4122
|
+
}
|
|
4123
|
+
return reasons.map(finding);
|
|
3075
4124
|
}
|
|
3076
4125
|
function policyFindings(c, policy) {
|
|
3077
4126
|
if (c.after == null) return null;
|
|
@@ -3093,12 +4142,54 @@ function renameKept(c) {
|
|
|
3093
4142
|
if (id === null || hookIdentity(c.path) !== id) return false;
|
|
3094
4143
|
return c.before != null && c.after != null ? c.before === c.after : c.hunks.length === 0;
|
|
3095
4144
|
}
|
|
3096
|
-
function
|
|
3097
|
-
|
|
3098
|
-
const
|
|
3099
|
-
const
|
|
3100
|
-
if (
|
|
3101
|
-
|
|
4145
|
+
function lefthookBase(c, changes, ctx) {
|
|
4146
|
+
const dir = c.path.includes("/") ? c.path.slice(0, c.path.lastIndexOf("/") + 1) : "";
|
|
4147
|
+
const names = ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml"].map((n) => dir + n);
|
|
4148
|
+
const inSet = changes.find((o) => o.kind === "file" && names.includes(o.path));
|
|
4149
|
+
if (inSet) return inSet.after != null ? parseDoc(inSet.after) : null;
|
|
4150
|
+
if (!ctx?.cwd) return null;
|
|
4151
|
+
for (const n of names) {
|
|
4152
|
+
try {
|
|
4153
|
+
const p = join3(ctx.cwd, n);
|
|
4154
|
+
if (existsSync(p)) return parseDoc(readFileSync3(p, "utf8"));
|
|
4155
|
+
} catch {
|
|
4156
|
+
}
|
|
4157
|
+
}
|
|
4158
|
+
return null;
|
|
4159
|
+
}
|
|
4160
|
+
function syntheticBase(over) {
|
|
4161
|
+
const base = {};
|
|
4162
|
+
for (const [section, v] of Object.entries(over)) {
|
|
4163
|
+
if (!isObj(v)) continue;
|
|
4164
|
+
for (const group of ["commands", "scripts"]) {
|
|
4165
|
+
const g = v[group];
|
|
4166
|
+
if (!isObj(g)) continue;
|
|
4167
|
+
for (const name of Object.keys(g)) {
|
|
4168
|
+
if (!/tamperward/.test(name)) continue;
|
|
4169
|
+
const sec = base[section] ??= {};
|
|
4170
|
+
const grp = sec[group] ??= {};
|
|
4171
|
+
grp[name] = { run: "npx tamperward check --staged" };
|
|
4172
|
+
}
|
|
4173
|
+
}
|
|
4174
|
+
}
|
|
4175
|
+
return base;
|
|
4176
|
+
}
|
|
4177
|
+
function configFindings(c, changes, policy, ctx) {
|
|
4178
|
+
if (c.after == null) return null;
|
|
4179
|
+
let reasons;
|
|
4180
|
+
if (isLefthookLocal(c.path)) {
|
|
4181
|
+
const overBefore = c.before != null ? parseDoc(c.before) : {};
|
|
4182
|
+
const overAfter = parseDoc(c.after);
|
|
4183
|
+
if (overBefore === null) return null;
|
|
4184
|
+
const base = lefthookBase(c, changes, ctx) ?? syntheticBase(overAfter ?? {});
|
|
4185
|
+
reasons = overAfter === null ? ["the file no longer parses as YAML \u2014 the tool that reads it runs nothing"] : lefthookWeakening(mergeDocs(base, overBefore), mergeDocs(base, overAfter));
|
|
4186
|
+
} else {
|
|
4187
|
+
if (c.before == null) return null;
|
|
4188
|
+
const before = parseDoc(c.before);
|
|
4189
|
+
const after = parseDoc(c.after);
|
|
4190
|
+
if (before === null) return null;
|
|
4191
|
+
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);
|
|
4192
|
+
}
|
|
3102
4193
|
return reasons.map(
|
|
3103
4194
|
(reason) => makeFinding(RULE6, policy, {
|
|
3104
4195
|
file: c.path,
|
|
@@ -3108,6 +4199,11 @@ function configFindings(c, policy) {
|
|
|
3108
4199
|
})
|
|
3109
4200
|
);
|
|
3110
4201
|
}
|
|
4202
|
+
function repoGateFiles(ctx) {
|
|
4203
|
+
const files = trackedFiles(ctx);
|
|
4204
|
+
if (!files) return [];
|
|
4205
|
+
return files.filter((f) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(f) || /^\.husky\/[^/_][^/]*$/.test(f));
|
|
4206
|
+
}
|
|
3111
4207
|
function updateIndexHiding(toks, policy) {
|
|
3112
4208
|
if (!toks.some((t) => t === "git" || t.endsWith("/git")) || !toks.includes("update-index")) return null;
|
|
3113
4209
|
const hide = toks.find((t) => /^--(?:skip-worktree|assume-unchanged)$/.test(t));
|
|
@@ -3129,6 +4225,7 @@ function repoRelative(path, ctx) {
|
|
|
3129
4225
|
const rel = relative(ctx.cwd, path);
|
|
3130
4226
|
return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
|
|
3131
4227
|
}
|
|
4228
|
+
var isHuskyScript = (path) => /(?:^|\/)\.husky\/[^/]+$/.test(path);
|
|
3132
4229
|
var hookTampering = {
|
|
3133
4230
|
id: RULE6,
|
|
3134
4231
|
surface: ["file", "command"],
|
|
@@ -3138,7 +4235,7 @@ var hookTampering = {
|
|
|
3138
4235
|
for (const c of changes) {
|
|
3139
4236
|
let semantic;
|
|
3140
4237
|
if (c.kind === "file") {
|
|
3141
|
-
const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false);
|
|
4238
|
+
const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false) || isClaudeSettings(c.path);
|
|
3142
4239
|
if (!targetsHook) {
|
|
3143
4240
|
if (isPackageJson(c.path) && c.before != null && c.after != null) {
|
|
3144
4241
|
for (const reason of packageJsonWeakening(c.before, c.after)) {
|
|
@@ -3182,12 +4279,12 @@ var hookTampering = {
|
|
|
3182
4279
|
remediation: "Renaming a hook out of place disables it. Restore the original path."
|
|
3183
4280
|
})
|
|
3184
4281
|
);
|
|
3185
|
-
} else if ((c.path.endsWith(POLICY_FILE) || isClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c,
|
|
4282
|
+
} else if ((c.path.endsWith(POLICY_FILE) || isClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c, policy)) !== null) {
|
|
3186
4283
|
out2.push(...semantic);
|
|
3187
4284
|
} else if (c.op === "rename") {
|
|
3188
4285
|
} else if (isCodeowners(c.path)) {
|
|
3189
4286
|
if (c.before != null && c.after != null) {
|
|
3190
|
-
for (const reason of codeownersWeakening(c.before, c.after)) {
|
|
4287
|
+
for (const reason of codeownersWeakening(c.before, c.after, [...repoGateFiles(ctx), c.path])) {
|
|
3191
4288
|
out2.push(
|
|
3192
4289
|
makeFinding(RULE6, policy, {
|
|
3193
4290
|
file: c.path,
|
|
@@ -3198,9 +4295,40 @@ var hookTampering = {
|
|
|
3198
4295
|
);
|
|
3199
4296
|
}
|
|
3200
4297
|
}
|
|
3201
|
-
} else if ((isLefthook(c.path) || isPreCommitConfig(c.path)) && (semantic = configFindings(c, policy)) !== null) {
|
|
4298
|
+
} else if ((isLefthook(c.path) || isPreCommitConfig(c.path)) && (semantic = configFindings(c, changes, policy, ctx)) !== null) {
|
|
3202
4299
|
out2.push(...semantic);
|
|
3203
4300
|
} else if (!c.path.endsWith(POLICY_FILE)) {
|
|
4301
|
+
const husky = isHuskyScript(c.path);
|
|
4302
|
+
const beforePin = c.before != null && c.after != null ? initScriptPin(c.before) : null;
|
|
4303
|
+
if (beforePin !== null) {
|
|
4304
|
+
const afterPin = initScriptPin(c.after);
|
|
4305
|
+
const kept = afterPin !== null && (beforePin === "" ? afterPin === "" || PLAIN_SEMVER.test(afterPin) : pinNotBelow(afterPin, beforePin));
|
|
4306
|
+
if (!kept) {
|
|
4307
|
+
out2.push(
|
|
4308
|
+
makeFinding(RULE6, policy, {
|
|
4309
|
+
file: c.path,
|
|
4310
|
+
message: "A protected hook script no longer matches the shape `tamperward init` writes.",
|
|
4311
|
+
evidence: "the gate script no longer matches the shape init wrote; re-run init or sign off",
|
|
4312
|
+
remediation: "The pre-commit script init writes is the marker line and the pinned staged check, nothing else. Re-run `tamperward init` to restore it, or sign off on the customised script."
|
|
4313
|
+
})
|
|
4314
|
+
);
|
|
4315
|
+
}
|
|
4316
|
+
continue;
|
|
4317
|
+
}
|
|
4318
|
+
if (!husky && c.after != null) {
|
|
4319
|
+
const problem = shebangProblem(c.after);
|
|
4320
|
+
if (problem && (c.before == null || problem !== shebangProblem(c.before))) {
|
|
4321
|
+
out2.push(
|
|
4322
|
+
makeFinding(RULE6, policy, {
|
|
4323
|
+
file: c.path,
|
|
4324
|
+
line: 1,
|
|
4325
|
+
message: `A protected hook's interpreter was changed: ${problem}.`,
|
|
4326
|
+
evidence: c.after.split("\n")[0],
|
|
4327
|
+
remediation: "Keep a shell in shebang position. git execs the hook directly, and an interpreter that never reads the script is a disabled hook."
|
|
4328
|
+
})
|
|
4329
|
+
);
|
|
4330
|
+
}
|
|
4331
|
+
}
|
|
3204
4332
|
const added = addedLines(c);
|
|
3205
4333
|
const earlyExit = added.find((l) => insertsPassingExit(l.content));
|
|
3206
4334
|
if (earlyExit) {
|
|
@@ -3228,7 +4356,7 @@ var hookTampering = {
|
|
|
3228
4356
|
}
|
|
3229
4357
|
const beforeLines = c.before != null ? c.before.split("\n") : removedLines(c).map((l) => l.content);
|
|
3230
4358
|
const afterLines = c.after != null ? c.after.split("\n") : added.map((l) => l.content);
|
|
3231
|
-
const lost = scriptWeakening(beforeLines, afterLines);
|
|
4359
|
+
const lost = scriptWeakening(beforeLines, afterLines, husky ? { errexit: true } : {});
|
|
3232
4360
|
if (lost.length) {
|
|
3233
4361
|
out2.push(
|
|
3234
4362
|
makeFinding(RULE6, policy, {
|
|
@@ -3272,6 +4400,7 @@ var hookTampering = {
|
|
|
3272
4400
|
}
|
|
3273
4401
|
}
|
|
3274
4402
|
} else {
|
|
4403
|
+
const fed = [];
|
|
3275
4404
|
for (const seg of segments(c.raw)) {
|
|
3276
4405
|
const toks = tokens(seg).map(unquote);
|
|
3277
4406
|
const hiding = updateIndexHiding(toks, policy);
|
|
@@ -3284,11 +4413,12 @@ var hookTampering = {
|
|
|
3284
4413
|
remediation: "Leave protected files visible to git. Hiding one from the diff hides it from the gate, which is the tamper \u2014 use `--no-skip-worktree` / `--no-assume-unchanged` to undo it."
|
|
3285
4414
|
})
|
|
3286
4415
|
);
|
|
4416
|
+
fed.push(...toks);
|
|
3287
4417
|
continue;
|
|
3288
4418
|
}
|
|
3289
|
-
const why = shellWritesHook(seg, toks, policy);
|
|
4419
|
+
const why = shellWritesHook(seg, toks, policy, ctx) ?? xargsWritesHook(toks, fed, policy, ctx);
|
|
3290
4420
|
if (why) {
|
|
3291
|
-
const target = shellHookTarget(seg, toks, policy);
|
|
4421
|
+
const target = shellHookTarget(seg, toks, policy, ctx) ?? shellHookTarget("", fed, policy, ctx);
|
|
3292
4422
|
out2.push(
|
|
3293
4423
|
makeFinding(RULE6, policy, {
|
|
3294
4424
|
...target ? { file: repoRelative(target, ctx) } : {},
|
|
@@ -3298,6 +4428,7 @@ var hookTampering = {
|
|
|
3298
4428
|
})
|
|
3299
4429
|
);
|
|
3300
4430
|
}
|
|
4431
|
+
fed.push(...toks);
|
|
3301
4432
|
}
|
|
3302
4433
|
}
|
|
3303
4434
|
}
|
|
@@ -3390,21 +4521,20 @@ function collect(root, runner) {
|
|
|
3390
4521
|
const entries = take(key2, e).map((pattern) => ({ pattern, key: key2 }));
|
|
3391
4522
|
sel.ignore = [...sel.ignore ?? [], ...entries];
|
|
3392
4523
|
};
|
|
4524
|
+
let projectList = null;
|
|
3393
4525
|
const projects = (e) => {
|
|
3394
4526
|
sel.present = true;
|
|
3395
|
-
|
|
4527
|
+
projectList = e;
|
|
4528
|
+
};
|
|
4529
|
+
const visit = (node) => {
|
|
4530
|
+
if (ts3.isSpreadAssignment(node) && spreadsIntoConfig(node, runner)) {
|
|
4531
|
+
sel.present = true;
|
|
3396
4532
|
sel.opaque = true;
|
|
3397
|
-
return;
|
|
3398
4533
|
}
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
else sel.opaque = true;
|
|
4534
|
+
if (ts3.isCallExpression(node) && /^(?:\w+\.)?mergeConfig$/.test(node.expression.getText()) && node.arguments[0] && !ts3.isObjectLiteralExpression(unwrapConfigCall(node.arguments[0]))) {
|
|
4535
|
+
sel.present = true;
|
|
4536
|
+
sel.opaque = true;
|
|
3403
4537
|
}
|
|
3404
|
-
sel.projects = list;
|
|
3405
|
-
if (list.some((p) => p.opaque)) sel.opaque = true;
|
|
3406
|
-
};
|
|
3407
|
-
const visit = (node) => {
|
|
3408
4538
|
if (ts3.isPropertyAssignment(node)) {
|
|
3409
4539
|
const k = keyName2(node.name);
|
|
3410
4540
|
if (runner === "jest") {
|
|
@@ -3424,7 +4554,7 @@ function collect(root, runner) {
|
|
|
3424
4554
|
} else if (ownerKey2(node) === "test") {
|
|
3425
4555
|
if (k === "include") sel.include = take(k, node.initializer);
|
|
3426
4556
|
else if (k === "exclude") ignores(k, node.initializer);
|
|
3427
|
-
else if (k === "dir") {
|
|
4557
|
+
else if (k === "dir" || k === "root") {
|
|
3428
4558
|
const { dir, opaque } = dirValue(node.initializer);
|
|
3429
4559
|
sel.present = true;
|
|
3430
4560
|
if (opaque) sel.opaque = true;
|
|
@@ -3438,8 +4568,68 @@ function collect(root, runner) {
|
|
|
3438
4568
|
ts3.forEachChild(node, visit);
|
|
3439
4569
|
};
|
|
3440
4570
|
visit(root);
|
|
4571
|
+
if (projectList) sel.projects = readProjects(projectList, sel, runner);
|
|
4572
|
+
if (sel.projects?.some((p) => p.opaque)) sel.opaque = true;
|
|
3441
4573
|
return sel;
|
|
3442
4574
|
}
|
|
4575
|
+
function unwrapConfigCall(e) {
|
|
4576
|
+
let x = e;
|
|
4577
|
+
while (ts3.isParenthesizedExpression(x) || ts3.isAsExpression(x) || x.kind === ts3.SyntaxKind.SatisfiesExpression) {
|
|
4578
|
+
x = x.expression;
|
|
4579
|
+
}
|
|
4580
|
+
if (ts3.isCallExpression(x) && /^(?:\w+\.)?define(?:Config|Project)$/.test(x.expression.getText()) && x.arguments[0]) return unwrapConfigCall(x.arguments[0]);
|
|
4581
|
+
return x;
|
|
4582
|
+
}
|
|
4583
|
+
var JEST_SELECTION_KEYS = /^(?:testMatch|testRegex|testPathIgnorePatterns|modulePathIgnorePatterns|roots|rootDir|projects)$/;
|
|
4584
|
+
function spreadsIntoConfig(node, runner) {
|
|
4585
|
+
const obj = node.parent;
|
|
4586
|
+
if (!ts3.isObjectLiteralExpression(obj)) return false;
|
|
4587
|
+
const keys = obj.properties.map((p) => ts3.isPropertyAssignment(p) ? keyName2(p.name) : null);
|
|
4588
|
+
if (runner === "vitest") {
|
|
4589
|
+
if (keys.includes("test")) return true;
|
|
4590
|
+
let q = obj.parent;
|
|
4591
|
+
while (q && (ts3.isParenthesizedExpression(q) || ts3.isAsExpression(q) || q.kind === ts3.SyntaxKind.SatisfiesExpression)) q = q.parent;
|
|
4592
|
+
return !!q && ts3.isPropertyAssignment(q) && keyName2(q.name) === "test";
|
|
4593
|
+
}
|
|
4594
|
+
return keys.some((k) => k !== null && JEST_SELECTION_KEYS.test(k));
|
|
4595
|
+
}
|
|
4596
|
+
function extendsOf(el) {
|
|
4597
|
+
for (const p of el.properties) {
|
|
4598
|
+
if (!ts3.isPropertyAssignment(p) || keyName2(p.name) !== "extends") continue;
|
|
4599
|
+
if (p.initializer.kind === ts3.SyntaxKind.TrueKeyword) return true;
|
|
4600
|
+
if (p.initializer.kind === ts3.SyntaxKind.FalseKeyword) return null;
|
|
4601
|
+
return "path";
|
|
4602
|
+
}
|
|
4603
|
+
return null;
|
|
4604
|
+
}
|
|
4605
|
+
function readProject(el, root, runner) {
|
|
4606
|
+
const p = collect(el, runner);
|
|
4607
|
+
const ext = extendsOf(el);
|
|
4608
|
+
if (ext === "path") p.opaque = true;
|
|
4609
|
+
else if (ext === true && root) {
|
|
4610
|
+
p.include ??= root.include;
|
|
4611
|
+
p.regex ??= root.regex;
|
|
4612
|
+
p.ignore ??= root.ignore;
|
|
4613
|
+
p.roots ??= root.roots;
|
|
4614
|
+
p.base ??= root.base;
|
|
4615
|
+
if (root.opaque) p.opaque = true;
|
|
4616
|
+
}
|
|
4617
|
+
p.present = true;
|
|
4618
|
+
return p;
|
|
4619
|
+
}
|
|
4620
|
+
function readProjects(e, root, runner) {
|
|
4621
|
+
if (!ts3.isArrayLiteralExpression(e)) {
|
|
4622
|
+
root.opaque = true;
|
|
4623
|
+
return [];
|
|
4624
|
+
}
|
|
4625
|
+
const list2 = [];
|
|
4626
|
+
for (const el of e.elements) {
|
|
4627
|
+
const lit = unwrapConfigCall(el);
|
|
4628
|
+
if (ts3.isObjectLiteralExpression(lit)) list2.push(readProject(lit, root, runner));
|
|
4629
|
+
else root.opaque = true;
|
|
4630
|
+
}
|
|
4631
|
+
return list2;
|
|
4632
|
+
}
|
|
3443
4633
|
function workspaceArray(sf) {
|
|
3444
4634
|
for (const st of sf.statements) {
|
|
3445
4635
|
let e;
|
|
@@ -3461,14 +4651,9 @@ function parseSelection(src, runner, path = "") {
|
|
|
3461
4651
|
if (runner === "vitest" && isWorkspaceFile(path)) {
|
|
3462
4652
|
const arr = workspaceArray(sf);
|
|
3463
4653
|
if (arr) {
|
|
3464
|
-
|
|
3465
|
-
for (const el of arr.elements) {
|
|
3466
|
-
if (ts3.isObjectLiteralExpression(el)) list.push(collect(el, runner));
|
|
3467
|
-
else sel.opaque = true;
|
|
3468
|
-
}
|
|
3469
|
-
sel.projects = list;
|
|
4654
|
+
sel.projects = readProjects(arr, sel, runner);
|
|
3470
4655
|
sel.present = true;
|
|
3471
|
-
if (
|
|
4656
|
+
if (sel.projects.some((p) => p.opaque)) sel.opaque = true;
|
|
3472
4657
|
} else if (src.trim()) sel.opaque = true;
|
|
3473
4658
|
} else sel = collect(sf, runner);
|
|
3474
4659
|
} catch {
|
|
@@ -3528,8 +4713,8 @@ function predicate(sel, runner) {
|
|
|
3528
4713
|
if (!inRoots(r)) return `roots no longer covers it (${JSON.stringify(sel.roots)})`;
|
|
3529
4714
|
const hit = ignoredBy(r);
|
|
3530
4715
|
if (hit) return `${hit.key === "exclude" ? "test.exclude" : hit.key} now matches it (${JSON.stringify(hit.pattern)})`;
|
|
3531
|
-
const key2 = sel.include ?
|
|
3532
|
-
return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? [])})`;
|
|
4716
|
+
const key2 = runner === "jest" ? sel.include ? "testMatch" : sel.regex ? "testRegex" : "testMatch (default)" : sel.include ? "test.include" : "test.include (default)";
|
|
4717
|
+
return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? includeList ?? [])})`;
|
|
3533
4718
|
}
|
|
3534
4719
|
};
|
|
3535
4720
|
}
|
|
@@ -3568,8 +4753,21 @@ function calleeName(expr) {
|
|
|
3568
4753
|
}
|
|
3569
4754
|
return null;
|
|
3570
4755
|
}
|
|
3571
|
-
var
|
|
3572
|
-
var
|
|
4756
|
+
var TABLE_METHOD = /^(?:each|for)$/;
|
|
4757
|
+
var isEachOf = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && (expr.expression.text === "it" || expr.expression.text === "test") && TABLE_METHOD.test(expr.name.text);
|
|
4758
|
+
var isDescribeEach = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && expr.expression.text === "describe" && TABLE_METHOD.test(expr.name.text);
|
|
4759
|
+
function loopRows(iterable) {
|
|
4760
|
+
if (ts4.isArrayLiteralExpression(iterable)) {
|
|
4761
|
+
let n = 0;
|
|
4762
|
+
let open = false;
|
|
4763
|
+
for (const el of iterable.elements) {
|
|
4764
|
+
if (ts4.isSpreadElement(el)) open = true;
|
|
4765
|
+
else n++;
|
|
4766
|
+
}
|
|
4767
|
+
return { n: Math.max(n, open ? 0 : 1), open };
|
|
4768
|
+
}
|
|
4769
|
+
return { n: 1, open: true };
|
|
4770
|
+
}
|
|
3573
4771
|
function templateRows(node) {
|
|
3574
4772
|
const rows = node.template.getText().split("\n").filter((l) => /\S/.test(l.replace(/[`]/g, ""))).length - 1;
|
|
3575
4773
|
return Math.max(rows, 1);
|
|
@@ -3607,7 +4805,23 @@ function countTests(src, path = "spec.ts", substantiveOnly = false) {
|
|
|
3607
4805
|
let n = 0;
|
|
3608
4806
|
let open = false;
|
|
3609
4807
|
const visit = (node, mult) => {
|
|
4808
|
+
if (ts4.isForOfStatement(node)) {
|
|
4809
|
+
const r = loopRows(node.expression);
|
|
4810
|
+
if (r.open) open = true;
|
|
4811
|
+
visit(node.statement, mult * r.n);
|
|
4812
|
+
return;
|
|
4813
|
+
}
|
|
4814
|
+
if (ts4.isForStatement(node) || ts4.isForInStatement(node) || ts4.isWhileStatement(node) || ts4.isDoStatement(node)) {
|
|
4815
|
+
open = true;
|
|
4816
|
+
}
|
|
3610
4817
|
if (ts4.isCallExpression(node)) {
|
|
4818
|
+
const callee = node.expression;
|
|
4819
|
+
if (ts4.isPropertyAccessExpression(callee) && callee.name.text === "forEach") {
|
|
4820
|
+
const r = loopRows(callee.expression);
|
|
4821
|
+
if (r.open) open = true;
|
|
4822
|
+
for (const a of node.arguments) visit(a, mult * r.n);
|
|
4823
|
+
return;
|
|
4824
|
+
}
|
|
3611
4825
|
const head = node.expression;
|
|
3612
4826
|
if (ts4.isCallExpression(head) && isDescribeEach(head.expression)) {
|
|
3613
4827
|
const r = eachRows(head);
|
|
@@ -3655,6 +4869,12 @@ function runnerSamples(configPath, policy, ctx) {
|
|
|
3655
4869
|
const own = files.filter((f) => f.startsWith(root) && langOf(f) === "js" && isProtected(f, policy, "tests") && !OTHER_RUNNER_DIR.test(f)).map((f) => f.slice(root.length));
|
|
3656
4870
|
return own.length ? own : CANONICAL_SAMPLES;
|
|
3657
4871
|
}
|
|
4872
|
+
var WHOLE_SUITE_SCRIPT = /^test(?::ci)?$/;
|
|
4873
|
+
function excludesOtherRunner(flag, cmd) {
|
|
4874
|
+
if (flag !== "--exclude") return false;
|
|
4875
|
+
const values = [...cmd.matchAll(/--exclude(?:=|\s+)(['"]?)(\S+?)\1(?=\s|$)/g)].map((m) => m[2]);
|
|
4876
|
+
return values.length > 0 && values.every((v) => OTHER_RUNNER_DIR.test(v.replace(/^\.\//, "").replace(/(?:\/\*+)*\/?$/, "") + "/"));
|
|
4877
|
+
}
|
|
3658
4878
|
function scriptNarrowings(before, after, path) {
|
|
3659
4879
|
if ((path.split("/").pop() ?? path) !== "package.json") return [];
|
|
3660
4880
|
const scriptsOf = (src) => {
|
|
@@ -3669,7 +4889,7 @@ function scriptNarrowings(before, after, path) {
|
|
|
3669
4889
|
const now = scriptsOf(after);
|
|
3670
4890
|
const out2 = [];
|
|
3671
4891
|
for (const [name, cmd] of Object.entries(now)) {
|
|
3672
|
-
if (
|
|
4892
|
+
if (!WHOLE_SUITE_SCRIPT.test(name) || !/\b(?:jest|vitest)\b/.test(cmd)) continue;
|
|
3673
4893
|
const prev = was[name] ?? "";
|
|
3674
4894
|
const flags = new RegExp(SUITE_NARROWING_FLAGS.source, "g");
|
|
3675
4895
|
for (const m of cmd.matchAll(flags)) {
|
|
@@ -3677,6 +4897,7 @@ function scriptNarrowings(before, after, path) {
|
|
|
3677
4897
|
if (!flag) continue;
|
|
3678
4898
|
const one = new RegExp(SUITE_NARROWING_FLAGS.source);
|
|
3679
4899
|
if (one.test(prev) && prev.includes(flag)) continue;
|
|
4900
|
+
if (excludesOtherRunner(flag, cmd)) continue;
|
|
3680
4901
|
out2.push({ script: name, flag, before: prev, after: cmd });
|
|
3681
4902
|
break;
|
|
3682
4903
|
}
|
|
@@ -3708,10 +4929,11 @@ var testDeletion = {
|
|
|
3708
4929
|
certainty: "mechanical",
|
|
3709
4930
|
run(changes, policy, _view, ctx) {
|
|
3710
4931
|
const out2 = [];
|
|
4932
|
+
const isSpec = (p) => isProtected(p, policy, "tests") && !isProtected(p, policy, "snapshots");
|
|
3711
4933
|
const addedTestLines = /* @__PURE__ */ new Set();
|
|
3712
4934
|
let addedTestBlocks = 0;
|
|
3713
4935
|
for (const c of changes) {
|
|
3714
|
-
if (c.kind !== "file" || c.after == null || !
|
|
4936
|
+
if (c.kind !== "file" || c.after == null || !isSpec(c.path)) continue;
|
|
3715
4937
|
if (c.op === "add") {
|
|
3716
4938
|
for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
|
|
3717
4939
|
addedTestBlocks += countTests(c.after, c.path, true).min;
|
|
@@ -3729,7 +4951,7 @@ var testDeletion = {
|
|
|
3729
4951
|
};
|
|
3730
4952
|
for (const c of changes) {
|
|
3731
4953
|
if (c.kind === "file") {
|
|
3732
|
-
const isTest =
|
|
4954
|
+
const isTest = isSpec(c.path);
|
|
3733
4955
|
if (c.op === "delete" && isTest) {
|
|
3734
4956
|
if (c.before != null && isRelocation(significantLines(c.before, c.path), countTestBlocks(c.before, c.path))) continue;
|
|
3735
4957
|
out2.push(
|
|
@@ -3740,7 +4962,7 @@ var testDeletion = {
|
|
|
3740
4962
|
remediation: "Fix the code under test, not the suite. Deleting a failing test to pass is the move this blocks."
|
|
3741
4963
|
})
|
|
3742
4964
|
);
|
|
3743
|
-
} else if (c.op === "rename" && c.oldPath &&
|
|
4965
|
+
} else if (c.op === "rename" && c.oldPath && isSpec(c.oldPath) && !isTest) {
|
|
3744
4966
|
out2.push(
|
|
3745
4967
|
makeFinding(RULE7, policy, {
|
|
3746
4968
|
file: c.path,
|
|
@@ -3801,22 +5023,22 @@ var testDeletion = {
|
|
|
3801
5023
|
const testToks = toks.filter((t) => {
|
|
3802
5024
|
const resolved = root !== null && /(?:^|\/)\.{1,2}(?:\/|$)/.test(t) ? joinRoot(root, t) : null;
|
|
3803
5025
|
const probe = resolved ?? t;
|
|
3804
|
-
if (!
|
|
5026
|
+
if (!isSpec(probe)) return false;
|
|
3805
5027
|
if (!listing || root === null) return true;
|
|
3806
5028
|
const looksLikeFile = /\.[A-Za-z0-9]+$/.test(probe.split("/").pop() ?? "");
|
|
3807
5029
|
if (looksLikeFile || listing.includes(resolved ?? inRepo(t))) return true;
|
|
3808
|
-
return containsProtected(resolved ?? inRepo(t), policy, "tests", ctx);
|
|
5030
|
+
return containsProtected(resolved ?? inRepo(t), policy, "tests", ctx, "snapshots");
|
|
3809
5031
|
});
|
|
3810
|
-
const rootHasProtected = () => listing ? listing.some(
|
|
5032
|
+
const rootHasProtected = () => listing ? listing.some(isSpec) : true;
|
|
3811
5033
|
const dirToks = toks.filter((t) => {
|
|
3812
5034
|
if (t.startsWith("-") || testToks.includes(t)) return false;
|
|
3813
5035
|
const whole = /^(?:\.|\.\/|\*|\.\/\*)$/.test(t);
|
|
3814
5036
|
if (!whole && /[*?{}[\]]/.test(t)) return false;
|
|
3815
|
-
if (root === null) return whole ? false : containsProtected(t, policy, "tests", ctx);
|
|
5037
|
+
if (root === null) return whole ? false : containsProtected(t, policy, "tests", ctx, "snapshots");
|
|
3816
5038
|
const dir = whole ? root : joinRoot(root, t);
|
|
3817
5039
|
if (dir === null) return false;
|
|
3818
5040
|
if (dir === "") return rootHasProtected();
|
|
3819
|
-
return containsProtected(dir, policy, "tests", ctx);
|
|
5041
|
+
return containsProtected(dir, policy, "tests", ctx, "snapshots");
|
|
3820
5042
|
});
|
|
3821
5043
|
if (testToks.length === 0 && dirToks.length === 0) continue;
|
|
3822
5044
|
const named = [...testToks, ...dirToks];
|
|
@@ -3824,13 +5046,13 @@ var testDeletion = {
|
|
|
3824
5046
|
let why = null;
|
|
3825
5047
|
let evidence = seg;
|
|
3826
5048
|
const redirectTarget = seg.match(/>\s*(\S+)/)?.[1];
|
|
3827
|
-
const redirectsOntoTest = redirectTarget ?
|
|
5049
|
+
const redirectsOntoTest = redirectTarget ? isSpec(unquote(redirectTarget)) : false;
|
|
3828
5050
|
const findDeletes = cmd === "find" && /\s(?:-delete\b|-exec\s+(?:rm|unlink)\b|-execdir\s+(?:rm|unlink)\b)/.test(seg);
|
|
3829
5051
|
const gitRestoresOld = cmd === "git" && /^git\s+(?:checkout|restore)\b/.test(seg) && (() => {
|
|
3830
5052
|
const src = seg.match(/--source[= ]\s*(\S+)/)?.[1];
|
|
3831
5053
|
const revIdx = toks.findIndex((t, i) => i >= 2 && !t.startsWith("-") && !named.includes(t) && t !== "--");
|
|
3832
5054
|
const rev = src ?? (revIdx >= 0 ? toks[revIdx] : void 0);
|
|
3833
|
-
if (!rev || rev === "HEAD") return false;
|
|
5055
|
+
if (!rev || rev === "HEAD" || rev === "@" || revIsHead(rev, ctx) === true) return false;
|
|
3834
5056
|
if (src || seg.includes(" -- ")) return true;
|
|
3835
5057
|
return revIdx >= 0 && toks.some((t, i) => i > revIdx && named.includes(t));
|
|
3836
5058
|
})();
|
|
@@ -3846,7 +5068,7 @@ var testDeletion = {
|
|
|
3846
5068
|
why = "truncate empties a test file";
|
|
3847
5069
|
} else if (redirectsOntoTest) {
|
|
3848
5070
|
why = "a redirect overwrites/empties a test file";
|
|
3849
|
-
} else if (cmd === "cp" && testToks.length > 0 &&
|
|
5071
|
+
} else if (cmd === "cp" && testToks.length > 0 && isSpec(toks[toks.length - 1] ?? "") && !isSpec(toks[toks.length - 2] ?? "")) {
|
|
3850
5072
|
why = "cp overwrites a test file with something else";
|
|
3851
5073
|
} else if (cmd === "tee" && !toks.includes("-a") && !toks.includes("--append")) {
|
|
3852
5074
|
why = "tee overwrites a test file";
|
|
@@ -3856,7 +5078,7 @@ var testDeletion = {
|
|
|
3856
5078
|
why = "git checkout <rev> replaces a test file with an older version";
|
|
3857
5079
|
} else if (/\bmv\b/.test(seg)) {
|
|
3858
5080
|
const dest = toks[toks.length - 1];
|
|
3859
|
-
if (dest && testToks.length > 0 && !
|
|
5081
|
+
if (dest && testToks.length > 0 && !isSpec(dest)) {
|
|
3860
5082
|
why = "mv renames a test file out of the test glob";
|
|
3861
5083
|
evidence = `${testToks[0]} \u2192 ${dest}`;
|
|
3862
5084
|
} else if (dest && testToks.length === 0 && dirToks[0] !== dest && leavesGlob(dirToks[0] ?? "", dest, policy, ctx)) {
|
|
@@ -3893,7 +5115,7 @@ function eachTables(src, path) {
|
|
|
3893
5115
|
try {
|
|
3894
5116
|
const sf = ts5.createSourceFile("spec.ts", src, ts5.ScriptTarget.Latest, true, ts5.ScriptKind.TS);
|
|
3895
5117
|
const visit = (node) => {
|
|
3896
|
-
if (ts5.isCallExpression(node) && ts5.isPropertyAccessExpression(node.expression) && node.expression.name.text
|
|
5118
|
+
if (ts5.isCallExpression(node) && ts5.isPropertyAccessExpression(node.expression) && /^(?:each|for)$/.test(node.expression.name.text) && ts5.isIdentifier(node.expression.expression) && /^(?:it|test|describe)$/.test(node.expression.expression.text)) {
|
|
3897
5119
|
const arg = node.arguments[0];
|
|
3898
5120
|
if (arg && ts5.isArrayLiteralExpression(arg)) {
|
|
3899
5121
|
for (const el of arg.elements) {
|
|
@@ -3914,6 +5136,7 @@ function eachTables(src, path) {
|
|
|
3914
5136
|
}
|
|
3915
5137
|
return { rows, open };
|
|
3916
5138
|
}
|
|
5139
|
+
var TEST_DIR_FILE = /(?:^|\/)(?:test|tests|__tests__|spec|specs)\//;
|
|
3917
5140
|
function significantLinesOrdered(src, path) {
|
|
3918
5141
|
const out2 = [];
|
|
3919
5142
|
const lang = langOf(path);
|
|
@@ -3934,11 +5157,14 @@ var testContentRemoval = {
|
|
|
3934
5157
|
for (const c of changes) {
|
|
3935
5158
|
if (c.kind !== "file" || c.after == null) continue;
|
|
3936
5159
|
if (isProtected(c.path, policy, "tests")) keptPool += ws(significantLinesOrdered(c.after, c.path).join("\n"));
|
|
3937
|
-
|
|
5160
|
+
let addedHere = "";
|
|
5161
|
+
if (c.op === "add") addedHere = ws(c.after);
|
|
3938
5162
|
else if (c.before != null) {
|
|
3939
5163
|
const had = new Set(c.before.split("\n").map((l) => l.trim()));
|
|
3940
|
-
|
|
5164
|
+
addedHere = ws(c.after.split("\n").filter((l) => !had.has(l.trim())).join("\n"));
|
|
3941
5165
|
}
|
|
5166
|
+
addedPool += addedHere;
|
|
5167
|
+
if (!isProtected(c.path, policy, "tests") && TEST_DIR_FILE.test(c.path) && langOf(c.path) === "js") keptPool += addedHere;
|
|
3942
5168
|
}
|
|
3943
5169
|
for (const c of changes) {
|
|
3944
5170
|
if (c.kind !== "file" || c.op !== "modify" && c.op !== "rename" || c.before == null || c.after == null) continue;
|
|
@@ -4101,7 +5327,7 @@ var GUARDED_RULE = "hook-tampering";
|
|
|
4101
5327
|
var BASELINE_HOOKS = defaultPolicy().protected.hooks;
|
|
4102
5328
|
function isGuardedFinding(f) {
|
|
4103
5329
|
if (f.rule !== GUARDED_RULE || !f.file) return false;
|
|
4104
|
-
return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS);
|
|
5330
|
+
return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS) || isClaudeSettings(f.file);
|
|
4105
5331
|
}
|
|
4106
5332
|
function key(f) {
|
|
4107
5333
|
return `${f.rule}|${f.file ?? ""}|${f.line ?? ""}|${f.evidence}`;
|
|
@@ -4160,8 +5386,8 @@ function hasBlocking(findings) {
|
|
|
4160
5386
|
}
|
|
4161
5387
|
|
|
4162
5388
|
// src/policy-load.ts
|
|
4163
|
-
import { readFileSync as
|
|
4164
|
-
import { isAbsolute as isAbsolute2, join as
|
|
5389
|
+
import { readFileSync as readFileSync4, existsSync as existsSync2 } from "node:fs";
|
|
5390
|
+
import { isAbsolute as isAbsolute2, join as join4, posix } from "node:path";
|
|
4165
5391
|
import { parse as parse2 } from "yaml";
|
|
4166
5392
|
var PolicyError = class extends Error {
|
|
4167
5393
|
};
|
|
@@ -4242,8 +5468,8 @@ function parsePolicy(raw, where = POLICY_FILE) {
|
|
|
4242
5468
|
const version = normalizeVersion(r.version, where);
|
|
4243
5469
|
validate(r, where);
|
|
4244
5470
|
const base = defaultPolicy(version);
|
|
4245
|
-
const globs4 = (
|
|
4246
|
-
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat,
|
|
5471
|
+
const globs4 = (list2) => list2?.map(normalizeGlob);
|
|
5472
|
+
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list2]) => [cat, globs4(list2) ?? []])) : void 0;
|
|
4247
5473
|
const userRules = r.rules ? Object.fromEntries(
|
|
4248
5474
|
Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs4(cfg.exclude) } : cfg])
|
|
4249
5475
|
) : void 0;
|
|
@@ -4285,9 +5511,9 @@ function parseOrThrow(src, where) {
|
|
|
4285
5511
|
return parsePolicy(raw, where);
|
|
4286
5512
|
}
|
|
4287
5513
|
function loadPolicy(cwd = process.cwd()) {
|
|
4288
|
-
const path =
|
|
5514
|
+
const path = join4(cwd, POLICY_FILE);
|
|
4289
5515
|
if (!existsSync2(path)) return defaultPolicy();
|
|
4290
|
-
return parseOrThrow(
|
|
5516
|
+
return parseOrThrow(readFileSync4(path, "utf8"), POLICY_FILE);
|
|
4291
5517
|
}
|
|
4292
5518
|
function loadPolicyAt(rev, cwd) {
|
|
4293
5519
|
const src = fileAt(rev, POLICY_FILE, { cwd });
|
|
@@ -4297,8 +5523,8 @@ function loadPolicyAt(rev, cwd) {
|
|
|
4297
5523
|
|
|
4298
5524
|
// src/signoff.ts
|
|
4299
5525
|
import { createHash } from "node:crypto";
|
|
4300
|
-
import { appendFileSync, mkdirSync, readFileSync as
|
|
4301
|
-
import { dirname, join as
|
|
5526
|
+
import { appendFileSync, mkdirSync, readFileSync as readFileSync5, existsSync as existsSync3 } from "node:fs";
|
|
5527
|
+
import { dirname as dirname2, join as join5 } from "node:path";
|
|
4302
5528
|
var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
4303
5529
|
function fingerprint(rule, file, evidence) {
|
|
4304
5530
|
return createHash("sha256").update(`${rule}\0${file ?? ""}\0${evidence}`).digest("hex").slice(0, 16);
|
|
@@ -4309,13 +5535,13 @@ function ledgerPath(cwd, policy) {
|
|
|
4309
5535
|
if (!ledgerInsideRepo(rel)) {
|
|
4310
5536
|
throw new PolicyError(`signoff.ledger must be a relative path inside the repository, got ${JSON.stringify(rel)}`);
|
|
4311
5537
|
}
|
|
4312
|
-
return
|
|
5538
|
+
return join5(cwd, rel);
|
|
4313
5539
|
}
|
|
4314
5540
|
function readLedger(cwd, policy) {
|
|
4315
5541
|
const p = ledgerPath(cwd, policy);
|
|
4316
5542
|
if (!existsSync3(p)) return [];
|
|
4317
5543
|
const out2 = [];
|
|
4318
|
-
for (const line of
|
|
5544
|
+
for (const line of readFileSync5(p, "utf8").split("\n")) {
|
|
4319
5545
|
if (!line.trim()) continue;
|
|
4320
5546
|
try {
|
|
4321
5547
|
const e = JSON.parse(line);
|
|
@@ -4327,7 +5553,7 @@ function readLedger(cwd, policy) {
|
|
|
4327
5553
|
}
|
|
4328
5554
|
function appendEntry(cwd, policy, e) {
|
|
4329
5555
|
const p = ledgerPath(cwd, policy);
|
|
4330
|
-
mkdirSync(
|
|
5556
|
+
mkdirSync(dirname2(p), { recursive: true });
|
|
4331
5557
|
appendFileSync(p, JSON.stringify(e) + "\n");
|
|
4332
5558
|
}
|
|
4333
5559
|
function makeEntry(f, reason, now, ttlMs = DEFAULT_TTL_MS) {
|
|
@@ -4684,21 +5910,21 @@ function runCheck(opts) {
|
|
|
4684
5910
|
}
|
|
4685
5911
|
|
|
4686
5912
|
// src/cli/hook.ts
|
|
4687
|
-
import { readFileSync as
|
|
5913
|
+
import { readFileSync as readFileSync11, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7, statSync as statSync4 } from "node:fs";
|
|
4688
5914
|
import { createHash as createHash4 } from "node:crypto";
|
|
4689
|
-
import { join as
|
|
5915
|
+
import { join as join10 } from "node:path";
|
|
4690
5916
|
|
|
4691
5917
|
// src/adapters/claude/changes.ts
|
|
4692
5918
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
4693
|
-
import { readFileSync as
|
|
5919
|
+
import { readFileSync as readFileSync6, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
4694
5920
|
import { tmpdir } from "node:os";
|
|
4695
|
-
import { isAbsolute as isAbsolute3, join as
|
|
5921
|
+
import { isAbsolute as isAbsolute3, join as join6, relative as relative2, resolve } from "node:path";
|
|
4696
5922
|
function asStr(v) {
|
|
4697
5923
|
return typeof v === "string" ? v : "";
|
|
4698
5924
|
}
|
|
4699
5925
|
function readDisk(path) {
|
|
4700
5926
|
try {
|
|
4701
|
-
return existsSync4(path) ?
|
|
5927
|
+
return existsSync4(path) ? readFileSync6(path, "utf8") : null;
|
|
4702
5928
|
} catch {
|
|
4703
5929
|
return null;
|
|
4704
5930
|
}
|
|
@@ -4721,11 +5947,11 @@ function synthFileChange(displayPath, before, after) {
|
|
|
4721
5947
|
if (before === null) op = "add";
|
|
4722
5948
|
else if (after === null) op = "delete";
|
|
4723
5949
|
else op = "modify";
|
|
4724
|
-
const dir = mkdtempSync(
|
|
5950
|
+
const dir = mkdtempSync(join6(tmpdir(), "hf-"));
|
|
4725
5951
|
let raw = "";
|
|
4726
5952
|
try {
|
|
4727
|
-
const a =
|
|
4728
|
-
const b =
|
|
5953
|
+
const a = join6(dir, "a");
|
|
5954
|
+
const b = join6(dir, "b");
|
|
4729
5955
|
writeFileSync(a, before ?? "");
|
|
4730
5956
|
writeFileSync(b, after ?? "");
|
|
4731
5957
|
try {
|
|
@@ -4810,14 +6036,14 @@ function formatDenial(blocks) {
|
|
|
4810
6036
|
}
|
|
4811
6037
|
|
|
4812
6038
|
// src/session.ts
|
|
4813
|
-
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as
|
|
4814
|
-
import { dirname as
|
|
6039
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "node:fs";
|
|
6040
|
+
import { dirname as dirname3, join as join7 } from "node:path";
|
|
4815
6041
|
var UNSAFE = /[^A-Za-z0-9_-]/g;
|
|
4816
6042
|
var SHA = /^[0-9a-f]{7,40}$/;
|
|
4817
6043
|
function baselinePath(cwd, sessionId) {
|
|
4818
6044
|
const gd = gitDir(cwd);
|
|
4819
6045
|
if (!gd) return null;
|
|
4820
|
-
return
|
|
6046
|
+
return join7(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
|
|
4821
6047
|
}
|
|
4822
6048
|
function turnBaseline(cwd, sessionId) {
|
|
4823
6049
|
if (!sessionId) return null;
|
|
@@ -4825,12 +6051,12 @@ function turnBaseline(cwd, sessionId) {
|
|
|
4825
6051
|
const p = baselinePath(cwd, sessionId);
|
|
4826
6052
|
if (!p) return null;
|
|
4827
6053
|
if (existsSync5(p)) {
|
|
4828
|
-
const v =
|
|
6054
|
+
const v = readFileSync7(p, "utf8").trim();
|
|
4829
6055
|
if (SHA.test(v)) return v;
|
|
4830
6056
|
}
|
|
4831
6057
|
const head = headSha(cwd);
|
|
4832
6058
|
if (!head) return null;
|
|
4833
|
-
mkdirSync2(
|
|
6059
|
+
mkdirSync2(dirname3(p), { recursive: true });
|
|
4834
6060
|
writeFileSync2(p, head);
|
|
4835
6061
|
return head;
|
|
4836
6062
|
} catch {
|
|
@@ -4843,7 +6069,7 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
4843
6069
|
const p = baselinePath(cwd, sessionId);
|
|
4844
6070
|
const head = headSha(cwd);
|
|
4845
6071
|
if (!p || !head) return;
|
|
4846
|
-
mkdirSync2(
|
|
6072
|
+
mkdirSync2(dirname3(p), { recursive: true });
|
|
4847
6073
|
writeFileSync2(p, head);
|
|
4848
6074
|
} catch {
|
|
4849
6075
|
}
|
|
@@ -4851,8 +6077,8 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
4851
6077
|
|
|
4852
6078
|
// src/effect.ts
|
|
4853
6079
|
import { createHash as createHash2 } from "node:crypto";
|
|
4854
|
-
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as
|
|
4855
|
-
import { isAbsolute as isAbsolute4, join as
|
|
6080
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync8, renameSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
6081
|
+
import { isAbsolute as isAbsolute4, join as join8 } from "node:path";
|
|
4856
6082
|
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
4857
6083
|
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
4858
6084
|
var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
@@ -4864,7 +6090,7 @@ function gitDir2(cwd) {
|
|
|
4864
6090
|
stdio: ["ignore", "pipe", "pipe"]
|
|
4865
6091
|
}).trim();
|
|
4866
6092
|
if (!d) return null;
|
|
4867
|
-
return isAbsolute4(d) ? d :
|
|
6093
|
+
return isAbsolute4(d) ? d : join8(cwd, d);
|
|
4868
6094
|
} catch {
|
|
4869
6095
|
return null;
|
|
4870
6096
|
}
|
|
@@ -4873,8 +6099,8 @@ function statePath(cwd, sessionId, kind) {
|
|
|
4873
6099
|
if (!sessionId) return null;
|
|
4874
6100
|
const gd = gitDir2(cwd);
|
|
4875
6101
|
if (!gd) return null;
|
|
4876
|
-
const dir =
|
|
4877
|
-
return
|
|
6102
|
+
const dir = join8(gd, "tamperward");
|
|
6103
|
+
return join8(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
|
|
4878
6104
|
}
|
|
4879
6105
|
function ptreePath(cwd, sessionId) {
|
|
4880
6106
|
return statePath(cwd, sessionId, "ptree");
|
|
@@ -4888,8 +6114,8 @@ function contentHash(content) {
|
|
|
4888
6114
|
}
|
|
4889
6115
|
function snapshotProtected(cwd, policy, prev) {
|
|
4890
6116
|
const out2 = {};
|
|
4891
|
-
const
|
|
4892
|
-
const abs2 = rel ?
|
|
6117
|
+
const walk2 = (rel) => {
|
|
6118
|
+
const abs2 = rel ? join8(cwd, rel) : cwd;
|
|
4893
6119
|
let names;
|
|
4894
6120
|
try {
|
|
4895
6121
|
names = readdirSync(abs2);
|
|
@@ -4901,15 +6127,15 @@ function snapshotProtected(cwd, policy, prev) {
|
|
|
4901
6127
|
const r = rel ? `${rel}/${name}` : name;
|
|
4902
6128
|
let st;
|
|
4903
6129
|
try {
|
|
4904
|
-
st = statSync(
|
|
6130
|
+
st = statSync(join8(cwd, r));
|
|
4905
6131
|
} catch {
|
|
4906
6132
|
continue;
|
|
4907
6133
|
}
|
|
4908
|
-
if (st.isDirectory())
|
|
6134
|
+
if (st.isDirectory()) walk2(r);
|
|
4909
6135
|
else if (st.isFile() && isProtected(r, policy)) {
|
|
4910
6136
|
let hash = "";
|
|
4911
6137
|
try {
|
|
4912
|
-
hash = sha(
|
|
6138
|
+
hash = sha(readFileSync8(join8(cwd, r)));
|
|
4913
6139
|
} catch {
|
|
4914
6140
|
hash = "unreadable";
|
|
4915
6141
|
}
|
|
@@ -4917,7 +6143,7 @@ function snapshotProtected(cwd, policy, prev) {
|
|
|
4917
6143
|
}
|
|
4918
6144
|
}
|
|
4919
6145
|
};
|
|
4920
|
-
|
|
6146
|
+
walk2("");
|
|
4921
6147
|
return out2;
|
|
4922
6148
|
}
|
|
4923
6149
|
function driftBetween(expected, current) {
|
|
@@ -4937,7 +6163,7 @@ function driftBetween(expected, current) {
|
|
|
4937
6163
|
function loadTree(p) {
|
|
4938
6164
|
if (!p || !existsSync6(p)) return null;
|
|
4939
6165
|
try {
|
|
4940
|
-
return JSON.parse(
|
|
6166
|
+
return JSON.parse(readFileSync8(p, "utf8"));
|
|
4941
6167
|
} catch {
|
|
4942
6168
|
return null;
|
|
4943
6169
|
}
|
|
@@ -4945,7 +6171,7 @@ function loadTree(p) {
|
|
|
4945
6171
|
function saveTree(p, tree) {
|
|
4946
6172
|
if (!p) return;
|
|
4947
6173
|
try {
|
|
4948
|
-
mkdirSync3(
|
|
6174
|
+
mkdirSync3(join8(p, ".."), { recursive: true });
|
|
4949
6175
|
const tmp = `${p}.${process.pid}.tmp`;
|
|
4950
6176
|
writeFileSync3(tmp, JSON.stringify(tree));
|
|
4951
6177
|
renameSync(tmp, p);
|
|
@@ -4976,15 +6202,15 @@ function dropTurnTree(cwd, sessionId) {
|
|
|
4976
6202
|
}
|
|
4977
6203
|
|
|
4978
6204
|
// src/cli/watch.ts
|
|
4979
|
-
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as
|
|
4980
|
-
import { join as
|
|
6205
|
+
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync9, statSync as statSync2, watch } from "node:fs";
|
|
6206
|
+
import { join as join9 } from "node:path";
|
|
4981
6207
|
import { createHash as createHash3 } from "node:crypto";
|
|
4982
6208
|
var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
|
|
4983
6209
|
function snap(abs2) {
|
|
4984
6210
|
try {
|
|
4985
6211
|
const st = statSync2(abs2);
|
|
4986
6212
|
if (!st.isFile()) return { mode: st.mode, size: null, hash: null };
|
|
4987
|
-
const hash = createHash3("sha256").update(
|
|
6213
|
+
const hash = createHash3("sha256").update(readFileSync9(abs2)).digest("hex").slice(0, 16);
|
|
4988
6214
|
return { mode: st.mode, size: st.size, hash };
|
|
4989
6215
|
} catch {
|
|
4990
6216
|
return { mode: null, size: null, hash: null };
|
|
@@ -4992,7 +6218,7 @@ function snap(abs2) {
|
|
|
4992
6218
|
}
|
|
4993
6219
|
function defaultEventLog(cwd) {
|
|
4994
6220
|
if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
|
|
4995
|
-
return
|
|
6221
|
+
return join9(gitDir(cwd) ?? join9(cwd, ".git"), "tamperward", "fsevents.jsonl");
|
|
4996
6222
|
}
|
|
4997
6223
|
function watchTree(dir, cb) {
|
|
4998
6224
|
if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
|
|
@@ -5009,12 +6235,12 @@ function watchTree(dir, cb) {
|
|
|
5009
6235
|
if (watchers.has(rel) || SKIP2.test(rel + "/")) return;
|
|
5010
6236
|
let w;
|
|
5011
6237
|
try {
|
|
5012
|
-
w = watch(rel ?
|
|
6238
|
+
w = watch(rel ? join9(dir, rel) : dir, (kind, fname) => {
|
|
5013
6239
|
if (!fname) return;
|
|
5014
6240
|
const child = rel ? `${rel}/${String(fname)}` : String(fname);
|
|
5015
6241
|
cb(kind, child);
|
|
5016
6242
|
try {
|
|
5017
|
-
if (statSync2(
|
|
6243
|
+
if (statSync2(join9(dir, child)).isDirectory()) addDir(child);
|
|
5018
6244
|
} catch {
|
|
5019
6245
|
}
|
|
5020
6246
|
});
|
|
@@ -5023,11 +6249,11 @@ function watchTree(dir, cb) {
|
|
|
5023
6249
|
}
|
|
5024
6250
|
watchers.set(rel, w);
|
|
5025
6251
|
};
|
|
5026
|
-
const
|
|
6252
|
+
const walk2 = (rel) => {
|
|
5027
6253
|
addDir(rel);
|
|
5028
6254
|
let names = [];
|
|
5029
6255
|
try {
|
|
5030
|
-
names = readdirSync2(rel ?
|
|
6256
|
+
names = readdirSync2(rel ? join9(dir, rel) : dir);
|
|
5031
6257
|
} catch {
|
|
5032
6258
|
return;
|
|
5033
6259
|
}
|
|
@@ -5035,25 +6261,25 @@ function watchTree(dir, cb) {
|
|
|
5035
6261
|
const child = rel ? `${rel}/${name}` : name;
|
|
5036
6262
|
if (SKIP2.test(child + "/")) continue;
|
|
5037
6263
|
try {
|
|
5038
|
-
if (statSync2(
|
|
6264
|
+
if (statSync2(join9(dir, child)).isDirectory()) walk2(child);
|
|
5039
6265
|
} catch {
|
|
5040
6266
|
}
|
|
5041
6267
|
}
|
|
5042
6268
|
};
|
|
5043
|
-
|
|
6269
|
+
walk2("");
|
|
5044
6270
|
return { close: () => {
|
|
5045
6271
|
for (const w of watchers.values()) w.close();
|
|
5046
6272
|
} };
|
|
5047
6273
|
}
|
|
5048
6274
|
function startWatcher(dir, log, policy) {
|
|
5049
6275
|
try {
|
|
5050
|
-
mkdirSync4(
|
|
6276
|
+
mkdirSync4(join9(log, ".."), { recursive: true });
|
|
5051
6277
|
} catch {
|
|
5052
6278
|
}
|
|
5053
6279
|
const last = /* @__PURE__ */ new Map();
|
|
5054
6280
|
return watchTree(dir, (kind, rel) => {
|
|
5055
6281
|
if (SKIP2.test(rel) || !isProtected(rel, policy)) return;
|
|
5056
|
-
const s = snap(
|
|
6282
|
+
const s = snap(join9(dir, rel));
|
|
5057
6283
|
const key2 = `${s.hash}:${s.mode}`;
|
|
5058
6284
|
if (last.get(rel) === key2) return;
|
|
5059
6285
|
last.set(rel, key2);
|
|
@@ -5080,7 +6306,7 @@ function runWatch(args) {
|
|
|
5080
6306
|
}
|
|
5081
6307
|
|
|
5082
6308
|
// src/detectors/fs-events.ts
|
|
5083
|
-
import { readFileSync as
|
|
6309
|
+
import { readFileSync as readFileSync10, statSync as statSync3 } from "node:fs";
|
|
5084
6310
|
var RULE11 = "transient-protected-mutation";
|
|
5085
6311
|
function readEvents(log, offset) {
|
|
5086
6312
|
let size = 0;
|
|
@@ -5090,7 +6316,7 @@ function readEvents(log, offset) {
|
|
|
5090
6316
|
return { events: [], newOffset: offset };
|
|
5091
6317
|
}
|
|
5092
6318
|
if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
|
|
5093
|
-
const raw =
|
|
6319
|
+
const raw = readFileSync10(log, "utf8").slice(offset);
|
|
5094
6320
|
const events = [];
|
|
5095
6321
|
for (const line of raw.split("\n")) {
|
|
5096
6322
|
if (!line.trim()) continue;
|
|
@@ -5143,7 +6369,7 @@ var HookInputError = class extends Error {
|
|
|
5143
6369
|
};
|
|
5144
6370
|
function readStdin() {
|
|
5145
6371
|
try {
|
|
5146
|
-
return
|
|
6372
|
+
return readFileSync11(0, "utf8");
|
|
5147
6373
|
} catch (e) {
|
|
5148
6374
|
throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
|
|
5149
6375
|
}
|
|
@@ -5202,7 +6428,7 @@ function fileChanges(changes) {
|
|
|
5202
6428
|
}
|
|
5203
6429
|
function hashOnDisk(cwd, rel) {
|
|
5204
6430
|
try {
|
|
5205
|
-
return contentHash(
|
|
6431
|
+
return contentHash(readFileSync11(join10(cwd, rel)));
|
|
5206
6432
|
} catch {
|
|
5207
6433
|
return null;
|
|
5208
6434
|
}
|
|
@@ -5391,7 +6617,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
|
|
|
5391
6617
|
if (!existsSync7(log)) return none;
|
|
5392
6618
|
let offset = 0;
|
|
5393
6619
|
if (cp && existsSync7(cp)) {
|
|
5394
|
-
const n = Number(
|
|
6620
|
+
const n = Number(readFileSync11(cp, "utf8"));
|
|
5395
6621
|
offset = Number.isFinite(n) ? n : 0;
|
|
5396
6622
|
}
|
|
5397
6623
|
const { events, newOffset } = readEvents(log, offset);
|
|
@@ -5405,7 +6631,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
|
|
|
5405
6631
|
const finalHash = (path) => {
|
|
5406
6632
|
try {
|
|
5407
6633
|
if (!statSync4(`${cwd}/${path}`).isFile()) return null;
|
|
5408
|
-
return createHash4("sha256").update(
|
|
6634
|
+
return createHash4("sha256").update(readFileSync11(`${cwd}/${path}`)).digest("hex").slice(0, 16);
|
|
5409
6635
|
} catch {
|
|
5410
6636
|
return null;
|
|
5411
6637
|
}
|
|
@@ -5548,9 +6774,8 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
5548
6774
|
// src/cli/init.ts
|
|
5549
6775
|
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
5550
6776
|
import { createHash as createHash5 } from "node:crypto";
|
|
5551
|
-
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as
|
|
5552
|
-
import { basename, dirname as
|
|
5553
|
-
import { fileURLToPath } from "node:url";
|
|
6777
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync12, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
|
|
6778
|
+
import { basename, dirname as dirname4, isAbsolute as isAbsolute5, join as join11, relative as relative3, resolve as resolve2 } from "node:path";
|
|
5554
6779
|
var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
|
|
5555
6780
|
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
5556
6781
|
function gitConfig(cwd, key2) {
|
|
@@ -5560,12 +6785,9 @@ function gitConfig(cwd, key2) {
|
|
|
5560
6785
|
return null;
|
|
5561
6786
|
}
|
|
5562
6787
|
}
|
|
5563
|
-
var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
|
|
5564
|
-
var MARKER = "# tamperward: block agent shortcuts before they land";
|
|
5565
6788
|
var HOOK_RE = /\btamperward(?:@\S+)?\s+hook\s+claude\b/;
|
|
5566
6789
|
var SWEEP_RE = /\btamperward(?:@\S+)?\s+sweep\s+claude\b/;
|
|
5567
6790
|
var PRECOMMIT_RE = /\btamperward(?:@\S+)?\s+check\s+--staged\b/;
|
|
5568
|
-
var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
|
|
5569
6791
|
var CODEOWNERS_MARK = "# tamperward: the gate cannot guard the file that defines it";
|
|
5570
6792
|
var CODEOWNERS_PATHS = ["/.github/workflows/", "/.tamperward.yml", "/.github/CODEOWNERS"];
|
|
5571
6793
|
function coveredBy(existing, critical) {
|
|
@@ -5609,24 +6831,6 @@ version: 1
|
|
|
5609
6831
|
# snapshot-rewrite: { severity: block }
|
|
5610
6832
|
# ignore: [] # visible blind spots \u2014 the count is always reported
|
|
5611
6833
|
`;
|
|
5612
|
-
function shippedVersion() {
|
|
5613
|
-
try {
|
|
5614
|
-
const here = dirname3(fileURLToPath(import.meta.url));
|
|
5615
|
-
for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
|
|
5616
|
-
try {
|
|
5617
|
-
const pkg = JSON.parse(readFileSync11(join10(here, rel), "utf8"));
|
|
5618
|
-
if (pkg.name === "tamperward" && pkg.version) return pkg.version;
|
|
5619
|
-
} catch {
|
|
5620
|
-
}
|
|
5621
|
-
}
|
|
5622
|
-
} catch {
|
|
5623
|
-
}
|
|
5624
|
-
return "latest";
|
|
5625
|
-
}
|
|
5626
|
-
var TW_VERSION = shippedVersion();
|
|
5627
|
-
var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
|
|
5628
|
-
var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
|
|
5629
|
-
var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
|
|
5630
6834
|
function pinOf(command) {
|
|
5631
6835
|
const m = command.match(OURS);
|
|
5632
6836
|
return m ? m[1] ?? "" : null;
|
|
@@ -5721,13 +6925,13 @@ function readWorkflowMark(src) {
|
|
|
5721
6925
|
if (!m) return null;
|
|
5722
6926
|
return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
|
|
5723
6927
|
}
|
|
5724
|
-
function
|
|
6928
|
+
function toolSet(matcher2) {
|
|
5725
6929
|
const m = String(matcher2 ?? "").trim();
|
|
5726
6930
|
if (m === "" || m === "*") return null;
|
|
5727
6931
|
return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
|
|
5728
6932
|
}
|
|
5729
6933
|
function missingTools(matcher2) {
|
|
5730
|
-
const have =
|
|
6934
|
+
const have = toolSet(matcher2);
|
|
5731
6935
|
if (have === null) return [];
|
|
5732
6936
|
return PRE_MATCHER.split("|").filter((t) => !have.has(t));
|
|
5733
6937
|
}
|
|
@@ -5747,7 +6951,7 @@ function hooksShapeError(hooks) {
|
|
|
5747
6951
|
return null;
|
|
5748
6952
|
}
|
|
5749
6953
|
function planPolicy(cwd) {
|
|
5750
|
-
const path =
|
|
6954
|
+
const path = join11(cwd, POLICY_FILE);
|
|
5751
6955
|
if (existsSync8(path)) {
|
|
5752
6956
|
try {
|
|
5753
6957
|
loadPolicy(cwd);
|
|
@@ -5766,12 +6970,12 @@ function planPolicy(cwd) {
|
|
|
5766
6970
|
}
|
|
5767
6971
|
function planClaudeHooks(cwd) {
|
|
5768
6972
|
const rel = ".claude/settings.json";
|
|
5769
|
-
const path =
|
|
6973
|
+
const path = join11(cwd, rel);
|
|
5770
6974
|
let settings = {};
|
|
5771
6975
|
if (existsSync8(path)) {
|
|
5772
6976
|
let parsed;
|
|
5773
6977
|
try {
|
|
5774
|
-
parsed = JSON.parse(
|
|
6978
|
+
parsed = JSON.parse(readFileSync12(path, "utf8"));
|
|
5775
6979
|
} catch {
|
|
5776
6980
|
return {
|
|
5777
6981
|
item: "agent",
|
|
@@ -5824,12 +7028,12 @@ function planClaudeHooks(cwd) {
|
|
|
5824
7028
|
});
|
|
5825
7029
|
}
|
|
5826
7030
|
for (const m of stale) {
|
|
5827
|
-
m.matcher = [...
|
|
7031
|
+
m.matcher = [...toolSet(m.matcher) ?? /* @__PURE__ */ new Set(), ...missingTools(m.matcher)].join("|");
|
|
5828
7032
|
}
|
|
5829
7033
|
if (needStop) {
|
|
5830
7034
|
(hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
|
|
5831
7035
|
}
|
|
5832
|
-
mkdirSync5(
|
|
7036
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
5833
7037
|
writeFileSync5(path, JSON.stringify(settings, null, 2) + "\n");
|
|
5834
7038
|
}
|
|
5835
7039
|
};
|
|
@@ -5864,7 +7068,7 @@ function hooksDir(cwd) {
|
|
|
5864
7068
|
if (out2) return resolve2(cwd, out2);
|
|
5865
7069
|
} catch {
|
|
5866
7070
|
}
|
|
5867
|
-
return existsSync8(
|
|
7071
|
+
return existsSync8(join11(cwd, ".git")) ? join11(cwd, ".git", "hooks") : null;
|
|
5868
7072
|
}
|
|
5869
7073
|
function display(cwd, path) {
|
|
5870
7074
|
const rel = relative3(cwd, path);
|
|
@@ -5891,30 +7095,30 @@ function planPreCommit(cwd) {
|
|
|
5891
7095
|
const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
|
|
5892
7096
|
let path;
|
|
5893
7097
|
let note;
|
|
5894
|
-
if (existsSync8(
|
|
5895
|
-
path =
|
|
7098
|
+
if (existsSync8(join11(cwd, ".husky"))) {
|
|
7099
|
+
path = join11(cwd, ".husky", "pre-commit");
|
|
5896
7100
|
note = "husky";
|
|
5897
7101
|
} else {
|
|
5898
|
-
const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(
|
|
7102
|
+
const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join11(cwd, c))));
|
|
5899
7103
|
if (managed) {
|
|
5900
7104
|
return skip(`${managed.name} manages this repository's hooks and regenerates them on install, so a line in the hooks directory would not last \u2014 ${managed.hint}`);
|
|
5901
7105
|
}
|
|
5902
7106
|
const dir = hooksDir(cwd);
|
|
5903
7107
|
if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
|
|
5904
|
-
if (basename(dir) === "_" && basename(
|
|
5905
|
-
path =
|
|
7108
|
+
if (basename(dir) === "_" && basename(dirname4(dir)) === ".husky") {
|
|
7109
|
+
path = join11(dirname4(dir), "pre-commit");
|
|
5906
7110
|
note = "husky";
|
|
5907
7111
|
} else {
|
|
5908
|
-
path =
|
|
7112
|
+
path = join11(dir, "pre-commit");
|
|
5909
7113
|
const rel2 = display(cwd, dir);
|
|
5910
|
-
note = rel2 ===
|
|
7114
|
+
note = rel2 === join11(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
|
|
5911
7115
|
}
|
|
5912
7116
|
}
|
|
5913
7117
|
const rel = display(cwd, path);
|
|
5914
7118
|
if (existsSync8(path) && !statSync5(path).isFile()) {
|
|
5915
7119
|
return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
|
|
5916
7120
|
}
|
|
5917
|
-
const existing = existsSync8(path) ?
|
|
7121
|
+
const existing = existsSync8(path) ? readFileSync12(path, "utf8") : null;
|
|
5918
7122
|
if (existing === null) {
|
|
5919
7123
|
return {
|
|
5920
7124
|
item: "pre-commit",
|
|
@@ -5922,7 +7126,7 @@ function planPreCommit(cwd) {
|
|
|
5922
7126
|
status: "create",
|
|
5923
7127
|
detail: `create via ${note}`,
|
|
5924
7128
|
apply: () => {
|
|
5925
|
-
mkdirSync5(
|
|
7129
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
5926
7130
|
writeFileSync5(path, `#!/bin/sh
|
|
5927
7131
|
${block.join("\n")}
|
|
5928
7132
|
`);
|
|
@@ -6003,10 +7207,10 @@ ${block.join("\n")}
|
|
|
6003
7207
|
function planCodeowners(cwd) {
|
|
6004
7208
|
const rel = ".github/CODEOWNERS";
|
|
6005
7209
|
const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
|
|
6006
|
-
(r) => existsSync8(
|
|
7210
|
+
(r) => existsSync8(join11(cwd, r))
|
|
6007
7211
|
);
|
|
6008
|
-
const path =
|
|
6009
|
-
const existing = existingRel ?
|
|
7212
|
+
const path = join11(cwd, existingRel ?? rel);
|
|
7213
|
+
const existing = existingRel ? readFileSync12(path, "utf8") : null;
|
|
6010
7214
|
const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
|
|
6011
7215
|
if (missing.length === 0) {
|
|
6012
7216
|
return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
|
|
@@ -6032,16 +7236,16 @@ ${CODEOWNERS_MARK}
|
|
|
6032
7236
|
warning: `${inferred.owner} was inferred from the origin remote and is not confirmed to be a user. If "${inferred.owner.slice(1)}" is an organisation, replace it with ${inferred.owner}/<team> in CODEOWNERS: GitHub rejects a bare organisation as an owner, and a rule with an invalid owner protects nothing.`
|
|
6033
7237
|
} : {},
|
|
6034
7238
|
apply: () => {
|
|
6035
|
-
mkdirSync5(
|
|
7239
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
6036
7240
|
writeFileSync5(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
|
|
6037
7241
|
}
|
|
6038
7242
|
};
|
|
6039
7243
|
}
|
|
6040
7244
|
function planWorkflow(cwd, force) {
|
|
6041
7245
|
const rel = ".github/workflows/tamperward.yml";
|
|
6042
|
-
const path =
|
|
7246
|
+
const path = join11(cwd, rel);
|
|
6043
7247
|
const write = () => {
|
|
6044
|
-
mkdirSync5(
|
|
7248
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
6045
7249
|
writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
|
|
6046
7250
|
};
|
|
6047
7251
|
if (!existsSync8(path)) {
|
|
@@ -6055,7 +7259,7 @@ function planWorkflow(cwd, force) {
|
|
|
6055
7259
|
}
|
|
6056
7260
|
let src;
|
|
6057
7261
|
try {
|
|
6058
|
-
src =
|
|
7262
|
+
src = readFileSync12(path, "utf8");
|
|
6059
7263
|
} catch {
|
|
6060
7264
|
return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
|
|
6061
7265
|
}
|
|
@@ -6154,15 +7358,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
6154
7358
|
// src/cli/verify.ts
|
|
6155
7359
|
import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
|
|
6156
7360
|
import { createHash as createHash8 } from "node:crypto";
|
|
6157
|
-
import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as
|
|
7361
|
+
import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync15, readlinkSync as readlinkSync2, rmSync as rmSync3, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
|
|
6158
7362
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
6159
|
-
import { dirname as
|
|
7363
|
+
import { dirname as dirname5, join as join14, resolve as resolve3 } from "node:path";
|
|
6160
7364
|
|
|
6161
7365
|
// src/git/trusted.ts
|
|
6162
7366
|
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
6163
7367
|
import { createHash as createHash6 } from "node:crypto";
|
|
6164
|
-
import { existsSync as existsSync9, readFileSync as
|
|
6165
|
-
import { isAbsolute as isAbsolute6, join as
|
|
7368
|
+
import { existsSync as existsSync9, readFileSync as readFileSync13 } from "node:fs";
|
|
7369
|
+
import { isAbsolute as isAbsolute6, join as join12 } from "node:path";
|
|
6166
7370
|
function trustedGitEnv() {
|
|
6167
7371
|
return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
6168
7372
|
}
|
|
@@ -6175,7 +7379,7 @@ function gitDirOf(cwd) {
|
|
|
6175
7379
|
env: trustedGitEnv()
|
|
6176
7380
|
}).trim();
|
|
6177
7381
|
if (!d) return null;
|
|
6178
|
-
return isAbsolute6(d) ? d :
|
|
7382
|
+
return isAbsolute6(d) ? d : join12(cwd, d);
|
|
6179
7383
|
} catch {
|
|
6180
7384
|
return null;
|
|
6181
7385
|
}
|
|
@@ -6200,9 +7404,9 @@ function objectRewriteState(cwd) {
|
|
|
6200
7404
|
parts.push(`${rel}:<no-git-dir>`);
|
|
6201
7405
|
continue;
|
|
6202
7406
|
}
|
|
6203
|
-
const p =
|
|
7407
|
+
const p = join12(dir, rel);
|
|
6204
7408
|
try {
|
|
6205
|
-
parts.push(`${rel}:` + (existsSync9(p) ?
|
|
7409
|
+
parts.push(`${rel}:` + (existsSync9(p) ? readFileSync13(p, "utf8").trim() : ""));
|
|
6206
7410
|
} catch {
|
|
6207
7411
|
parts.push(`${rel}:<unreadable>`);
|
|
6208
7412
|
}
|
|
@@ -6213,8 +7417,8 @@ function objectRewriteState(cwd) {
|
|
|
6213
7417
|
// src/fingerprint.ts
|
|
6214
7418
|
import { execFileSync as execFileSync7 } from "node:child_process";
|
|
6215
7419
|
import { createHash as createHash7 } from "node:crypto";
|
|
6216
|
-
import { readdirSync as readdirSync3, readFileSync as
|
|
6217
|
-
import { join as
|
|
7420
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync14, readlinkSync, statSync as statSync6 } from "node:fs";
|
|
7421
|
+
import { join as join13 } from "node:path";
|
|
6218
7422
|
function treeFingerprint(cwd) {
|
|
6219
7423
|
const files = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
6220
7424
|
cwd,
|
|
@@ -6227,7 +7431,7 @@ function treeFingerprint(cwd) {
|
|
|
6227
7431
|
h.update(rel);
|
|
6228
7432
|
h.update("\0");
|
|
6229
7433
|
try {
|
|
6230
|
-
h.update(
|
|
7434
|
+
h.update(readFileSync14(join13(cwd, rel)));
|
|
6231
7435
|
} catch {
|
|
6232
7436
|
h.update("<unreadable>");
|
|
6233
7437
|
}
|
|
@@ -6236,9 +7440,9 @@ function treeFingerprint(cwd) {
|
|
|
6236
7440
|
return h.digest("hex");
|
|
6237
7441
|
}
|
|
6238
7442
|
function depsFingerprint(cwd) {
|
|
6239
|
-
const root =
|
|
7443
|
+
const root = join13(cwd, "node_modules");
|
|
6240
7444
|
const h = createHash7("sha256");
|
|
6241
|
-
const
|
|
7445
|
+
const walk2 = (dir) => {
|
|
6242
7446
|
let entries;
|
|
6243
7447
|
try {
|
|
6244
7448
|
entries = readdirSync3(dir, { withFileTypes: true });
|
|
@@ -6246,12 +7450,12 @@ function depsFingerprint(cwd) {
|
|
|
6246
7450
|
return;
|
|
6247
7451
|
}
|
|
6248
7452
|
for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
6249
|
-
const p =
|
|
6250
|
-
if (e.isDirectory())
|
|
7453
|
+
const p = join13(dir, e.name);
|
|
7454
|
+
if (e.isDirectory()) walk2(p);
|
|
6251
7455
|
else if (e.isFile()) {
|
|
6252
7456
|
h.update(p);
|
|
6253
7457
|
try {
|
|
6254
|
-
h.update(
|
|
7458
|
+
h.update(readFileSync14(p));
|
|
6255
7459
|
h.update(String(statSync6(p).mode));
|
|
6256
7460
|
} catch {
|
|
6257
7461
|
h.update("<unreadable>");
|
|
@@ -6271,7 +7475,7 @@ function depsFingerprint(cwd) {
|
|
|
6271
7475
|
} catch {
|
|
6272
7476
|
return "none";
|
|
6273
7477
|
}
|
|
6274
|
-
|
|
7478
|
+
walk2(root);
|
|
6275
7479
|
return h.digest("hex");
|
|
6276
7480
|
}
|
|
6277
7481
|
|
|
@@ -6366,7 +7570,7 @@ function baseIsAncestorOfHead(base, cwd) {
|
|
|
6366
7570
|
function materialize(cwd, dest) {
|
|
6367
7571
|
const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
6368
7572
|
for (const rel of listed) {
|
|
6369
|
-
const src =
|
|
7573
|
+
const src = join14(cwd, rel);
|
|
6370
7574
|
let st;
|
|
6371
7575
|
try {
|
|
6372
7576
|
st = statSync7(src);
|
|
@@ -6374,18 +7578,18 @@ function materialize(cwd, dest) {
|
|
|
6374
7578
|
continue;
|
|
6375
7579
|
}
|
|
6376
7580
|
if (!st.isFile()) continue;
|
|
6377
|
-
const out2 =
|
|
6378
|
-
mkdirSync6(
|
|
7581
|
+
const out2 = join14(dest, rel);
|
|
7582
|
+
mkdirSync6(dirname5(out2), { recursive: true });
|
|
6379
7583
|
dropSymlink(out2);
|
|
6380
7584
|
if (lstatSync(src).isSymbolicLink()) {
|
|
6381
|
-
writeFileSync6(out2,
|
|
7585
|
+
writeFileSync6(out2, readFileSync15(src));
|
|
6382
7586
|
} else {
|
|
6383
7587
|
cpSync(src, out2, { dereference: true });
|
|
6384
7588
|
}
|
|
6385
7589
|
chmodSync2(out2, st.mode);
|
|
6386
7590
|
}
|
|
6387
7591
|
const nm = resolve3(cwd, "node_modules");
|
|
6388
|
-
if (existsSync10(nm)) symlinkSync(nm,
|
|
7592
|
+
if (existsSync10(nm)) symlinkSync(nm, join14(dest, "node_modules"), "dir");
|
|
6389
7593
|
}
|
|
6390
7594
|
function dropSymlink(p) {
|
|
6391
7595
|
try {
|
|
@@ -6418,11 +7622,11 @@ function overlayDigest(dest, paths) {
|
|
|
6418
7622
|
for (const rel of [...paths].sort()) {
|
|
6419
7623
|
h.update(rel);
|
|
6420
7624
|
h.update("\0");
|
|
6421
|
-
const p =
|
|
7625
|
+
const p = join14(dest, rel);
|
|
6422
7626
|
try {
|
|
6423
7627
|
const st = lstatSync(p);
|
|
6424
7628
|
h.update(String(st.mode));
|
|
6425
|
-
h.update(st.isSymbolicLink() ? readlinkSync2(p) :
|
|
7629
|
+
h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync15(p));
|
|
6426
7630
|
} catch {
|
|
6427
7631
|
h.update("<gone>");
|
|
6428
7632
|
}
|
|
@@ -6445,8 +7649,8 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
|
6445
7649
|
throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
|
|
6446
7650
|
}
|
|
6447
7651
|
const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
6448
|
-
const out2 =
|
|
6449
|
-
mkdirSync6(
|
|
7652
|
+
const out2 = join14(dest, e.path);
|
|
7653
|
+
mkdirSync6(dirname5(out2), { recursive: true });
|
|
6450
7654
|
rmSync3(out2, { force: true });
|
|
6451
7655
|
if (e.mode === "120000") {
|
|
6452
7656
|
symlinkSync(content.toString("utf8"), out2);
|
|
@@ -6460,7 +7664,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
|
6460
7664
|
const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
6461
7665
|
for (const rel of inCopy) {
|
|
6462
7666
|
if (!isOverlay(rel) || baseProtected.has(rel)) continue;
|
|
6463
|
-
rmSync3(
|
|
7667
|
+
rmSync3(join14(dest, rel), { force: true });
|
|
6464
7668
|
removed++;
|
|
6465
7669
|
}
|
|
6466
7670
|
return { restored, removed };
|
|
@@ -6502,8 +7706,8 @@ function suiteEnv(scratch) {
|
|
|
6502
7706
|
if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
|
|
6503
7707
|
env[k] = v;
|
|
6504
7708
|
}
|
|
6505
|
-
const userRc =
|
|
6506
|
-
const globalRc =
|
|
7709
|
+
const userRc = join14(scratch, "npmrc-user");
|
|
7710
|
+
const globalRc = join14(scratch, "npmrc-global");
|
|
6507
7711
|
for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
|
|
6508
7712
|
env.npm_config_userconfig = userRc;
|
|
6509
7713
|
env.npm_config_globalconfig = globalRc;
|
|
@@ -6513,8 +7717,8 @@ function suiteEnv(scratch) {
|
|
|
6513
7717
|
}
|
|
6514
7718
|
function runSuite(dir, cmd, budgetSecs) {
|
|
6515
7719
|
const t0 = Date.now();
|
|
6516
|
-
const outDir = mkdtempSync2(
|
|
6517
|
-
const outFile =
|
|
7720
|
+
const outDir = mkdtempSync2(join14(tmpdir2(), "tw-verify-run-"));
|
|
7721
|
+
const outFile = join14(outDir, "result.json");
|
|
6518
7722
|
try {
|
|
6519
7723
|
spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
|
|
6520
7724
|
cwd: dir,
|
|
@@ -6527,7 +7731,7 @@ function runSuite(dir, cmd, budgetSecs) {
|
|
|
6527
7731
|
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
6528
7732
|
let r;
|
|
6529
7733
|
try {
|
|
6530
|
-
r = JSON.parse(
|
|
7734
|
+
r = JSON.parse(readFileSync15(outFile, "utf8"));
|
|
6531
7735
|
} catch {
|
|
6532
7736
|
return { exit: null, secs };
|
|
6533
7737
|
}
|
|
@@ -6578,8 +7782,8 @@ function runVerify(opts) {
|
|
|
6578
7782
|
out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
|
|
6579
7783
|
return 2;
|
|
6580
7784
|
}
|
|
6581
|
-
const visRoot = mkdtempSync2(
|
|
6582
|
-
const visDir =
|
|
7785
|
+
const visRoot = mkdtempSync2(join14(tmpdir2(), "tw-verify-vis-"));
|
|
7786
|
+
const visDir = join14(visRoot, "t");
|
|
6583
7787
|
const cleanup = (dirs) => {
|
|
6584
7788
|
if (opts.keep) return;
|
|
6585
7789
|
for (const d of dirs) rmSync3(d, { recursive: true, force: true });
|
|
@@ -6607,8 +7811,8 @@ function runVerify(opts) {
|
|
|
6607
7811
|
out2("pristine run executes through it too. Failing closed, not open.");
|
|
6608
7812
|
return 2;
|
|
6609
7813
|
}
|
|
6610
|
-
const priRoot = mkdtempSync2(
|
|
6611
|
-
const priDir =
|
|
7814
|
+
const priRoot = mkdtempSync2(join14(tmpdir2(), "tw-verify-pri-"));
|
|
7815
|
+
const priDir = join14(priRoot, "t");
|
|
6612
7816
|
let restored = [];
|
|
6613
7817
|
let removedAdded = 0;
|
|
6614
7818
|
try {
|
|
@@ -6720,7 +7924,7 @@ function parseVerify(args) {
|
|
|
6720
7924
|
|
|
6721
7925
|
// src/cli/run.ts
|
|
6722
7926
|
import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
|
|
6723
|
-
import { readdirSync as readdirSync4, readFileSync as
|
|
7927
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync16, realpathSync } from "node:fs";
|
|
6724
7928
|
import { resolve as resolve4 } from "node:path";
|
|
6725
7929
|
var out = (s) => process.stdout.write(s + "\n");
|
|
6726
7930
|
var err = (s) => process.stderr.write(s + "\n");
|
|
@@ -6729,7 +7933,7 @@ function git3(args, cwd) {
|
|
|
6729
7933
|
}
|
|
6730
7934
|
function nowTicks() {
|
|
6731
7935
|
try {
|
|
6732
|
-
return Math.floor(parseFloat(
|
|
7936
|
+
return Math.floor(parseFloat(readFileSync16("/proc/uptime", "utf8").split(" ")[0]) * 100);
|
|
6733
7937
|
} catch {
|
|
6734
7938
|
return Number.POSITIVE_INFINITY;
|
|
6735
7939
|
}
|
|
@@ -6759,7 +7963,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
6759
7963
|
}
|
|
6760
7964
|
if (held !== real && !held.startsWith(real + "/")) continue;
|
|
6761
7965
|
try {
|
|
6762
|
-
const stat =
|
|
7966
|
+
const stat = readFileSync16(`/proc/${pid}/stat`, "utf8");
|
|
6763
7967
|
const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
|
|
6764
7968
|
if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
|
|
6765
7969
|
} catch {
|