tamperward 2.6.0 → 2.7.0
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 +1368 -338
- 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",
|
|
@@ -1605,9 +1642,9 @@ function simpleNarrowings(path, before, after) {
|
|
|
1605
1642
|
for (const e of listEntries(after, key2)) if (!had.has(e) && !BENIGN_EXEMPTION.test(e)) out2.push(`${what} ${JSON.stringify(e)}`);
|
|
1606
1643
|
}
|
|
1607
1644
|
if (CODECOV.test(path)) {
|
|
1608
|
-
const
|
|
1609
|
-
const had =
|
|
1610
|
-
for (const s of
|
|
1645
|
+
const off2 = (s) => new Set(s.split("\n").map((l) => l.match(/^\s*(project|patch):\s*off\b/)?.[1]).filter((x) => !!x));
|
|
1646
|
+
const had = off2(before);
|
|
1647
|
+
for (const s of off2(after)) if (!had.has(s)) out2.push(`codecov status.${s} switched off`);
|
|
1611
1648
|
}
|
|
1612
1649
|
return out2;
|
|
1613
1650
|
}
|
|
@@ -2121,8 +2158,8 @@ function parseTriggers(src) {
|
|
|
2121
2158
|
const head = lines[i].replace(/^(?:on|"on"|'on'|true):\s*/, "");
|
|
2122
2159
|
const inline = uncommented(head);
|
|
2123
2160
|
if (inline) {
|
|
2124
|
-
const
|
|
2125
|
-
for (const e of
|
|
2161
|
+
const list2 = flowList(inline);
|
|
2162
|
+
for (const e of list2 ?? [inline]) t.events.add(e);
|
|
2126
2163
|
return t;
|
|
2127
2164
|
}
|
|
2128
2165
|
let event = null;
|
|
@@ -2153,8 +2190,8 @@ function parseTriggers(src) {
|
|
|
2153
2190
|
if (event && key2) {
|
|
2154
2191
|
listKey = key2;
|
|
2155
2192
|
listInd = ind;
|
|
2156
|
-
const
|
|
2157
|
-
t[listKey].set(event,
|
|
2193
|
+
const list2 = flowList(kv[2]);
|
|
2194
|
+
t[listKey].set(event, list2 ?? []);
|
|
2158
2195
|
} else listKey = null;
|
|
2159
2196
|
}
|
|
2160
2197
|
return t;
|
|
@@ -2195,25 +2232,25 @@ function triggerNarrowings(before, after, opts) {
|
|
|
2195
2232
|
for (const e of CODE_EVENTS) {
|
|
2196
2233
|
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
2234
|
}
|
|
2198
|
-
for (const [e,
|
|
2235
|
+
for (const [e, list2] of after.pathsIgnore) {
|
|
2199
2236
|
const had = before.pathsIgnore.get(e) ?? [];
|
|
2200
|
-
if (
|
|
2237
|
+
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
2238
|
}
|
|
2202
|
-
for (const [e,
|
|
2239
|
+
for (const [e, list2] of after.paths) {
|
|
2203
2240
|
if (!CODE_EVENTS.includes(e) || !after.events.has(e)) continue;
|
|
2204
2241
|
const had = before.paths.get(e);
|
|
2205
2242
|
if (had && !pathsMatchSource(had, opts.sources)) continue;
|
|
2206
|
-
if (!pathsMatchSource(
|
|
2243
|
+
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
2244
|
}
|
|
2208
|
-
for (const [e,
|
|
2245
|
+
for (const [e, list2] of after.types) {
|
|
2209
2246
|
if (!/^pull_request(?:_target)?$/.test(e) || !after.events.has(e)) continue;
|
|
2210
2247
|
const had = before.types.get(e);
|
|
2211
2248
|
if (had && !had.some((t) => PR_CODE_TYPES.test(t))) continue;
|
|
2212
|
-
if (!
|
|
2249
|
+
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
2250
|
}
|
|
2214
|
-
for (const [e,
|
|
2251
|
+
for (const [e, list2] of after.branchesIgnore) {
|
|
2215
2252
|
const had = before.branchesIgnore.get(e) ?? [];
|
|
2216
|
-
if (
|
|
2253
|
+
if (list2.some((g) => (ALL(g) || isDefault(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
|
|
2217
2254
|
}
|
|
2218
2255
|
for (const [e, was] of before.branches) {
|
|
2219
2256
|
const now = after.branches.get(e);
|
|
@@ -2374,12 +2411,59 @@ var ciTampering = {
|
|
|
2374
2411
|
};
|
|
2375
2412
|
|
|
2376
2413
|
// src/detectors/hook-tampering.ts
|
|
2377
|
-
import { existsSync, readFileSync as
|
|
2378
|
-
import { isAbsolute, join as
|
|
2414
|
+
import { existsSync, readFileSync as readFileSync3 } from "node:fs";
|
|
2415
|
+
import { isAbsolute, join as join3, relative } from "node:path";
|
|
2416
|
+
|
|
2417
|
+
// src/wiring.ts
|
|
2418
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
2419
|
+
import { dirname, join as join2 } from "node:path";
|
|
2420
|
+
import { fileURLToPath } from "node:url";
|
|
2421
|
+
function shippedVersion() {
|
|
2422
|
+
try {
|
|
2423
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
2424
|
+
for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
|
|
2425
|
+
try {
|
|
2426
|
+
const pkg = JSON.parse(readFileSync2(join2(here, rel), "utf8"));
|
|
2427
|
+
if (pkg.name === "tamperward" && pkg.version) return pkg.version;
|
|
2428
|
+
} catch {
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
} catch {
|
|
2432
|
+
}
|
|
2433
|
+
return "latest";
|
|
2434
|
+
}
|
|
2435
|
+
var TW_VERSION = shippedVersion();
|
|
2436
|
+
var PRE_TOOLS = ["Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"];
|
|
2437
|
+
var PRE_MATCHER = PRE_TOOLS.join("|");
|
|
2438
|
+
var MARKER = "# tamperward: block agent shortcuts before they land";
|
|
2439
|
+
var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
|
|
2440
|
+
var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
|
|
2441
|
+
var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
|
|
2442
|
+
var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
|
|
2443
|
+
var PLAIN_SEMVER = /^\d+\.\d+\.\d+$/;
|
|
2444
|
+
function compareVersions(a, b) {
|
|
2445
|
+
if (!PLAIN_SEMVER.test(a) || !PLAIN_SEMVER.test(b)) return null;
|
|
2446
|
+
const pa = a.split(".").map(Number);
|
|
2447
|
+
const pb = b.split(".").map(Number);
|
|
2448
|
+
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
|
|
2449
|
+
return 0;
|
|
2450
|
+
}
|
|
2451
|
+
function pinNotBelow(now, floor) {
|
|
2452
|
+
if (!now) return false;
|
|
2453
|
+
const c = compareVersions(now, floor);
|
|
2454
|
+
return c !== null && c >= 0;
|
|
2455
|
+
}
|
|
2456
|
+
var INIT_SCRIPT = /^#!\/bin\/sh\n# tamperward: block agent shortcuts before they land\nnpx --yes tamperward(?:@(\S+))? check --staged\n?$/;
|
|
2457
|
+
function initScriptPin(src) {
|
|
2458
|
+
const m = src.match(INIT_SCRIPT);
|
|
2459
|
+
return m ? m[1] ?? "" : null;
|
|
2460
|
+
}
|
|
2461
|
+
var CLAUDE_SETTINGS = /(?:^|\/)(?:\.claude\/settings(?:\.local)?\.json|managed-settings\.json)$/;
|
|
2462
|
+
var isClaudeSettings = (path) => CLAUDE_SETTINGS.test(path);
|
|
2379
2463
|
|
|
2380
2464
|
// src/detectors/policy-diff.ts
|
|
2381
2465
|
import { parse } from "yaml";
|
|
2382
|
-
var globs2 = (
|
|
2466
|
+
var globs2 = (list2) => Array.isArray(list2) ? list2.filter((g) => typeof g === "string").map(normalizeGlob) : [];
|
|
2383
2467
|
function safeParse(src) {
|
|
2384
2468
|
try {
|
|
2385
2469
|
const v = parse(src);
|
|
@@ -2398,7 +2482,7 @@ function effective(raw) {
|
|
|
2398
2482
|
const over = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg : {};
|
|
2399
2483
|
rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude: globs2(over.exclude) } : {} };
|
|
2400
2484
|
}
|
|
2401
|
-
const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat,
|
|
2485
|
+
const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list2]) => [cat, globs2(list2)])) : void 0;
|
|
2402
2486
|
return {
|
|
2403
2487
|
version,
|
|
2404
2488
|
rules,
|
|
@@ -2519,7 +2603,7 @@ var RUNNER = /^(?:npx|pnpm|yarn|bunx|bun|npm)\s+(?:(?:exec|dlx|x|run)\s+)?(?:(?:
|
|
|
2519
2603
|
var TOOL = /^(tamperward|jest|vitest|eslint|tsc|lint-staged|lefthook|husky|pytest|make|cargo|go|pre-commit|mypy|ruff|rubocop|prettier|gradlew?)\b/;
|
|
2520
2604
|
var SCRIPT = /^(test|tests|lint|typecheck|type-check|coverage|check)\b/;
|
|
2521
2605
|
var SUBCOMMAND_TOOLS = /* @__PURE__ */ new Set(["tamperward", "make", "cargo", "go", "lefthook", "husky", "pre-commit"]);
|
|
2522
|
-
var PREFIX = /^(?:(?:then|do|else|\{|\(
|
|
2606
|
+
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
2607
|
function checkIdentity(segment) {
|
|
2524
2608
|
const core = segment.trim().replace(PREFIX, "").replace(/\s+/g, " ");
|
|
2525
2609
|
const viaRunner = RUNNER.test(core);
|
|
@@ -2530,20 +2614,49 @@ function checkIdentity(segment) {
|
|
|
2530
2614
|
if (tool === "tamperward") {
|
|
2531
2615
|
const sub = toks[1];
|
|
2532
2616
|
if (!sub || sub.startsWith("-")) return null;
|
|
2533
|
-
|
|
2617
|
+
const cwd = rest.match(/\s--cwd(?:=|\s+)(\S+)/)?.[1];
|
|
2618
|
+
return cwd ? `tamperward ${sub} --cwd ${cwd}` : `tamperward ${sub}`;
|
|
2534
2619
|
}
|
|
2535
2620
|
if (SUBCOMMAND_TOOLS.has(tool) && toks[1] && !toks[1].startsWith("-")) return `${tool} ${toks[1]}`;
|
|
2536
2621
|
return tool;
|
|
2537
2622
|
}
|
|
2623
|
+
function pinOfSegment(segment) {
|
|
2624
|
+
const m = segment.trim().replace(PREFIX, "").replace(RUNNER, "").match(/^tamperward(?:@(\S+))?\b/);
|
|
2625
|
+
return m ? m[1] ?? "" : null;
|
|
2626
|
+
}
|
|
2538
2627
|
function split(stmt) {
|
|
2539
2628
|
const segs = [];
|
|
2540
2629
|
const ops = [];
|
|
2541
|
-
|
|
2630
|
+
let depth = 0;
|
|
2631
|
+
let single = false;
|
|
2632
|
+
let double = false;
|
|
2542
2633
|
let last = 0;
|
|
2543
|
-
for (
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2634
|
+
for (let i = 0; i < stmt.length; i++) {
|
|
2635
|
+
const ch = stmt[i];
|
|
2636
|
+
if (ch === "'" && !double) {
|
|
2637
|
+
single = !single;
|
|
2638
|
+
continue;
|
|
2639
|
+
}
|
|
2640
|
+
if (ch === '"' && !single) {
|
|
2641
|
+
double = !double;
|
|
2642
|
+
continue;
|
|
2643
|
+
}
|
|
2644
|
+
if (single || double) continue;
|
|
2645
|
+
if (ch === "(" || ch === "{" && /(?:^|\s)$/.test(stmt.slice(0, i)) && /^(?:\s|$)/.test(stmt.slice(i + 1))) {
|
|
2646
|
+
depth++;
|
|
2647
|
+
continue;
|
|
2648
|
+
}
|
|
2649
|
+
if (ch === ")" || ch === "}" && depth > 0 && /(?:^|[\s;])$/.test(stmt.slice(0, i))) {
|
|
2650
|
+
depth = Math.max(0, depth - 1);
|
|
2651
|
+
continue;
|
|
2652
|
+
}
|
|
2653
|
+
if (depth > 0) continue;
|
|
2654
|
+
const op = stmt.startsWith("&&", i) ? "&&" : stmt.startsWith("||", i) ? "||" : ch === ";" ? ";" : ch === "|" ? "|" : null;
|
|
2655
|
+
if (!op) continue;
|
|
2656
|
+
segs.push(stmt.slice(last, i));
|
|
2657
|
+
ops.push(op);
|
|
2658
|
+
i += op.length - 1;
|
|
2659
|
+
last = i + 1;
|
|
2547
2660
|
}
|
|
2548
2661
|
segs.push(stmt.slice(last));
|
|
2549
2662
|
return { segs, ops };
|
|
@@ -2592,106 +2705,602 @@ function alwaysFalse(cond) {
|
|
|
2592
2705
|
if (/^(?:""|'')$/.test(inner)) return true;
|
|
2593
2706
|
return false;
|
|
2594
2707
|
}
|
|
2595
|
-
var alwaysTrue = (cond) =>
|
|
2708
|
+
var alwaysTrue = (cond) => {
|
|
2709
|
+
const c = cond.trim().replace(/;$/, "").trim();
|
|
2710
|
+
if (/^(?:true|:|!\s*false|\(\s*exit\s+0*\s*\))$/.test(c)) return true;
|
|
2711
|
+
const m = c.match(/^(?:\[\[\s+(.+?)\s+\]\]|\[\s+(.+?)\s+\]|test\s+(.+))$/);
|
|
2712
|
+
if (!m) return false;
|
|
2713
|
+
const inner = (m[1] ?? m[2] ?? m[3]).trim();
|
|
2714
|
+
if (inner.includes("$")) return false;
|
|
2715
|
+
const parts = inner.match(/^(\S+)\s+(-eq|-ne|-lt|-le|-gt|-ge|=|==|!=)\s+(\S+)$/);
|
|
2716
|
+
if (parts) {
|
|
2717
|
+
const ints = /^-?\d+$/.test(unq(parts[1])) && /^-?\d+$/.test(unq(parts[3]));
|
|
2718
|
+
if (parts[2].startsWith("-") && !ints) return false;
|
|
2719
|
+
return !alwaysFalse(c);
|
|
2720
|
+
}
|
|
2721
|
+
if (/^(?:-n|-z)\s+\S+$/.test(inner)) return !alwaysFalse(c);
|
|
2722
|
+
if (/^\S+$/.test(inner)) return unq(inner) !== "";
|
|
2723
|
+
return false;
|
|
2724
|
+
};
|
|
2596
2725
|
var EXIT_ZERO = /^(?:exit|return)(?:\s+0+)?$/;
|
|
2597
2726
|
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
2727
|
function insertsPassingExit(line) {
|
|
2600
2728
|
if (isComment(line)) return false;
|
|
2601
|
-
return split(stripComment(line)).segs.some((s) => EXIT_ZERO.test(s.trim().replace(PREFIX, "")));
|
|
2729
|
+
return split(stripComment(line)).segs.some((s) => EXIT_ZERO.test(s.trim().replace(/^!\s+/, "").replace(PREFIX, "")));
|
|
2602
2730
|
}
|
|
2603
2731
|
function insertsDeadGuard(line) {
|
|
2604
2732
|
if (isComment(line)) return false;
|
|
2605
2733
|
const m = stripComment(line).trim().match(/^(?:if|elif)\s+(.+?)\s*(?:;\s*then)?\s*$/);
|
|
2606
2734
|
return !!m && alwaysFalse(m[1]);
|
|
2607
2735
|
}
|
|
2608
|
-
function
|
|
2736
|
+
function joinContinued(lines) {
|
|
2609
2737
|
const out2 = [];
|
|
2610
|
-
|
|
2611
|
-
const
|
|
2738
|
+
let cur = null;
|
|
2739
|
+
for (const l of lines) {
|
|
2740
|
+
cur = cur === null ? l : cur + " " + l.trimStart();
|
|
2741
|
+
const m = cur.match(/(\\+)$/);
|
|
2742
|
+
if (m && m[1].length % 2 === 1 && !isComment(cur)) {
|
|
2743
|
+
cur = cur.slice(0, -1);
|
|
2744
|
+
continue;
|
|
2745
|
+
}
|
|
2746
|
+
out2.push(cur);
|
|
2747
|
+
cur = null;
|
|
2748
|
+
}
|
|
2749
|
+
if (cur !== null) out2.push(cur);
|
|
2750
|
+
return out2;
|
|
2751
|
+
}
|
|
2752
|
+
function stripHeredocs(lines) {
|
|
2753
|
+
const out2 = [];
|
|
2754
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2755
|
+
const l = lines[i];
|
|
2756
|
+
out2.push(l);
|
|
2757
|
+
if (isComment(l)) continue;
|
|
2758
|
+
const m = stripComment(l).match(/<<(-?)\s*(?:'([^']+)'|"([^"]+)"|\\?([A-Za-z_]\w*))/);
|
|
2759
|
+
if (!m) continue;
|
|
2760
|
+
const word = m[2] ?? m[3] ?? m[4];
|
|
2761
|
+
const dash = m[1] === "-";
|
|
2762
|
+
for (i++; i < lines.length; i++) {
|
|
2763
|
+
const t = dash ? lines[i].replace(/^\t+/, "") : lines[i];
|
|
2764
|
+
if (t === word) break;
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
return out2;
|
|
2768
|
+
}
|
|
2769
|
+
function explodeKeywords(line) {
|
|
2770
|
+
if (isComment(line)) return [line];
|
|
2771
|
+
let s = stripComment(line);
|
|
2772
|
+
s = s.replace(/;;/g, "\n;;");
|
|
2773
|
+
s = s.replace(/;\s*(then|do|fi|done|esac|else|elif)\b/g, "\n$1");
|
|
2774
|
+
s = s.replace(/^(\s*case\s+\S+\s+in)\s+(?=\S)/, "$1\n");
|
|
2775
|
+
s = s.replace(/^(\s*)(then|do|else)\s+(?=\S)/, "$1$2\n");
|
|
2776
|
+
s = s.replace(/\n(then|do|else)\s+(?=\S)/g, "\n$1\n");
|
|
2777
|
+
return s.split("\n");
|
|
2778
|
+
}
|
|
2779
|
+
function closingBrace(line, depth) {
|
|
2780
|
+
let single = false;
|
|
2781
|
+
let double = false;
|
|
2782
|
+
let expansion = 0;
|
|
2783
|
+
for (let i = 0; i < line.length; i++) {
|
|
2784
|
+
const ch = line[i];
|
|
2785
|
+
if (ch === "'" && !double) single = !single;
|
|
2786
|
+
else if (ch === '"' && !single) double = !double;
|
|
2787
|
+
else if (single) continue;
|
|
2788
|
+
else if (ch === "$" && line[i + 1] === "{") {
|
|
2789
|
+
expansion++;
|
|
2790
|
+
i++;
|
|
2791
|
+
} else if (ch === "}" && expansion > 0) expansion--;
|
|
2792
|
+
else if (ch === "{" && !double) depth++;
|
|
2793
|
+
else if (ch === "}" && !double && --depth === 0) return i;
|
|
2794
|
+
}
|
|
2795
|
+
return -1;
|
|
2796
|
+
}
|
|
2797
|
+
function braceDelta(line) {
|
|
2798
|
+
let d = 0;
|
|
2799
|
+
let single = false;
|
|
2800
|
+
let double = false;
|
|
2801
|
+
let expansion = 0;
|
|
2802
|
+
for (let i = 0; i < line.length; i++) {
|
|
2803
|
+
const ch = line[i];
|
|
2804
|
+
if (ch === "'" && !double) single = !single;
|
|
2805
|
+
else if (ch === '"' && !single) double = !double;
|
|
2806
|
+
else if (single) continue;
|
|
2807
|
+
else if (ch === "$" && line[i + 1] === "{") {
|
|
2808
|
+
expansion++;
|
|
2809
|
+
i++;
|
|
2810
|
+
} else if (ch === "}" && expansion > 0) expansion--;
|
|
2811
|
+
else if (ch === "{" && !double) d++;
|
|
2812
|
+
else if (ch === "}" && !double) d--;
|
|
2813
|
+
}
|
|
2814
|
+
return d;
|
|
2815
|
+
}
|
|
2816
|
+
var FN_DEF = /^\s*(?:function\s+)?([A-Za-z_][\w-]*)\s*\(\s*\)\s*(.*)$|^\s*function\s+([A-Za-z_][\w-]*)\s*(\{.*)?$/;
|
|
2817
|
+
function prepare(lines, opts) {
|
|
2818
|
+
const flat = stripHeredocs(joinContinued(lines)).flatMap(explodeKeywords);
|
|
2819
|
+
const top = [];
|
|
2820
|
+
const fns = /* @__PURE__ */ new Map();
|
|
2821
|
+
const shadowed = /* @__PURE__ */ new Set();
|
|
2822
|
+
let errexit = opts.errexit ?? false;
|
|
2823
|
+
let pipefail = false;
|
|
2824
|
+
if (flat[0]?.startsWith("#!")) {
|
|
2825
|
+
const sb = flat[0];
|
|
2826
|
+
if (/\s-[a-zA-Z]*e[a-zA-Z]*(?:\s|$)/.test(sb)) errexit = true;
|
|
2827
|
+
if (/-[a-zA-Z]*o\s+pipefail\b/.test(sb)) pipefail = true;
|
|
2828
|
+
}
|
|
2829
|
+
for (let i = 0; i < flat.length; i++) {
|
|
2830
|
+
const raw = flat[i];
|
|
2831
|
+
if (isComment(raw)) {
|
|
2832
|
+
top.push(raw);
|
|
2833
|
+
continue;
|
|
2834
|
+
}
|
|
2835
|
+
const stmt = stripComment(raw).trim();
|
|
2836
|
+
const al = stmt.match(/^alias\s+([A-Za-z_][\w-]*)=/);
|
|
2837
|
+
if (al) shadowed.add(al[1]);
|
|
2838
|
+
const def = stmt.match(FN_DEF);
|
|
2839
|
+
if (!def) {
|
|
2840
|
+
top.push(raw);
|
|
2841
|
+
continue;
|
|
2842
|
+
}
|
|
2843
|
+
const name = def[1] ?? def[3];
|
|
2844
|
+
shadowed.add(name);
|
|
2845
|
+
let rest = (def[2] ?? def[4] ?? "").trim();
|
|
2846
|
+
if (rest === "" && /^\s*\{/.test(flat[i + 1] ?? "")) {
|
|
2847
|
+
i++;
|
|
2848
|
+
rest = flat[i].trim();
|
|
2849
|
+
}
|
|
2850
|
+
const body = [];
|
|
2851
|
+
if (!rest.startsWith("{")) {
|
|
2852
|
+
fns.set(name, body);
|
|
2853
|
+
continue;
|
|
2854
|
+
}
|
|
2855
|
+
rest = rest.slice(1);
|
|
2856
|
+
const after = (line, close2) => {
|
|
2857
|
+
const t = line.slice(0, close2);
|
|
2858
|
+
if (t.trim()) body.push(t);
|
|
2859
|
+
const remainder = line.slice(close2 + 1).replace(/^\s*;?\s*/, "");
|
|
2860
|
+
if (remainder.trim()) top.push(remainder);
|
|
2861
|
+
};
|
|
2862
|
+
let close = closingBrace(rest, 1);
|
|
2863
|
+
if (close !== -1) {
|
|
2864
|
+
after(rest, close);
|
|
2865
|
+
} else {
|
|
2866
|
+
if (rest.trim()) body.push(rest);
|
|
2867
|
+
let depth = 1 + braceDelta(rest);
|
|
2868
|
+
for (i++; i < flat.length; i++) {
|
|
2869
|
+
const line = isComment(flat[i]) ? "" : stripComment(flat[i]);
|
|
2870
|
+
close = closingBrace(line, depth);
|
|
2871
|
+
if (close !== -1) {
|
|
2872
|
+
after(line, close);
|
|
2873
|
+
break;
|
|
2874
|
+
}
|
|
2875
|
+
depth += braceDelta(line);
|
|
2876
|
+
body.push(flat[i]);
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
fns.set(name, body);
|
|
2880
|
+
}
|
|
2881
|
+
return { top, fns, shadowed, pipefail, errexit };
|
|
2882
|
+
}
|
|
2883
|
+
var TRANSPARENT = /^(?:fi|done|esac|\}|\)|;;|then|do|else|exit\s+"?\$\??"?|exit\s+"?\$\{\?\}"?)\s*;?$/;
|
|
2884
|
+
function statusAfterFailure(seg, vars) {
|
|
2885
|
+
const inner = seg.trim().replace(/^[{(]\s*/, "").replace(/\s*[})]\s*;?\s*$/, "");
|
|
2886
|
+
let status = "fail";
|
|
2887
|
+
for (const piece of inner.split(/;|&&|\|\||\|/)) {
|
|
2888
|
+
const w = piece.trim().replace(PREFIX, "").split(/\s+/);
|
|
2889
|
+
switch (w[0]) {
|
|
2890
|
+
case "":
|
|
2891
|
+
break;
|
|
2892
|
+
case "true":
|
|
2893
|
+
case ":":
|
|
2894
|
+
status = "ok";
|
|
2895
|
+
break;
|
|
2896
|
+
case "false":
|
|
2897
|
+
status = "fail";
|
|
2898
|
+
break;
|
|
2899
|
+
case "kill":
|
|
2900
|
+
status = w[1] === "-0" ? "ok" : "exit";
|
|
2901
|
+
break;
|
|
2902
|
+
case "exit":
|
|
2903
|
+
case "return": {
|
|
2904
|
+
const arg = unq(w[1] ?? "");
|
|
2905
|
+
if (arg === "" || arg === "$?" || arg === "${?}") return status === "ok" ? "ok" : "exit";
|
|
2906
|
+
if (/^0+$/.test(arg)) return "ok";
|
|
2907
|
+
if (/^[1-9]\d*$/.test(arg)) return "exit";
|
|
2908
|
+
const v = arg.match(/^\$\{?([A-Za-z_]\w*)\}?$/);
|
|
2909
|
+
const known = v ? vars.get(v[1]) : void 0;
|
|
2910
|
+
return known !== void 0 && /^0+$/.test(known) ? "ok" : "exit";
|
|
2911
|
+
}
|
|
2912
|
+
default:
|
|
2913
|
+
status = "ok";
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
return status;
|
|
2917
|
+
}
|
|
2918
|
+
function constStatus(seg) {
|
|
2919
|
+
const s = seg.trim().replace(/^!\s+/, "");
|
|
2920
|
+
if (alwaysTrue(s)) return "ok";
|
|
2921
|
+
if (alwaysFalse(s)) return "fail";
|
|
2922
|
+
return "unknown";
|
|
2923
|
+
}
|
|
2924
|
+
function reaches(segs, ops, i) {
|
|
2925
|
+
let status = "ok";
|
|
2926
|
+
let certain = true;
|
|
2927
|
+
for (let j = 0; j < i; j++) {
|
|
2928
|
+
const runs = j === 0 ? true : ops[j - 1] === "&&" ? status !== "fail" : ops[j - 1] === "||" ? status !== "ok" : true;
|
|
2929
|
+
if (!runs) continue;
|
|
2930
|
+
const s = constStatus(segs[j]);
|
|
2931
|
+
if (s === "unknown" && (ops[j] === "&&" || ops[j] === "||")) certain = false;
|
|
2932
|
+
if (ops[j] === ";" || ops[j] === "|") {
|
|
2933
|
+
status = "ok";
|
|
2934
|
+
certain = certain && true;
|
|
2935
|
+
continue;
|
|
2936
|
+
}
|
|
2937
|
+
status = s;
|
|
2938
|
+
}
|
|
2939
|
+
const gate = i === 0 ? true : ops[i - 1] === "&&" ? status !== "fail" : ops[i - 1] === "||" ? status !== "ok" : true;
|
|
2940
|
+
if (!gate) return "no";
|
|
2941
|
+
if (i === 0 || ops[i - 1] === ";" || ops[i - 1] === "|") return "yes";
|
|
2942
|
+
if (!certain || status === "unknown") return "maybe";
|
|
2943
|
+
return "yes";
|
|
2944
|
+
}
|
|
2945
|
+
function literal(arg, vars) {
|
|
2946
|
+
const a = unq(arg.trim());
|
|
2947
|
+
if (/[`]|\$\(/.test(a)) return null;
|
|
2948
|
+
let unknown = false;
|
|
2949
|
+
const r = a.replace(/\$\{?([A-Za-z_]\w*)\}?/g, (_, n) => {
|
|
2950
|
+
const v = vars.get(n);
|
|
2951
|
+
if (v === void 0) unknown = true;
|
|
2952
|
+
return v ?? "";
|
|
2953
|
+
});
|
|
2954
|
+
return unknown ? null : r;
|
|
2955
|
+
}
|
|
2956
|
+
function cdStaysHome(arg, vars) {
|
|
2957
|
+
const a = arg.trim();
|
|
2958
|
+
if (/rev-parse\s+--show-toplevel/.test(a)) return true;
|
|
2959
|
+
const lit = literal(a, vars);
|
|
2960
|
+
if (lit === null) return null;
|
|
2961
|
+
return lit === "" || lit === "." || lit === "./";
|
|
2962
|
+
}
|
|
2963
|
+
function walk(lines, st, topIdx, depth) {
|
|
2964
|
+
const frames = [{ dead: false, exhausted: false, kind: "base" }];
|
|
2612
2965
|
const dead = () => frames.some((f) => f.dead);
|
|
2613
|
-
|
|
2966
|
+
const conditional = () => frames.length > 1;
|
|
2967
|
+
let exits = false;
|
|
2968
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2969
|
+
const raw = lines[i];
|
|
2970
|
+
const idx = topIdx(i);
|
|
2614
2971
|
if (isComment(raw)) {
|
|
2615
2972
|
const id = checkIdentity(raw.replace(/^\s*#+\s*/, ""));
|
|
2616
|
-
if (id)
|
|
2973
|
+
if (id) st.out.push({ identity: id, line: raw, state: "comment", idx, errexit: st.errexit, passthrough: false });
|
|
2617
2974
|
continue;
|
|
2618
2975
|
}
|
|
2619
|
-
|
|
2976
|
+
let stmt = stripComment(raw).trim();
|
|
2620
2977
|
if (!stmt) continue;
|
|
2621
|
-
const ifm = stmt.match(/^(if|elif)\s+(
|
|
2978
|
+
const ifm = stmt.match(/^(if|elif)\s+(.+)$/);
|
|
2622
2979
|
if (ifm) {
|
|
2623
|
-
|
|
2624
|
-
|
|
2980
|
+
const cond = ifm[2].replace(/;\s*$/, "");
|
|
2981
|
+
const cid = checkIdentity(cond.replace(/^!\s+/, ""));
|
|
2982
|
+
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 });
|
|
2983
|
+
if (ifm[1] === "if") frames.push({ dead: alwaysFalse(cond), exhausted: alwaysTrue(cond), kind: "if" });
|
|
2984
|
+
else if (frames.length > 1) {
|
|
2985
|
+
const f = frames[frames.length - 1];
|
|
2986
|
+
f.dead = f.exhausted || alwaysFalse(cond);
|
|
2987
|
+
f.exhausted = f.exhausted || alwaysTrue(cond);
|
|
2988
|
+
}
|
|
2625
2989
|
continue;
|
|
2626
2990
|
}
|
|
2627
2991
|
if (/^else\b/.test(stmt)) {
|
|
2628
|
-
if (frames.length > 1)
|
|
2992
|
+
if (frames.length > 1) {
|
|
2993
|
+
const f = frames[frames.length - 1];
|
|
2994
|
+
f.dead = f.exhausted;
|
|
2995
|
+
}
|
|
2629
2996
|
continue;
|
|
2630
2997
|
}
|
|
2631
|
-
if (/^fi\b/.test(stmt)) {
|
|
2998
|
+
if (/^(?:fi|done|esac)\b/.test(stmt)) {
|
|
2632
2999
|
if (frames.length > 1) frames.pop();
|
|
2633
3000
|
continue;
|
|
2634
3001
|
}
|
|
3002
|
+
if (/^(?:then|do)\b/.test(stmt)) continue;
|
|
3003
|
+
const loop = stmt.match(/^(while|until)\s+(.+)$/);
|
|
3004
|
+
if (loop) {
|
|
3005
|
+
const cond = loop[2].replace(/;\s*$/, "");
|
|
3006
|
+
frames.push({ dead: loop[1] === "while" ? alwaysFalse(cond) : alwaysTrue(cond), exhausted: false, kind: "loop" });
|
|
3007
|
+
continue;
|
|
3008
|
+
}
|
|
3009
|
+
const forl = stmt.match(/^for\s+[A-Za-z_]\w*(?:\s+in\b(.*))?$/);
|
|
3010
|
+
if (forl) {
|
|
3011
|
+
const list2 = (forl[1] ?? "$@").replace(/;\s*$/, "").trim();
|
|
3012
|
+
frames.push({ dead: forl[1] !== void 0 && (list2 === "" || /^(?:""|'')$/.test(list2)), exhausted: false, kind: "loop" });
|
|
3013
|
+
continue;
|
|
3014
|
+
}
|
|
3015
|
+
const casem = stmt.match(/^case\s+(\S+)\s+in\b/);
|
|
3016
|
+
if (casem) {
|
|
3017
|
+
frames.push({ dead: false, exhausted: false, kind: "case", word: unq(casem[1]) });
|
|
3018
|
+
continue;
|
|
3019
|
+
}
|
|
3020
|
+
const pat = frames[frames.length - 1].kind === "case" ? stmt.match(/^\(?([^()]+)\)\s*(.*)$/) : null;
|
|
3021
|
+
if (pat) {
|
|
3022
|
+
const f = frames[frames.length - 1];
|
|
3023
|
+
const word = f.word ?? "";
|
|
3024
|
+
const patterns = pat[1].split("|").map((p) => unq(p.trim()));
|
|
3025
|
+
const may = /[$`]/.test(word) || patterns.some((p) => p === "*" || /[$`*?[]/.test(p) || p === word);
|
|
3026
|
+
f.dead = !may;
|
|
3027
|
+
stmt = pat[2].trim();
|
|
3028
|
+
if (!stmt) continue;
|
|
3029
|
+
}
|
|
3030
|
+
if (/^;;$/.test(stmt)) continue;
|
|
3031
|
+
if (/^[{}()]$/.test(stmt)) continue;
|
|
3032
|
+
const bg = /(?<!&)&\s*$/.test(stmt) && !/&&\s*$/.test(stmt);
|
|
3033
|
+
if (bg) stmt = stmt.replace(/\s*&\s*$/, "");
|
|
2635
3034
|
const { segs, ops } = split(stmt);
|
|
2636
|
-
for (let
|
|
2637
|
-
|
|
3035
|
+
for (let s = 0; s < segs.length; s++) {
|
|
3036
|
+
let seg = segs[s].trim();
|
|
3037
|
+
const inverted = /^!\s+/.test(seg);
|
|
3038
|
+
seg = seg.replace(/^!\s+/, "");
|
|
3039
|
+
const execs = /^exec\s+/.test(seg) && !/^exec\s+(?:\d*[<>]|&>)/.test(seg);
|
|
3040
|
+
seg = seg.replace(PREFIX, "");
|
|
3041
|
+
const reach = dead() ? "no" : reaches(segs, ops, s);
|
|
3042
|
+
const first = seg.split(/\s+/)[0] ?? "";
|
|
3043
|
+
if (reach !== "no") {
|
|
3044
|
+
const certain = reach === "yes" && !conditional();
|
|
3045
|
+
const set = seg.match(/^set\s+(.+)$/);
|
|
3046
|
+
if (set) {
|
|
3047
|
+
const toks = set[1].split(/\s+/);
|
|
3048
|
+
for (let t = 0; t < toks.length; t++) {
|
|
3049
|
+
const m = toks[t].match(/^([-+])([a-zA-Z]+)$/);
|
|
3050
|
+
if (!m) continue;
|
|
3051
|
+
const on = m[1] === "-";
|
|
3052
|
+
const flip = (cur) => on ? certain ? true : cur : false;
|
|
3053
|
+
if (m[2].includes("e")) st.errexit = flip(st.errexit);
|
|
3054
|
+
if (m[2].includes("o")) {
|
|
3055
|
+
const name = toks[++t];
|
|
3056
|
+
if (name === "errexit") st.errexit = flip(st.errexit);
|
|
3057
|
+
if (name === "pipefail") st.pipefail = flip(st.pipefail);
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
const trap = seg.match(/^trap\s+(?:'([^']*)'|"([^"]*)"|(\S+))\s+(.+)$/);
|
|
3062
|
+
if (trap) {
|
|
3063
|
+
const action = trap[1] ?? trap[2] ?? trap[3];
|
|
3064
|
+
const sigs = trap[4].split(/\s+/);
|
|
3065
|
+
if (sigs.some((x) => /^(?:EXIT|ERR|0|SIGERR)$/i.test(x)) && /(?:^|[;\s])(?:exit(?:\s+0+)?|true|:)\s*(?:;|$)/.test(action)) st.traps.push(idx);
|
|
3066
|
+
}
|
|
3067
|
+
const asg = seg.match(/^(?:export\s+|readonly\s+)?([A-Za-z_]\w*)=(.*)$/);
|
|
3068
|
+
if (asg) {
|
|
3069
|
+
const v = certain ? literal(asg[2], st.vars) : null;
|
|
3070
|
+
if (v === null) st.vars.delete(asg[1]);
|
|
3071
|
+
else st.vars.set(asg[1], v);
|
|
3072
|
+
}
|
|
3073
|
+
if (first === "cd") {
|
|
3074
|
+
const home = cdStaysHome(seg.slice(2), st.vars);
|
|
3075
|
+
if (home === false) st.cdAway = true;
|
|
3076
|
+
else if (home === true && certain) st.cdAway = false;
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
2638
3079
|
const id = checkIdentity(seg);
|
|
2639
3080
|
if (!id) {
|
|
2640
|
-
if (
|
|
3081
|
+
if (reach === "yes") {
|
|
3082
|
+
if (EXIT_ANY.test(seg)) {
|
|
3083
|
+
if (!conditional()) {
|
|
3084
|
+
frames[0].dead = true;
|
|
3085
|
+
if (/^exit\b/.test(seg)) exits = true;
|
|
3086
|
+
} else if (/^exit\b/.test(seg) && depth > 0) {
|
|
3087
|
+
}
|
|
3088
|
+
} else if (execs && !conditional()) {
|
|
3089
|
+
frames[0].dead = true;
|
|
3090
|
+
} else if (st.fns.has(first) && reach === "yes") {
|
|
3091
|
+
const start = st.out.length;
|
|
3092
|
+
const body = st.fns.get(first) ?? [];
|
|
3093
|
+
const sub = { ...st, out: st.out };
|
|
3094
|
+
const fnExits = depth < 6 && walk(body, sub, () => idx, depth + 1);
|
|
3095
|
+
st.errexit = sub.errexit;
|
|
3096
|
+
st.pipefail = sub.pipefail;
|
|
3097
|
+
st.cdAway = sub.cdAway;
|
|
3098
|
+
const verdict2 = chainVerdict(segs, ops, s, st, inverted, bg);
|
|
3099
|
+
for (let k = start; k < st.out.length; k++) {
|
|
3100
|
+
const inv = st.out[k];
|
|
3101
|
+
if (inv.state === "live" && verdict2.state !== "live") {
|
|
3102
|
+
inv.state = verdict2.state;
|
|
3103
|
+
inv.why = verdict2.why;
|
|
3104
|
+
}
|
|
3105
|
+
if (inv.state === "live") inv.passthrough = inv.passthrough && verdict2.passthrough;
|
|
3106
|
+
}
|
|
3107
|
+
if (fnExits && !conditional()) {
|
|
3108
|
+
frames[0].dead = true;
|
|
3109
|
+
exits = true;
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
} else if (reach === "maybe" && st.fns.has(first)) {
|
|
3113
|
+
const start = st.out.length;
|
|
3114
|
+
const sub = { ...st, out: st.out };
|
|
3115
|
+
if (depth < 6) walk(st.fns.get(first) ?? [], sub, () => idx, depth + 1);
|
|
3116
|
+
const verdict2 = chainVerdict(segs, ops, s, st, inverted, bg);
|
|
3117
|
+
for (let k = start; k < st.out.length; k++) {
|
|
3118
|
+
const inv = st.out[k];
|
|
3119
|
+
if (inv.state === "live" && verdict2.state !== "live") {
|
|
3120
|
+
inv.state = verdict2.state;
|
|
3121
|
+
inv.why = verdict2.why;
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
2641
3125
|
continue;
|
|
2642
3126
|
}
|
|
2643
|
-
let state =
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
3127
|
+
let state = "live";
|
|
3128
|
+
let why;
|
|
3129
|
+
let passthrough = false;
|
|
3130
|
+
if (reach === "no") {
|
|
3131
|
+
state = "unreachable";
|
|
3132
|
+
why = dead() ? "behind an always-false guard, an early exit, or a function body that is never called" : "behind a constant condition";
|
|
3133
|
+
} else {
|
|
3134
|
+
const v = chainVerdict(segs, ops, s, st, inverted, bg);
|
|
3135
|
+
state = v.state;
|
|
3136
|
+
why = v.why;
|
|
3137
|
+
passthrough = v.passthrough;
|
|
3138
|
+
if (state === "live" && /^tamperward\b/.test(id) && st.cdAway) {
|
|
3139
|
+
state = "neutered";
|
|
3140
|
+
why = "runs from another directory after a `cd` away from the repository root";
|
|
3141
|
+
}
|
|
3142
|
+
if (state === "live") {
|
|
3143
|
+
const words2 = [first, "tamperward"].filter((w) => w && st.shadowed.has(w));
|
|
3144
|
+
if (words2.length) {
|
|
3145
|
+
state = "neutered";
|
|
3146
|
+
why = `\`${words2[0]}\` is redefined by a function or alias in this script`;
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
2653
3149
|
}
|
|
2654
|
-
|
|
3150
|
+
const pin = pinOfSegment(seg);
|
|
3151
|
+
st.out.push({ identity: id, line: raw, state, why, idx, errexit: st.errexit, passthrough, ...pin !== null ? { pin } : {} });
|
|
2655
3152
|
}
|
|
2656
3153
|
}
|
|
2657
|
-
return
|
|
3154
|
+
return exits;
|
|
2658
3155
|
}
|
|
2659
|
-
function
|
|
3156
|
+
function chainVerdict(segs, ops, i, st, inverted, bg) {
|
|
3157
|
+
if (inverted) return { state: "neutered", why: "its status is inverted by `!`", passthrough: false };
|
|
3158
|
+
if (bg) return { state: "neutered", why: "run in the background \u2014 the hook does not wait for its status", passthrough: false };
|
|
3159
|
+
let cur = "fail";
|
|
3160
|
+
for (let j = i; j < ops.length; j++) {
|
|
3161
|
+
const op = ops[j];
|
|
3162
|
+
const next = segs[j + 1];
|
|
3163
|
+
if (op === "|") {
|
|
3164
|
+
if (!st.pipefail) return { state: "neutered", why: "piped into another command without `pipefail`", passthrough: false };
|
|
3165
|
+
continue;
|
|
3166
|
+
}
|
|
3167
|
+
if (cur === "exit") break;
|
|
3168
|
+
if (op === "||") {
|
|
3169
|
+
if (cur === "fail") cur = statusAfterFailure(next, st.vars);
|
|
3170
|
+
} else if (op === "&&") {
|
|
3171
|
+
if (cur === "ok") cur = statusAfterFailure(next, st.vars);
|
|
3172
|
+
} else if (op === ";") {
|
|
3173
|
+
const rest = segs.slice(j + 1).map((x) => x.trim());
|
|
3174
|
+
if (st.errexit && cur === "fail") {
|
|
3175
|
+
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 };
|
|
3176
|
+
return { state: "live", passthrough: false };
|
|
3177
|
+
}
|
|
3178
|
+
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" };
|
|
3179
|
+
return { state: "neutered", why: "followed by other statements whose status replaces it (no `set -e`)", passthrough: false };
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
if (cur === "ok") return { state: "neutered", why: "the `||` chain after it ends in success", passthrough: false };
|
|
3183
|
+
if (cur === "exit") return { state: "live", passthrough: false };
|
|
3184
|
+
return { state: "live", passthrough: i === ops.length || ops.slice(i).every((o) => o === "&&" || o === "||") };
|
|
3185
|
+
}
|
|
3186
|
+
function invocations(lines, opts = {}) {
|
|
3187
|
+
const prep = prepare(lines, opts);
|
|
3188
|
+
const st = {
|
|
3189
|
+
errexit: prep.errexit,
|
|
3190
|
+
pipefail: prep.pipefail,
|
|
3191
|
+
cdAway: false,
|
|
3192
|
+
vars: /* @__PURE__ */ new Map(),
|
|
3193
|
+
fns: prep.fns,
|
|
3194
|
+
shadowed: prep.shadowed,
|
|
3195
|
+
traps: [],
|
|
3196
|
+
out: [],
|
|
3197
|
+
lastReal: -1
|
|
3198
|
+
};
|
|
3199
|
+
for (let i = 0; i < prep.top.length; i++) {
|
|
3200
|
+
const t = stripComment(prep.top[i]).trim();
|
|
3201
|
+
if (t && !isComment(prep.top[i]) && !TRANSPARENT.test(t)) st.lastReal = i;
|
|
3202
|
+
}
|
|
3203
|
+
walk(prep.top, st, (i) => i, 0);
|
|
3204
|
+
const seen = new Set(st.out.map((o) => o.line));
|
|
3205
|
+
for (const [, body] of prep.fns) {
|
|
3206
|
+
const sub = { ...st, out: [], traps: [] };
|
|
3207
|
+
walk(body, sub, () => -1, 1);
|
|
3208
|
+
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" });
|
|
3209
|
+
}
|
|
3210
|
+
return st.out.map((inv) => {
|
|
3211
|
+
if (inv.state !== "live") return strip(inv);
|
|
3212
|
+
if (st.traps.some((t) => t < inv.idx) || !inv.errexit && st.traps.some((t) => t > inv.idx)) {
|
|
3213
|
+
return strip({ ...inv, state: "neutered", why: "a `trap` on EXIT/ERR exits 0 whatever the gate returned" });
|
|
3214
|
+
}
|
|
3215
|
+
if (inv.passthrough && !inv.errexit && inv.idx !== -1 && inv.idx < st.lastReal) {
|
|
3216
|
+
return strip({ ...inv, state: "neutered", why: "without `set -e` its failure does not fail the hook \u2014 other statements run after it" });
|
|
3217
|
+
}
|
|
3218
|
+
return strip(inv);
|
|
3219
|
+
});
|
|
3220
|
+
}
|
|
3221
|
+
var strip = (inv) => {
|
|
3222
|
+
const { idx: _i, errexit: _e, passthrough: _p, ...rest } = inv;
|
|
3223
|
+
return rest;
|
|
3224
|
+
};
|
|
3225
|
+
function scriptWeakening(before, after, opts = {}) {
|
|
3226
|
+
const beforeInv = invocations(before, opts);
|
|
3227
|
+
const afterInv = invocations(after, opts);
|
|
2660
3228
|
const live = (inv) => new Set(inv.filter((i) => i.state === "live").map((i) => i.identity));
|
|
2661
|
-
const bl = live(
|
|
2662
|
-
const afterInv = invocations(after);
|
|
3229
|
+
const bl = live(beforeInv);
|
|
2663
3230
|
const al = live(afterInv);
|
|
2664
3231
|
const out2 = [];
|
|
2665
3232
|
for (const id of bl) {
|
|
2666
3233
|
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();
|
|
3234
|
+
const trace = afterInv.find((i) => i.identity === id) ?? afterInv.find((i) => i.identity.split(" --cwd ")[0] === id.split(" --cwd ")[0]);
|
|
3235
|
+
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";
|
|
3236
|
+
const evidence = (trace?.line ?? before.find((l) => checkIdentity(stripComment(l).trim().replace(/^!\s+/, "").replace(PREFIX, "")) === id) ?? id).trim();
|
|
2670
3237
|
out2.push({ reason: `the check invocation \`${id}\` was ${how}`, evidence });
|
|
2671
3238
|
}
|
|
3239
|
+
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];
|
|
3240
|
+
if (floor) {
|
|
3241
|
+
for (const inv of afterInv) {
|
|
3242
|
+
if (inv.state !== "live" || !inv.pin || !PLAIN_SEMVER.test(inv.pin)) continue;
|
|
3243
|
+
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() });
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
2672
3246
|
return out2;
|
|
2673
3247
|
}
|
|
3248
|
+
function shebangProblem(src) {
|
|
3249
|
+
const first = src.split("\n")[0] ?? "";
|
|
3250
|
+
if (!first.startsWith("#!")) return null;
|
|
3251
|
+
const body = first.slice(2).trim();
|
|
3252
|
+
const toks = body.split(/\s+/);
|
|
3253
|
+
let i = 0;
|
|
3254
|
+
if (/(?:^|\/)env$/.test(toks[0] ?? "")) {
|
|
3255
|
+
i = 1;
|
|
3256
|
+
while (i < toks.length && toks[i].startsWith("-")) i++;
|
|
3257
|
+
}
|
|
3258
|
+
const interp = (toks[i] ?? "").replace(/^.*\//, "");
|
|
3259
|
+
if (!/^(?:sh|bash|dash|zsh|ksh|ash|busybox)$/.test(interp)) return `the shebang runs \`${body}\`, not a shell`;
|
|
3260
|
+
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`;
|
|
3261
|
+
return null;
|
|
3262
|
+
}
|
|
2674
3263
|
var isCodeowners = (path) => /(?:^|\/)CODEOWNERS$/.test(path);
|
|
2675
3264
|
var GATE_CRITICAL = [
|
|
2676
|
-
"/.github/workflows/",
|
|
3265
|
+
"/.github/workflows/tamperward.yml",
|
|
2677
3266
|
"/.tamperward.yml",
|
|
2678
3267
|
"/.github/CODEOWNERS",
|
|
2679
3268
|
"/CODEOWNERS",
|
|
2680
3269
|
"/docs/CODEOWNERS",
|
|
2681
|
-
"/.husky/",
|
|
3270
|
+
"/.husky/pre-commit",
|
|
2682
3271
|
"/.claude/settings.json",
|
|
2683
3272
|
"/.claude/settings.local.json",
|
|
2684
3273
|
"/.pre-commit-config.yaml",
|
|
2685
3274
|
"/lefthook.yml",
|
|
2686
3275
|
"/lefthook.yaml"
|
|
2687
3276
|
];
|
|
2688
|
-
function
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
if (p
|
|
2693
|
-
|
|
2694
|
-
|
|
3277
|
+
function codeownersMatcher(pattern) {
|
|
3278
|
+
let p = pattern.trim();
|
|
3279
|
+
if (p === "*" || p === "**") return () => true;
|
|
3280
|
+
let anchored = p.startsWith("/");
|
|
3281
|
+
if (anchored) p = p.slice(1);
|
|
3282
|
+
const dir = p.endsWith("/");
|
|
3283
|
+
p = p.replace(/\/+$/, "");
|
|
3284
|
+
if (p.includes("/")) anchored = true;
|
|
3285
|
+
if (p.startsWith("**/")) {
|
|
3286
|
+
anchored = false;
|
|
3287
|
+
p = p.slice(3);
|
|
3288
|
+
}
|
|
3289
|
+
let re = "";
|
|
3290
|
+
for (let i = 0; i < p.length; i++) {
|
|
3291
|
+
const ch = p[i];
|
|
3292
|
+
if (ch === "*") {
|
|
3293
|
+
if (p[i + 1] === "*") {
|
|
3294
|
+
re += ".*";
|
|
3295
|
+
i++;
|
|
3296
|
+
} else re += "[^/]*";
|
|
3297
|
+
} else if (ch === "?") re += "[^/]";
|
|
3298
|
+
else re += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
3299
|
+
}
|
|
3300
|
+
const body = (anchored ? "^/" : "(?:^|/)") + re;
|
|
3301
|
+
const exact = new RegExp(body + "$");
|
|
3302
|
+
const under = new RegExp(body + "/");
|
|
3303
|
+
return (file) => !dir && exact.test(file) || under.test(file);
|
|
2695
3304
|
}
|
|
2696
3305
|
function ownersOf(content, critical) {
|
|
2697
3306
|
let owners = null;
|
|
@@ -2699,13 +3308,20 @@ function ownersOf(content, critical) {
|
|
|
2699
3308
|
const t = line.trim();
|
|
2700
3309
|
if (!t || t.startsWith("#")) continue;
|
|
2701
3310
|
const [pattern, ...rest] = t.split(/\s+/);
|
|
2702
|
-
|
|
3311
|
+
let matches;
|
|
3312
|
+
try {
|
|
3313
|
+
matches = codeownersMatcher(pattern)(critical);
|
|
3314
|
+
} catch {
|
|
3315
|
+
matches = false;
|
|
3316
|
+
}
|
|
3317
|
+
if (matches) owners = rest.filter((o) => !o.startsWith("#"));
|
|
2703
3318
|
}
|
|
2704
3319
|
return owners;
|
|
2705
3320
|
}
|
|
2706
|
-
function codeownersWeakening(before, after) {
|
|
3321
|
+
function codeownersWeakening(before, after, extra = []) {
|
|
2707
3322
|
const out2 = [];
|
|
2708
|
-
|
|
3323
|
+
const files = [.../* @__PURE__ */ new Set([...GATE_CRITICAL, ...extra.map((f) => f.startsWith("/") ? f : "/" + f)])];
|
|
3324
|
+
for (const critical of files) {
|
|
2709
3325
|
const was = ownersOf(before, critical);
|
|
2710
3326
|
if (!was || was.length === 0) continue;
|
|
2711
3327
|
const now = ownersOf(after, critical);
|
|
@@ -2724,25 +3340,47 @@ function parseDoc(src) {
|
|
|
2724
3340
|
}
|
|
2725
3341
|
}
|
|
2726
3342
|
var isLefthook = (path) => /(?:^|\/)\.?lefthook(?:-local)?\.(?:ya?ml|toml|json)$/.test(path);
|
|
3343
|
+
var isLefthookLocal = (path) => /(?:^|\/)\.?lefthook-local\.(?:ya?ml|toml|json)$/.test(path);
|
|
2727
3344
|
var isPreCommitConfig = (path) => /(?:^|\/)\.pre-commit-config\.ya?ml$/.test(path);
|
|
2728
3345
|
var isPackageJson = (path) => /(?:^|\/)package\.json$/.test(path);
|
|
2729
3346
|
var GATE = /\btamperward\b/;
|
|
2730
3347
|
var str = (v) => typeof v === "string" ? v : Array.isArray(v) ? v.map(String).join(" ") : v == null ? "" : JSON.stringify(v);
|
|
3348
|
+
var list = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" ? v.split(/[\s,]+/).filter(Boolean) : [];
|
|
3349
|
+
function runsGate(command) {
|
|
3350
|
+
return invocations([command], { errexit: true }).some((i) => i.state === "live" && /^tamperward check\b/.test(i.identity));
|
|
3351
|
+
}
|
|
3352
|
+
function mergeDocs(base, over) {
|
|
3353
|
+
const out2 = { ...base };
|
|
3354
|
+
for (const [k, v] of Object.entries(over)) {
|
|
3355
|
+
const b = out2[k];
|
|
3356
|
+
out2[k] = isDoc(b) && isDoc(v) ? mergeDocs(b, v) : v;
|
|
3357
|
+
}
|
|
3358
|
+
return out2;
|
|
3359
|
+
}
|
|
3360
|
+
var off = (v) => v === void 0 || v === null || v === false;
|
|
2731
3361
|
function lefthookEntries(doc) {
|
|
2732
3362
|
const out2 = /* @__PURE__ */ new Map();
|
|
2733
3363
|
for (const [section, v] of Object.entries(doc)) {
|
|
2734
3364
|
if (!isDoc(v)) continue;
|
|
3365
|
+
const sectionExclude = list(v.exclude_tags);
|
|
2735
3366
|
for (const group of ["commands", "scripts"]) {
|
|
2736
3367
|
const g = v[group];
|
|
2737
3368
|
if (!isDoc(g)) continue;
|
|
2738
3369
|
for (const [name, cfg] of Object.entries(g)) {
|
|
2739
|
-
if (!isDoc(cfg)
|
|
3370
|
+
if (!isDoc(cfg)) continue;
|
|
3371
|
+
const run = cfg.run ?? cfg.runner;
|
|
3372
|
+
const named = GATE.test(name);
|
|
3373
|
+
if (!GATE.test(str(run)) && !named) continue;
|
|
3374
|
+
const tags = list(cfg.tags);
|
|
2740
3375
|
out2.set(`${section}.${group}.${name}`, {
|
|
3376
|
+
live: run === void 0 ? named : runsGate(str(run)),
|
|
2741
3377
|
skip: cfg.skip,
|
|
3378
|
+
only: cfg.only,
|
|
2742
3379
|
sectionSkip: v.skip,
|
|
3380
|
+
sectionOnly: v.only,
|
|
2743
3381
|
glob: str(cfg.glob),
|
|
2744
3382
|
exclude: str(cfg.exclude),
|
|
2745
|
-
|
|
3383
|
+
excludedByTag: tags.filter((t) => sectionExclude.includes(t))
|
|
2746
3384
|
});
|
|
2747
3385
|
}
|
|
2748
3386
|
}
|
|
@@ -2754,33 +3392,46 @@ function lefthookWeakening(before, after) {
|
|
|
2754
3392
|
const b = lefthookEntries(before);
|
|
2755
3393
|
const a = lefthookEntries(after);
|
|
2756
3394
|
for (const [key2, was] of b) {
|
|
3395
|
+
if (!was.live) continue;
|
|
2757
3396
|
const now = a.get(key2);
|
|
2758
3397
|
if (!now) {
|
|
2759
3398
|
out2.push(`lefthook entry ${key2} (the gate) was removed`);
|
|
2760
3399
|
continue;
|
|
2761
3400
|
}
|
|
2762
|
-
if (now.
|
|
2763
|
-
if (now.
|
|
3401
|
+
if (!now.live) out2.push(`lefthook entry ${key2} no longer runs \`tamperward check\` live`);
|
|
3402
|
+
if (!off(now.skip) && off(was.skip)) out2.push(`lefthook entry ${key2} (the gate) is now \`skip: ${str(now.skip)}\``);
|
|
3403
|
+
if (!off(now.sectionSkip) && off(was.sectionSkip)) out2.push(`the lefthook hook carrying ${key2} (the gate) is now \`skip: ${str(now.sectionSkip)}\``);
|
|
3404
|
+
if (!off(now.only) && str(now.only) !== str(was.only)) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`only\` (${str(now.only)})`);
|
|
3405
|
+
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
3406
|
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
3407
|
if (now.exclude !== was.exclude && now.exclude) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`exclude\` (${now.exclude})`);
|
|
2766
|
-
if (now.
|
|
3408
|
+
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
3409
|
}
|
|
2768
3410
|
return out2;
|
|
2769
3411
|
}
|
|
2770
|
-
var
|
|
3412
|
+
var STAGE_ALIAS = { commit: "pre-commit", push: "pre-push", merge_commit: "pre-merge-commit" };
|
|
3413
|
+
var GATE_STAGES = /* @__PURE__ */ new Set(["pre-commit", "pre-push"]);
|
|
3414
|
+
var ALL_STAGES = ["pre-commit", "pre-merge-commit", "pre-push", "prepare-commit-msg", "commit-msg", "post-checkout", "post-commit", "post-merge", "post-rewrite", "manual"];
|
|
3415
|
+
var normStage = (s) => STAGE_ALIAS[s] ?? s;
|
|
2771
3416
|
function preCommitEntries(doc) {
|
|
2772
3417
|
const out2 = /* @__PURE__ */ new Map();
|
|
2773
3418
|
const repos = Array.isArray(doc.repos) ? doc.repos : [];
|
|
3419
|
+
const defaults = Array.isArray(doc.default_stages) ? doc.default_stages.map(String).map(normStage) : ALL_STAGES;
|
|
2774
3420
|
for (const repo of repos) {
|
|
2775
3421
|
if (!isDoc(repo)) continue;
|
|
2776
3422
|
const hooks = Array.isArray(repo.hooks) ? repo.hooks : [];
|
|
2777
3423
|
for (const h of hooks) {
|
|
2778
3424
|
if (!isDoc(h)) continue;
|
|
2779
3425
|
if (!GATE.test(`${str(repo.repo)} ${str(h.id)} ${str(h.entry)} ${str(h.name)}`)) continue;
|
|
3426
|
+
const live = h.entry !== void 0 ? runsGate(str(h.entry)) : GATE.test(`${str(repo.repo)} ${str(h.id)}`);
|
|
2780
3427
|
out2.set(`${str(repo.repo)}:${str(h.id)}`, {
|
|
2781
|
-
|
|
3428
|
+
live,
|
|
3429
|
+
stages: Array.isArray(h.stages) ? h.stages.map(String).map(normStage) : defaults,
|
|
3430
|
+
explicitStages: Array.isArray(h.stages),
|
|
2782
3431
|
exclude: str(h.exclude),
|
|
2783
|
-
files: str(h.files)
|
|
3432
|
+
files: str(h.files),
|
|
3433
|
+
types: [str(h.types), str(h.types_or), str(h.exclude_types)].join(" ").trim(),
|
|
3434
|
+
alwaysRun: h.always_run === true
|
|
2784
3435
|
});
|
|
2785
3436
|
}
|
|
2786
3437
|
}
|
|
@@ -2791,28 +3442,45 @@ function preCommitWeakening(before, after) {
|
|
|
2791
3442
|
const b = preCommitEntries(before);
|
|
2792
3443
|
const a = preCommitEntries(after);
|
|
2793
3444
|
for (const [key2, was] of b) {
|
|
3445
|
+
if (!was.live) continue;
|
|
2794
3446
|
const now = a.get(key2);
|
|
2795
3447
|
if (!now) {
|
|
2796
3448
|
out2.push(`pre-commit hook ${key2} (the gate) was removed`);
|
|
2797
3449
|
continue;
|
|
2798
3450
|
}
|
|
2799
|
-
|
|
2800
|
-
|
|
3451
|
+
if (!now.live) out2.push(`pre-commit hook ${key2} no longer runs \`tamperward check\` live`);
|
|
3452
|
+
const lost = was.stages.filter((s) => GATE_STAGES.has(s) && !now.stages.includes(s));
|
|
3453
|
+
if (lost.length && !now.stages.some((s) => GATE_STAGES.has(s))) {
|
|
2801
3454
|
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(", ")}`);
|
|
3455
|
+
} else if (lost.length) {
|
|
3456
|
+
out2.push(`pre-commit hook ${key2} (the gate) no longer runs at stage(s) ${lost.join(", ")}${now.explicitStages ? "" : " (default_stages changed)"}`);
|
|
2805
3457
|
}
|
|
2806
3458
|
if (now.exclude !== was.exclude && now.exclude) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`exclude\` (${now.exclude})`);
|
|
2807
3459
|
if (now.files !== was.files && now.files) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`files\` (${now.files})`);
|
|
3460
|
+
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
3461
|
}
|
|
2809
3462
|
const topB = str(before.exclude);
|
|
2810
3463
|
const topA = str(after.exclude);
|
|
2811
3464
|
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
3465
|
return out2;
|
|
2813
3466
|
}
|
|
2814
|
-
var INSTALLER = /\b(?:husky|lefthook|simple-git-hooks|pre-commit)\b/;
|
|
2815
3467
|
var INSTALL_SCRIPTS = ["prepare", "postinstall", "install", "preinstall"];
|
|
3468
|
+
function installerOf(script) {
|
|
3469
|
+
for (const seg of script.split(/&&|\|\||;|\n/)) {
|
|
3470
|
+
const raw = seg.trim();
|
|
3471
|
+
if (!raw) continue;
|
|
3472
|
+
if (/^(?:HUSKY|LEFTHOOK)=0\b/.test(raw)) continue;
|
|
3473
|
+
const core = raw.replace(PREFIX, "").replace(RUNNER, "").replace(/\s+/g, " ");
|
|
3474
|
+
const m = core.match(/^(?:node\s+)?(?:\S*\/)?(husky|lefthook|simple-git-hooks|pre-commit)(?:\.js)?(?:\s+(\S+))?/);
|
|
3475
|
+
if (!m) continue;
|
|
3476
|
+
const [, tool, sub] = m;
|
|
3477
|
+
if (tool === "husky" && (sub === void 0 || sub === "install" || sub.startsWith("-"))) return sub === "install" ? "husky install" : "husky";
|
|
3478
|
+
if (tool === "lefthook" && sub === "install") return "lefthook install";
|
|
3479
|
+
if (tool === "pre-commit" && sub === "install") return "pre-commit install";
|
|
3480
|
+
if (tool === "simple-git-hooks" && (sub === void 0 || sub.startsWith("-"))) return "simple-git-hooks";
|
|
3481
|
+
}
|
|
3482
|
+
return null;
|
|
3483
|
+
}
|
|
2816
3484
|
function packageJsonWeakening(before, after) {
|
|
2817
3485
|
let b;
|
|
2818
3486
|
let a;
|
|
@@ -2823,13 +3491,14 @@ function packageJsonWeakening(before, after) {
|
|
|
2823
3491
|
return [];
|
|
2824
3492
|
}
|
|
2825
3493
|
if (!isDoc(b) || !isDoc(a)) return [];
|
|
2826
|
-
const
|
|
3494
|
+
const installers = (d) => {
|
|
2827
3495
|
const sc = d.scripts;
|
|
2828
|
-
|
|
3496
|
+
if (!isDoc(sc)) return [];
|
|
3497
|
+
return INSTALL_SCRIPTS.map((k) => installerOf(str(sc[k]))).filter((x) => x !== null);
|
|
2829
3498
|
};
|
|
2830
|
-
const was =
|
|
3499
|
+
const was = installers(b)[0];
|
|
2831
3500
|
if (!was) return [];
|
|
2832
|
-
if (
|
|
3501
|
+
if (installers(a).length) return [];
|
|
2833
3502
|
return [`the install script that wires the git hooks (\`${was}\`) was removed from package.json scripts \u2014 a fresh checkout installs no hooks`];
|
|
2834
3503
|
}
|
|
2835
3504
|
var GIT_HOOKS = /* @__PURE__ */ new Set([
|
|
@@ -2856,7 +3525,7 @@ var GIT_HOOKS = /* @__PURE__ */ new Set([
|
|
|
2856
3525
|
]);
|
|
2857
3526
|
function hookIdentity(path) {
|
|
2858
3527
|
const base = path.split("/").pop() ?? "";
|
|
2859
|
-
if (isLefthook(path)) return "lefthook";
|
|
3528
|
+
if (isLefthook(path)) return isLefthookLocal(path) ? "lefthook-local" : "lefthook";
|
|
2860
3529
|
if (isPreCommitConfig(path)) return "pre-commit-config";
|
|
2861
3530
|
if (base === "CODEOWNERS") return "codeowners";
|
|
2862
3531
|
const husky = path.match(/(?:^|\/)\.husky\/([^/]+)$/);
|
|
@@ -2864,7 +3533,7 @@ function hookIdentity(path) {
|
|
|
2864
3533
|
return null;
|
|
2865
3534
|
}
|
|
2866
3535
|
var ownerExec = (mode) => (parseInt(mode.slice(-3).padStart(3, "0"), 8) & 64) !== 0;
|
|
2867
|
-
var SYMBOLIC = /^[ugoa]*([-+=]
|
|
3536
|
+
var SYMBOLIC = /^([ugoa]*)((?:[-+=][rwxXstugo]*)+)$/;
|
|
2868
3537
|
function chmodDropsExec(toks) {
|
|
2869
3538
|
const at = toks.findIndex((t) => t === "chmod" || t.endsWith("/chmod"));
|
|
2870
3539
|
if (at === -1) return false;
|
|
@@ -2877,31 +3546,113 @@ function chmodDropsExec(toks) {
|
|
|
2877
3546
|
for (const clause of tok.split(",")) {
|
|
2878
3547
|
const m = clause.match(SYMBOLIC);
|
|
2879
3548
|
if (!m) continue;
|
|
2880
|
-
const [,
|
|
2881
|
-
if (
|
|
2882
|
-
|
|
3549
|
+
const [, who, opsText] = m;
|
|
3550
|
+
if (who && !/[ua]/.test(who)) continue;
|
|
3551
|
+
let exec = null;
|
|
3552
|
+
for (const g of opsText.match(/[-+=][rwxXstugo]*/g) ?? []) {
|
|
3553
|
+
const op = g[0];
|
|
3554
|
+
const perms = g.slice(1);
|
|
3555
|
+
if (/[ugo]/.test(perms)) {
|
|
3556
|
+
exec = false;
|
|
3557
|
+
continue;
|
|
3558
|
+
}
|
|
3559
|
+
if (op === "-" && /[xX]/.test(perms)) exec = false;
|
|
3560
|
+
else if (op === "+" && /[xX]/.test(perms)) exec = true;
|
|
3561
|
+
else if (op === "=") exec = /[xX]/.test(perms);
|
|
3562
|
+
}
|
|
3563
|
+
if (exec === false) return true;
|
|
2883
3564
|
}
|
|
2884
3565
|
}
|
|
2885
3566
|
return false;
|
|
2886
3567
|
}
|
|
3568
|
+
function setfaclDropsExec(toks) {
|
|
3569
|
+
if (!toks.some((t) => /^-[a-zA-Z]*m|^--modify/.test(t))) return false;
|
|
3570
|
+
return toks.some((t) => t.split(",").some((e) => {
|
|
3571
|
+
const m = e.match(/^(?:u|user)::([rwxX-]*)$/);
|
|
3572
|
+
return !!m && !/[xX]/.test(m[1]);
|
|
3573
|
+
}));
|
|
3574
|
+
}
|
|
2887
3575
|
var WRAPPERS = /^(?:sudo|command|exec|time|nice|env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/;
|
|
2888
|
-
var
|
|
2889
|
-
var
|
|
2890
|
-
|
|
3576
|
+
var INTERPRETERS = /* @__PURE__ */ new Set(["python", "python2", "python3", "node", "perl", "ruby", "php", "deno", "bun"]);
|
|
3577
|
+
var INLINE_FLAG = /^-(?:c|e|r|E|p|pe|pi|i|ne|ni|le)$|^--eval$|^--print$|^--exec$/;
|
|
3578
|
+
var EDITORS = /* @__PURE__ */ new Set(["ex", "ed", "vim", "vi", "nvim"]);
|
|
3579
|
+
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"];
|
|
3580
|
+
function nameGlob(pattern) {
|
|
3581
|
+
const re = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
|
|
3582
|
+
return (n) => re.test(n);
|
|
3583
|
+
}
|
|
3584
|
+
function destinations(cmd, args, isDir) {
|
|
3585
|
+
const positional = [];
|
|
3586
|
+
let target = null;
|
|
3587
|
+
for (let i = 0; i < args.length; i++) {
|
|
3588
|
+
const a = args[i];
|
|
3589
|
+
if (a === "--") {
|
|
3590
|
+
positional.push(...args.slice(i + 1));
|
|
3591
|
+
break;
|
|
3592
|
+
}
|
|
3593
|
+
if (a === "-t" || a === "--target-directory") {
|
|
3594
|
+
target = args[++i] ?? null;
|
|
3595
|
+
continue;
|
|
3596
|
+
}
|
|
3597
|
+
const eq = a.match(/^--target-directory=(.*)$/);
|
|
3598
|
+
if (eq) {
|
|
3599
|
+
target = eq[1];
|
|
3600
|
+
continue;
|
|
3601
|
+
}
|
|
3602
|
+
if (cmd === "install" && /^-[mogS]$/.test(a) || cmd === "install" && /^--(?:mode|owner|group|suffix)$/.test(a) || cmd === "rsync" && /^(?:-e|--rsh|--exclude|--include|--files-from)$/.test(a)) {
|
|
3603
|
+
i++;
|
|
3604
|
+
continue;
|
|
3605
|
+
}
|
|
3606
|
+
if (a.startsWith("-")) continue;
|
|
3607
|
+
positional.push(a);
|
|
3608
|
+
}
|
|
3609
|
+
const base = (p) => p.replace(/\/+$/, "").split("/").pop() ?? p;
|
|
3610
|
+
if (target !== null) return positional.map((p) => `${target.replace(/\/+$/, "")}/${base(p)}`);
|
|
3611
|
+
if (positional.length < 2) return [];
|
|
3612
|
+
const dest = positional[positional.length - 1];
|
|
3613
|
+
const srcs = positional.slice(0, -1);
|
|
3614
|
+
if (dest.endsWith("/") || srcs.length > 1 || isDir(dest)) return [dest, ...srcs.map((s) => `${dest.replace(/\/+$/, "")}/${base(s)}`)];
|
|
3615
|
+
return [dest];
|
|
3616
|
+
}
|
|
3617
|
+
var REDIRECT_TARGET = /(?:^|\s)(?:\d*>{1,2}\|?|&>{1,2})\s*(\S+)/g;
|
|
3618
|
+
function hookTests(policy, ctx) {
|
|
3619
|
+
const rel = (t) => {
|
|
3620
|
+
let p = t.replace(/^["']|["']$/g, "");
|
|
3621
|
+
if (ctx?.cwd && p.startsWith(ctx.cwd + "/")) p = p.slice(ctx.cwd.length + 1);
|
|
3622
|
+
return p.replace(/^\.\/(?=.)/, "");
|
|
3623
|
+
};
|
|
3624
|
+
const hook = (t) => isProtected(rel(t), policy, "hooks");
|
|
3625
|
+
const whole = (t) => /^(?:\.|\.\/|:\/|\*)$/.test(rel(t)) || ctx?.cwd !== void 0 && rel(t) === ctx.cwd;
|
|
3626
|
+
const holds = (t) => !t.startsWith("-") && (whole(t) || containsProtected(rel(t), policy, "hooks", ctx));
|
|
3627
|
+
const hookish = (t) => hook(t) || holds(t);
|
|
3628
|
+
return { rel, hook, whole, holds, hookish };
|
|
3629
|
+
}
|
|
3630
|
+
function shellHookTarget(seg, toks, policy, ctx) {
|
|
3631
|
+
const { rel, hook, whole, holds } = hookTests(policy, ctx);
|
|
2891
3632
|
for (const m of seg.matchAll(REDIRECT_TARGET)) {
|
|
2892
|
-
|
|
2893
|
-
if (isProtected(t, policy, "hooks")) return t;
|
|
3633
|
+
if (hook(m[1])) return rel(m[1]);
|
|
2894
3634
|
}
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
3635
|
+
const args = toks.map((t) => t.replace(/^(?:of=|--(?:include|exclude|directory)=)/, ""));
|
|
3636
|
+
const fileLike = (p) => {
|
|
3637
|
+
const b = p.replace(/\/+$/, "").split("/").pop() ?? "";
|
|
3638
|
+
return HOOK_BASENAMES.includes(b) || /\.[A-Za-z0-9]+$/.test(b.replace(/^\.+/, "")) || (trackedFiles(ctx)?.includes(p) ?? false);
|
|
3639
|
+
};
|
|
3640
|
+
for (const t of args) if (hook(t) && fileLike(rel(t))) return rel(t);
|
|
3641
|
+
for (const t of args) {
|
|
3642
|
+
if (t.startsWith("-") || !holds(t)) continue;
|
|
3643
|
+
const dir = rel(t).replace(/\/+$/, "");
|
|
3644
|
+
const under = whole(t) ? "" : dir + "/";
|
|
3645
|
+
const listed = trackedFiles(ctx)?.find((f) => f.startsWith(under) && isProtected(f, policy, "hooks"));
|
|
3646
|
+
if (listed) return listed;
|
|
3647
|
+
const guess = under + "pre-commit";
|
|
3648
|
+
return isProtected(guess, policy, "hooks") ? guess : whole(t) ? ".tamperward.yml" : dir;
|
|
2898
3649
|
}
|
|
2899
3650
|
return null;
|
|
2900
3651
|
}
|
|
2901
|
-
function shellWritesHook(seg, toks, policy) {
|
|
2902
|
-
const hook
|
|
3652
|
+
function shellWritesHook(seg, toks, policy, ctx) {
|
|
3653
|
+
const { rel, hook, whole, holds, hookish } = hookTests(policy, ctx);
|
|
2903
3654
|
for (const m of seg.matchAll(REDIRECT_TARGET)) {
|
|
2904
|
-
if (hook(m[1]
|
|
3655
|
+
if (hook(m[1])) return "a redirect empties or rewrites a protected hook";
|
|
2905
3656
|
}
|
|
2906
3657
|
let at = 0;
|
|
2907
3658
|
while (at < toks.length && WRAPPERS.test(toks[at])) at++;
|
|
@@ -2911,21 +3662,28 @@ function shellWritesHook(seg, toks, policy) {
|
|
|
2911
3662
|
const last = positional[positional.length - 1] ?? "";
|
|
2912
3663
|
const anyHook = args.some(hook);
|
|
2913
3664
|
const inPlace = args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a));
|
|
3665
|
+
const scripts = args.filter((_, i) => i > 0 && INLINE_FLAG.test(args[i - 1]));
|
|
3666
|
+
const namesHookInText = () => scripts.some((a) => (a.match(/[\w.~/-]+/g) ?? []).some(hook));
|
|
2914
3667
|
switch (cmd) {
|
|
2915
3668
|
case "rm":
|
|
2916
3669
|
case "unlink":
|
|
2917
3670
|
case "shred":
|
|
2918
|
-
return anyHook ? "rm deletes a protected hook" : null;
|
|
3671
|
+
return anyHook || positional.some(holds) ? "rm deletes a protected hook" : null;
|
|
2919
3672
|
case "truncate":
|
|
2920
3673
|
return anyHook ? "truncate empties a protected hook" : null;
|
|
2921
3674
|
case "chmod":
|
|
2922
|
-
return anyHook && chmodDropsExec(toks) ? "chmod removes execute permission from a hook \u2014 git will skip it" : null;
|
|
3675
|
+
return (anyHook || positional.some(holds)) && chmodDropsExec(toks) ? "chmod removes execute permission from a hook \u2014 git will skip it" : null;
|
|
3676
|
+
case "setfacl":
|
|
3677
|
+
return anyHook && setfaclDropsExec(args) ? "setfacl removes the owner execute permission from a hook \u2014 git will skip it" : null;
|
|
2923
3678
|
case "mv":
|
|
2924
|
-
return anyHook ? "mv moves a protected hook out of place or overwrites it" : null;
|
|
3679
|
+
return anyHook || positional.some(holds) ? "mv moves a protected hook out of place or overwrites it" : null;
|
|
2925
3680
|
case "cp":
|
|
2926
3681
|
case "install":
|
|
2927
3682
|
case "ln":
|
|
2928
|
-
|
|
3683
|
+
case "rsync": {
|
|
3684
|
+
const dests = destinations(cmd, args, holds);
|
|
3685
|
+
return dests.some(hook) ? `${cmd} replaces a protected hook in place` : null;
|
|
3686
|
+
}
|
|
2929
3687
|
case "tee":
|
|
2930
3688
|
case "sponge":
|
|
2931
3689
|
return anyHook ? `${cmd} rewrites a protected hook in place` : null;
|
|
@@ -2933,20 +3691,45 @@ function shellWritesHook(seg, toks, policy) {
|
|
|
2933
3691
|
return args.some((a) => a.startsWith("of=") && hook(a.slice(3))) ? "dd rewrites a protected hook in place" : null;
|
|
2934
3692
|
case "sed":
|
|
2935
3693
|
case "perl":
|
|
2936
|
-
|
|
3694
|
+
if (anyHook && inPlace) return `an in-place ${cmd} rewrites a protected hook`;
|
|
3695
|
+
if (cmd === "perl" && args.some((a) => INLINE_FLAG.test(a)) && namesHookInText()) return "an inline perl script names a protected hook";
|
|
3696
|
+
return null;
|
|
2937
3697
|
case "awk":
|
|
2938
3698
|
case "gawk":
|
|
2939
3699
|
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;
|
|
3700
|
+
case "patch":
|
|
3701
|
+
return anyHook ? "patch rewrites a protected hook" : null;
|
|
3702
|
+
case "xargs":
|
|
3703
|
+
return null;
|
|
3704
|
+
// judged by the caller, which sees the segments feeding it
|
|
3705
|
+
case "find": {
|
|
3706
|
+
const mutates = /\s(?:-delete\b|-exec(?:dir)?\s+(?:rm|unlink|chmod|truncate|mv|sed|tee|shred)\b)/.test(seg);
|
|
3707
|
+
if (!mutates) return null;
|
|
3708
|
+
if (/-exec(?:dir)?\s+chmod\b/.test(seg) && !chmodDropsExec(toks)) return null;
|
|
3709
|
+
const dirs = positional.filter((p) => !/^-/.test(p) && !["{}", ";", "+", "rm", "chmod", "unlink", "truncate", "mv", "sed", "tee", "shred", "inplace"].includes(p));
|
|
3710
|
+
const names = args.filter((a, i) => /^-i?(?:name|path|regex)$/.test(args[i - 1] ?? ""));
|
|
3711
|
+
const nameHits = names.length === 0 || names.some((n) => {
|
|
3712
|
+
try {
|
|
3713
|
+
const g = nameGlob(n.replace(/^.*\//, ""));
|
|
3714
|
+
return HOOK_BASENAMES.some(g);
|
|
3715
|
+
} catch {
|
|
3716
|
+
return true;
|
|
3717
|
+
}
|
|
3718
|
+
});
|
|
3719
|
+
const reachesHook = dirs.some((d) => hook(d) || holds(d) || whole(d) && nameHits) || dirs.length === 0 && nameHits;
|
|
3720
|
+
return reachesHook && nameHits ? "find rewrites or removes protected hooks" : null;
|
|
3721
|
+
}
|
|
2940
3722
|
case "git": {
|
|
2941
3723
|
let i = 0;
|
|
2942
3724
|
while (i < args.length && args[i].startsWith("-")) i += args[i] === "-C" || args[i] === "-c" ? 2 : 1;
|
|
2943
3725
|
const sub = args[i];
|
|
2944
3726
|
const rest = args.slice(i + 1);
|
|
2945
|
-
if (
|
|
3727
|
+
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;
|
|
3728
|
+
if (!rest.some(hookish)) return null;
|
|
2946
3729
|
if (sub === "checkout") {
|
|
2947
3730
|
const dd = rest.indexOf("--");
|
|
2948
3731
|
const before = dd === -1 ? rest : rest.slice(0, dd);
|
|
2949
|
-
const revs = before.filter((p) => !p.startsWith("-") && !
|
|
3732
|
+
const revs = before.filter((p) => !p.startsWith("-") && !hookish(p));
|
|
2950
3733
|
return revs.length > 0 ? "git checkout <rev> -- <hook> puts an older version of a protected hook back" : null;
|
|
2951
3734
|
}
|
|
2952
3735
|
if (sub === "restore" && rest.some((a) => /^--source(?:=|$)/.test(a) || a === "-s")) {
|
|
@@ -2955,108 +3738,290 @@ function shellWritesHook(seg, toks, policy) {
|
|
|
2955
3738
|
return null;
|
|
2956
3739
|
}
|
|
2957
3740
|
default:
|
|
3741
|
+
if (INTERPRETERS.has(cmd) && inPlace && anyHook) return `an in-place ${cmd} rewrites a protected hook`;
|
|
3742
|
+
if (INTERPRETERS.has(cmd) && args.some((a) => INLINE_FLAG.test(a)) && namesHookInText()) return `an inline ${cmd} script names a protected hook`;
|
|
3743
|
+
if (EDITORS.has(cmd) && (anyHook || args.some((a) => /^-[a-zA-Z]*[cs]/.test(a) || a === "--cmd") && namesHookInText())) return `${cmd} rewrites a protected hook`;
|
|
2958
3744
|
return null;
|
|
2959
3745
|
}
|
|
2960
3746
|
}
|
|
3747
|
+
function xargsWritesHook(toks, fed, policy, ctx) {
|
|
3748
|
+
const at = toks.findIndex((t) => t === "xargs" || t.endsWith("/xargs"));
|
|
3749
|
+
if (at === -1) return null;
|
|
3750
|
+
const rel = (t) => (ctx?.cwd && t.startsWith(ctx.cwd + "/") ? t.slice(ctx.cwd.length + 1) : t).replace(/^\.\//, "");
|
|
3751
|
+
const fedHook = fed.some((t) => isProtected(rel(t), policy, "hooks") || !t.startsWith("-") && containsProtected(rel(t), policy, "hooks", ctx));
|
|
3752
|
+
if (!fedHook) return null;
|
|
3753
|
+
let i = at + 1;
|
|
3754
|
+
while (i < toks.length && toks[i].startsWith("-")) i += /^-(?:I|n|L|P|d|a|s)$/.test(toks[i]) ? 2 : 1;
|
|
3755
|
+
const cmd = (toks[i] ?? "").replace(/^.*\//, "");
|
|
3756
|
+
const args = toks.slice(i + 1);
|
|
3757
|
+
if (["rm", "unlink", "shred", "truncate", "mv", "tee", "sponge"].includes(cmd)) return `xargs ${cmd} rewrites or removes a protected hook named earlier in the line`;
|
|
3758
|
+
if (cmd === "chmod" && chmodDropsExec(toks.slice(i))) return "xargs chmod removes execute permission from a hook named earlier in the line";
|
|
3759
|
+
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`;
|
|
3760
|
+
return null;
|
|
3761
|
+
}
|
|
2961
3762
|
|
|
2962
3763
|
// src/detectors/hook-tampering.ts
|
|
2963
3764
|
var RULE6 = "hook-tampering";
|
|
2964
3765
|
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
3766
|
var EVENTS = ["PreToolUse", "Stop"];
|
|
3767
|
+
var isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
2972
3768
|
function parseSettings(src) {
|
|
2973
3769
|
try {
|
|
2974
3770
|
const v = JSON.parse(src);
|
|
2975
|
-
return
|
|
3771
|
+
return isObj(v) ? v : null;
|
|
2976
3772
|
} catch {
|
|
2977
3773
|
return null;
|
|
2978
3774
|
}
|
|
2979
3775
|
}
|
|
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
3776
|
function eventEntries(s, event) {
|
|
2986
3777
|
const hooks = s.hooks;
|
|
2987
|
-
if (
|
|
3778
|
+
if (!isObj(hooks)) return null;
|
|
2988
3779
|
const arr = hooks[event];
|
|
2989
3780
|
return Array.isArray(arr) ? arr : null;
|
|
2990
3781
|
}
|
|
2991
3782
|
var declares = (s, event) => eventEntries(s, event) !== null;
|
|
2992
|
-
|
|
3783
|
+
var GATE_KEYS = /* @__PURE__ */ new Set(["type", "command", "timeout"]);
|
|
3784
|
+
var CANONICAL_CMD = /^npx --yes tamperward@(\S+) (hook|sweep) claude$/;
|
|
3785
|
+
var MENTIONS_GATE = /\btamperward\b.*\b(?:hook|sweep)\b/;
|
|
3786
|
+
var FORGERY = /hookSpecificOutput|permissionDecision|updatedInput|"decision"|"continue"/;
|
|
3787
|
+
var SENSITIVE_ENV = /^(?:PATH|NODE_OPTIONS|NODE_PATH|HOME|npm_config_.*|NPM_CONFIG_.*)$/;
|
|
3788
|
+
function analyseGateCommand(cmd, event) {
|
|
3789
|
+
const want = event === "PreToolUse" ? "hook" : "sweep";
|
|
3790
|
+
const problems = [];
|
|
3791
|
+
let runs = true;
|
|
3792
|
+
const canonical2 = cmd.match(CANONICAL_CMD);
|
|
3793
|
+
if (canonical2) {
|
|
3794
|
+
if (canonical2[2] !== want) {
|
|
3795
|
+
problems.push(`runs \`${canonical2[2]} claude\` under ${event}`);
|
|
3796
|
+
runs = false;
|
|
3797
|
+
}
|
|
3798
|
+
if (!PLAIN_SEMVER.test(canonical2[1])) {
|
|
3799
|
+
problems.push(`the pin \`${canonical2[1]}\` is not a plain version`);
|
|
3800
|
+
runs = false;
|
|
3801
|
+
}
|
|
3802
|
+
return { runs, problems, pin: canonical2[1] };
|
|
3803
|
+
}
|
|
3804
|
+
const inv = cmd.match(/\btamperward(?:@(\S+))?\s+(hook|sweep)\s+(\S+)/);
|
|
3805
|
+
if (!inv) return { runs: false, problems: ["the command does not invoke the gate"], pin: null };
|
|
3806
|
+
if (inv[2] !== want || inv[3] !== "claude") {
|
|
3807
|
+
problems.push(`runs \`${inv[2]} ${inv[3]}\` under ${event}`);
|
|
3808
|
+
runs = false;
|
|
3809
|
+
}
|
|
3810
|
+
if (inv[1] !== void 0 && !PLAIN_SEMVER.test(inv[1])) {
|
|
3811
|
+
problems.push(`the pin \`${inv[1]}\` is not a plain version`);
|
|
3812
|
+
runs = false;
|
|
3813
|
+
}
|
|
3814
|
+
if (/[|;&<>`]|\$\(/.test(cmd)) {
|
|
3815
|
+
problems.push("text around the invocation (a redirect, pipe or chain)");
|
|
3816
|
+
runs = false;
|
|
3817
|
+
}
|
|
3818
|
+
if (/^\s*[A-Za-z_]\w*=/.test(cmd)) {
|
|
3819
|
+
problems.push("an environment assignment in front of the gate");
|
|
3820
|
+
runs = false;
|
|
3821
|
+
}
|
|
3822
|
+
if (/\b(?:sh|bash|zsh|dash)\s+-[a-zA-Z]*c\b/.test(cmd)) {
|
|
3823
|
+
problems.push("wrapped in `sh -c`");
|
|
3824
|
+
runs = false;
|
|
3825
|
+
}
|
|
3826
|
+
if (/(?:^|\s)(?:-p|--package)(?:=|\s)/.test(cmd)) {
|
|
3827
|
+
problems.push("`-p`/`--package` points npx at another package");
|
|
3828
|
+
runs = false;
|
|
3829
|
+
}
|
|
3830
|
+
const at = cmd.indexOf(inv[0]);
|
|
3831
|
+
const trailing = cmd.slice(at + inv[0].length).trim();
|
|
3832
|
+
if (trailing) {
|
|
3833
|
+
problems.push(`extra text after the invocation (\`${trailing}\`)`);
|
|
3834
|
+
runs = false;
|
|
3835
|
+
}
|
|
3836
|
+
const head = cmd.slice(0, at).trim();
|
|
3837
|
+
if (head !== "npx --yes") problems.push(`the launcher \`${head || "(none)"}\` is not \`npx --yes\``);
|
|
3838
|
+
return { runs, problems, pin: inv[1] ?? "" };
|
|
3839
|
+
}
|
|
3840
|
+
function matcherSelection(m) {
|
|
3841
|
+
if (m === void 0) return { tools: null, problem: null };
|
|
3842
|
+
if (typeof m !== "string") return { tools: /* @__PURE__ */ new Set(), problem: `the matcher ${JSON.stringify(m)} is not a string` };
|
|
3843
|
+
if (m === "" || m === "*") return { tools: null, problem: null };
|
|
3844
|
+
let problem = null;
|
|
3845
|
+
if (/[^\x00-\x7f]/.test(m)) problem = "the matcher contains non-ASCII characters that look like tool names but are not";
|
|
3846
|
+
if (/^[A-Za-z0-9_ |-]+$/.test(m)) return { tools: new Set(m.split("|").map((t) => t.trim()).filter(Boolean)), problem };
|
|
3847
|
+
let re;
|
|
3848
|
+
try {
|
|
3849
|
+
re = new RegExp(m);
|
|
3850
|
+
} catch {
|
|
3851
|
+
return { tools: /* @__PURE__ */ new Set(), problem: `the matcher \`${m}\` is not a valid pattern \u2014 it selects nothing` };
|
|
3852
|
+
}
|
|
3853
|
+
return { tools: new Set(PRE_TOOLS.filter((t) => re.test(t))), problem };
|
|
3854
|
+
}
|
|
3855
|
+
function gateCandidates(s, event) {
|
|
2993
3856
|
const out2 = [];
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
if (
|
|
3002
|
-
|
|
3003
|
-
|
|
3857
|
+
const mentions = (v) => /\btamperward\b/.test(JSON.stringify(v) ?? "");
|
|
3858
|
+
for (const group of eventEntries(s, event) ?? []) {
|
|
3859
|
+
if (!isObj(group)) {
|
|
3860
|
+
if (mentions(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["the matcher entry is not an object"], pin: null, tools: null });
|
|
3861
|
+
continue;
|
|
3862
|
+
}
|
|
3863
|
+
if (!Array.isArray(group.hooks)) {
|
|
3864
|
+
if (mentions(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["`hooks` is not a list"], pin: null, tools: null });
|
|
3865
|
+
continue;
|
|
3866
|
+
}
|
|
3867
|
+
for (const h of group.hooks) {
|
|
3868
|
+
if (!isObj(h)) {
|
|
3869
|
+
if (mentions(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the hook entry is not an object"], pin: null, tools: null });
|
|
3870
|
+
continue;
|
|
3871
|
+
}
|
|
3872
|
+
const cmd = typeof h.command === "string" ? h.command : null;
|
|
3873
|
+
if (cmd === null) {
|
|
3874
|
+
if (mentions(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the gate is not in a `command` string"], pin: null, tools: null });
|
|
3875
|
+
continue;
|
|
3876
|
+
}
|
|
3877
|
+
if (!MENTIONS_GATE.test(cmd)) continue;
|
|
3878
|
+
const a = analyseGateCommand(cmd, event);
|
|
3879
|
+
const problems = [...a.problems];
|
|
3880
|
+
let runs = a.runs;
|
|
3881
|
+
if (h.type !== "command") {
|
|
3882
|
+
problems.push(h.type === void 0 ? "no `type`" : `\`type\` is ${JSON.stringify(h.type)}`);
|
|
3883
|
+
if (h.type !== void 0) runs = false;
|
|
3884
|
+
}
|
|
3885
|
+
for (const k of Object.keys(h)) {
|
|
3886
|
+
if (GATE_KEYS.has(k)) continue;
|
|
3887
|
+
problems.push(`carries \`${k}\``);
|
|
3888
|
+
runs = false;
|
|
3889
|
+
}
|
|
3890
|
+
if ("timeout" in h) {
|
|
3891
|
+
const t = h.timeout;
|
|
3892
|
+
if (typeof t !== "number" || !Number.isInteger(t) || t < 120) {
|
|
3893
|
+
problems.push(`\`timeout\` ${JSON.stringify(t)} \u2014 the gate is cut off before it answers`);
|
|
3894
|
+
runs = false;
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3897
|
+
let tools = null;
|
|
3898
|
+
if (event === "PreToolUse") {
|
|
3899
|
+
const sel = matcherSelection(group.matcher);
|
|
3900
|
+
tools = sel.tools;
|
|
3901
|
+
if (sel.problem) {
|
|
3902
|
+
problems.push(sel.problem);
|
|
3903
|
+
}
|
|
3904
|
+
} else if (group.matcher !== void 0 && !(typeof group.matcher === "string" && (group.matcher === "" || group.matcher === "*"))) {
|
|
3905
|
+
problems.push(`a \`matcher\` (${JSON.stringify(group.matcher)}) on the Stop entry`);
|
|
3906
|
+
runs = false;
|
|
3907
|
+
}
|
|
3908
|
+
out2.push({ key: JSON.stringify({ m: group.matcher, h }), runs, problems, pin: a.pin, tools });
|
|
3004
3909
|
}
|
|
3005
3910
|
}
|
|
3006
3911
|
return out2;
|
|
3007
3912
|
}
|
|
3008
|
-
function
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3913
|
+
function otherCommands(s, event) {
|
|
3914
|
+
const out2 = [];
|
|
3915
|
+
for (const group of eventEntries(s, event) ?? []) {
|
|
3916
|
+
if (!isObj(group) || !Array.isArray(group.hooks)) continue;
|
|
3917
|
+
for (const h of group.hooks) {
|
|
3918
|
+
if (!isObj(h) || typeof h.command !== "string" || MENTIONS_GATE.test(h.command)) continue;
|
|
3919
|
+
out2.push(h.command);
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
return out2;
|
|
3923
|
+
}
|
|
3924
|
+
function coverage(cs) {
|
|
3925
|
+
const live = cs.filter((c) => c.runs);
|
|
3926
|
+
if (live.length === 0) return "none";
|
|
3927
|
+
if (live.some((c) => c.tools === null)) return null;
|
|
3928
|
+
return new Set(live.flatMap((c) => [...c.tools]));
|
|
3929
|
+
}
|
|
3930
|
+
function duplicateJsonKeys(src) {
|
|
3931
|
+
const dups = [];
|
|
3932
|
+
const stack = [];
|
|
3933
|
+
let expectKey = false;
|
|
3934
|
+
for (let i = 0; i < src.length; i++) {
|
|
3935
|
+
const ch = src[i];
|
|
3936
|
+
if (ch === '"') {
|
|
3937
|
+
let j = i + 1;
|
|
3938
|
+
let s = "";
|
|
3939
|
+
while (j < src.length && src[j] !== '"') {
|
|
3940
|
+
if (src[j] === "\\") {
|
|
3941
|
+
s += src[j + 1] ?? "";
|
|
3942
|
+
j += 2;
|
|
3943
|
+
} else {
|
|
3944
|
+
s += src[j];
|
|
3945
|
+
j++;
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3948
|
+
if (expectKey) {
|
|
3949
|
+
const set = stack[stack.length - 1];
|
|
3950
|
+
if (set) {
|
|
3951
|
+
if (set.has(s)) dups.push(s);
|
|
3952
|
+
set.add(s);
|
|
3953
|
+
}
|
|
3954
|
+
expectKey = false;
|
|
3955
|
+
}
|
|
3956
|
+
i = j;
|
|
3957
|
+
continue;
|
|
3958
|
+
}
|
|
3959
|
+
if (ch === "{") {
|
|
3960
|
+
stack.push(/* @__PURE__ */ new Set());
|
|
3961
|
+
expectKey = true;
|
|
3962
|
+
} else if (ch === "[") {
|
|
3963
|
+
stack.push(null);
|
|
3964
|
+
expectKey = false;
|
|
3965
|
+
} else if (ch === "}" || ch === "]") {
|
|
3966
|
+
stack.pop();
|
|
3967
|
+
expectKey = false;
|
|
3968
|
+
} else if (ch === ",") expectKey = stack[stack.length - 1] !== null;
|
|
3969
|
+
}
|
|
3970
|
+
return [...new Set(dups)];
|
|
3012
3971
|
}
|
|
3013
|
-
|
|
3014
|
-
function settingsWeakening(before, after, sibling) {
|
|
3972
|
+
function settingsWeakening(before, after, local) {
|
|
3015
3973
|
const reasons = [];
|
|
3016
|
-
|
|
3017
|
-
|
|
3974
|
+
const dis = after.disableAllHooks;
|
|
3975
|
+
if (dis !== void 0 && dis !== false && !(before !== null && before.disableAllHooks === dis)) {
|
|
3976
|
+
reasons.push(`\`disableAllHooks: ${JSON.stringify(dis)}\` switches every hook off, the gate included`);
|
|
3977
|
+
}
|
|
3978
|
+
if (isObj(after.env)) {
|
|
3979
|
+
const was = before !== null && isObj(before.env) ? before.env : {};
|
|
3980
|
+
for (const [k, v] of Object.entries(after.env)) {
|
|
3981
|
+
if (SENSITIVE_ENV.test(k) && JSON.stringify(was[k]) !== JSON.stringify(v)) {
|
|
3982
|
+
reasons.push(`a top-level \`env\` sets ${k} \u2014 the gate command resolves through it`);
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3018
3985
|
}
|
|
3019
3986
|
for (const event of EVENTS) {
|
|
3020
|
-
const
|
|
3021
|
-
const
|
|
3987
|
+
const bc = before !== null ? gateCandidates(before, event) : [];
|
|
3988
|
+
const ac = gateCandidates(after, event);
|
|
3989
|
+
const was = coverage(bc);
|
|
3990
|
+
const now = coverage(ac);
|
|
3022
3991
|
if (was !== "none") {
|
|
3023
3992
|
if (now === "none") {
|
|
3024
3993
|
reasons.push(`the tamperward ${event} hook entry was removed`);
|
|
3025
3994
|
} else if (event === "PreToolUse") {
|
|
3026
3995
|
if (was === null && now !== null) {
|
|
3027
|
-
reasons.push(`the PreToolUse matcher for the gate was narrowed from every tool to ${[...now].join("|")}`);
|
|
3996
|
+
reasons.push(`the PreToolUse matcher for the gate was narrowed from every tool to ${[...now].join("|") || "(nothing)"}`);
|
|
3028
3997
|
} else if (was !== null && now !== null) {
|
|
3029
3998
|
const lost = [...was].filter((t) => !now.has(t));
|
|
3030
3999
|
if (lost.length) reasons.push(`the PreToolUse matcher for the gate no longer covers ${lost.join(", ")}`);
|
|
3031
4000
|
}
|
|
3032
4001
|
}
|
|
3033
|
-
|
|
4002
|
+
} else if (now === "none" && declares(after, event) && !(before !== null && declares(before, event)) && (eventEntries(after, event) ?? []).length === 0) {
|
|
4003
|
+
reasons.push(`${event} hooks declared empty in ${local ? "an override file" : "the settings file"}`);
|
|
4004
|
+
}
|
|
4005
|
+
const had = new Set(bc.map((c) => c.key));
|
|
4006
|
+
const fresh = ac.filter((c) => !had.has(c.key));
|
|
4007
|
+
for (const c of fresh) {
|
|
4008
|
+
if (c.problems.length) reasons.push(`the ${event} gate entry does not match the shape \`tamperward init\` writes: ${c.problems.join("; ")}`);
|
|
4009
|
+
}
|
|
4010
|
+
const pins = bc.map((c) => c.pin).filter((p) => !!p && PLAIN_SEMVER.test(p)).sort((a, b) => compareVersions(b, a) ?? 0);
|
|
4011
|
+
const floor = pins[0] ?? (before === null && PLAIN_SEMVER.test(TW_VERSION) ? TW_VERSION : null);
|
|
4012
|
+
if (floor) {
|
|
4013
|
+
for (const c of fresh) {
|
|
4014
|
+
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}`);
|
|
4015
|
+
}
|
|
3034
4016
|
}
|
|
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
|
-
);
|
|
4017
|
+
const hadOther = new Set(before !== null ? otherCommands(before, event) : []);
|
|
4018
|
+
for (const cmd of otherCommands(after, event)) {
|
|
4019
|
+
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
4020
|
}
|
|
3044
4021
|
}
|
|
3045
4022
|
return reasons;
|
|
3046
4023
|
}
|
|
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) {
|
|
4024
|
+
function claudeSettingsFindings(c, policy) {
|
|
3060
4025
|
if (c.after == null) return null;
|
|
3061
4026
|
const after = parseSettings(c.after);
|
|
3062
4027
|
const before = c.before != null ? parseSettings(c.before) : null;
|
|
@@ -3064,14 +4029,19 @@ function claudeSettingsFindings(c, changes, policy, ctx) {
|
|
|
3064
4029
|
file: c.path,
|
|
3065
4030
|
message: `The Claude Code hook wiring was weakened: ${reason}.`,
|
|
3066
4031
|
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."
|
|
4032
|
+
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
4033
|
});
|
|
3069
4034
|
if (c.before != null && before === null) return null;
|
|
3070
4035
|
if (after === null) {
|
|
3071
4036
|
if (c.before == null) return [];
|
|
3072
4037
|
return [finding("the settings file no longer parses as JSON \u2014 the runtime ignores it, hooks and all")];
|
|
3073
4038
|
}
|
|
3074
|
-
|
|
4039
|
+
const reasons = settingsWeakening(before, after, /settings\.local\.json$/.test(c.path));
|
|
4040
|
+
const had = new Set(c.before != null ? duplicateJsonKeys(c.before) : []);
|
|
4041
|
+
for (const k of duplicateJsonKeys(c.after)) {
|
|
4042
|
+
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`);
|
|
4043
|
+
}
|
|
4044
|
+
return reasons.map(finding);
|
|
3075
4045
|
}
|
|
3076
4046
|
function policyFindings(c, policy) {
|
|
3077
4047
|
if (c.after == null) return null;
|
|
@@ -3093,12 +4063,54 @@ function renameKept(c) {
|
|
|
3093
4063
|
if (id === null || hookIdentity(c.path) !== id) return false;
|
|
3094
4064
|
return c.before != null && c.after != null ? c.before === c.after : c.hunks.length === 0;
|
|
3095
4065
|
}
|
|
3096
|
-
function
|
|
3097
|
-
|
|
3098
|
-
const
|
|
3099
|
-
const
|
|
3100
|
-
if (
|
|
3101
|
-
|
|
4066
|
+
function lefthookBase(c, changes, ctx) {
|
|
4067
|
+
const dir = c.path.includes("/") ? c.path.slice(0, c.path.lastIndexOf("/") + 1) : "";
|
|
4068
|
+
const names = ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml"].map((n) => dir + n);
|
|
4069
|
+
const inSet = changes.find((o) => o.kind === "file" && names.includes(o.path));
|
|
4070
|
+
if (inSet) return inSet.after != null ? parseDoc(inSet.after) : null;
|
|
4071
|
+
if (!ctx?.cwd) return null;
|
|
4072
|
+
for (const n of names) {
|
|
4073
|
+
try {
|
|
4074
|
+
const p = join3(ctx.cwd, n);
|
|
4075
|
+
if (existsSync(p)) return parseDoc(readFileSync3(p, "utf8"));
|
|
4076
|
+
} catch {
|
|
4077
|
+
}
|
|
4078
|
+
}
|
|
4079
|
+
return null;
|
|
4080
|
+
}
|
|
4081
|
+
function syntheticBase(over) {
|
|
4082
|
+
const base = {};
|
|
4083
|
+
for (const [section, v] of Object.entries(over)) {
|
|
4084
|
+
if (!isObj(v)) continue;
|
|
4085
|
+
for (const group of ["commands", "scripts"]) {
|
|
4086
|
+
const g = v[group];
|
|
4087
|
+
if (!isObj(g)) continue;
|
|
4088
|
+
for (const name of Object.keys(g)) {
|
|
4089
|
+
if (!/tamperward/.test(name)) continue;
|
|
4090
|
+
const sec = base[section] ??= {};
|
|
4091
|
+
const grp = sec[group] ??= {};
|
|
4092
|
+
grp[name] = { run: "npx tamperward check --staged" };
|
|
4093
|
+
}
|
|
4094
|
+
}
|
|
4095
|
+
}
|
|
4096
|
+
return base;
|
|
4097
|
+
}
|
|
4098
|
+
function configFindings(c, changes, policy, ctx) {
|
|
4099
|
+
if (c.after == null) return null;
|
|
4100
|
+
let reasons;
|
|
4101
|
+
if (isLefthookLocal(c.path)) {
|
|
4102
|
+
const overBefore = c.before != null ? parseDoc(c.before) : {};
|
|
4103
|
+
const overAfter = parseDoc(c.after);
|
|
4104
|
+
if (overBefore === null) return null;
|
|
4105
|
+
const base = lefthookBase(c, changes, ctx) ?? syntheticBase(overAfter ?? {});
|
|
4106
|
+
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));
|
|
4107
|
+
} else {
|
|
4108
|
+
if (c.before == null) return null;
|
|
4109
|
+
const before = parseDoc(c.before);
|
|
4110
|
+
const after = parseDoc(c.after);
|
|
4111
|
+
if (before === null) return null;
|
|
4112
|
+
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);
|
|
4113
|
+
}
|
|
3102
4114
|
return reasons.map(
|
|
3103
4115
|
(reason) => makeFinding(RULE6, policy, {
|
|
3104
4116
|
file: c.path,
|
|
@@ -3108,6 +4120,11 @@ function configFindings(c, policy) {
|
|
|
3108
4120
|
})
|
|
3109
4121
|
);
|
|
3110
4122
|
}
|
|
4123
|
+
function repoGateFiles(ctx) {
|
|
4124
|
+
const files = trackedFiles(ctx);
|
|
4125
|
+
if (!files) return [];
|
|
4126
|
+
return files.filter((f) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(f) || /^\.husky\/[^/_][^/]*$/.test(f));
|
|
4127
|
+
}
|
|
3111
4128
|
function updateIndexHiding(toks, policy) {
|
|
3112
4129
|
if (!toks.some((t) => t === "git" || t.endsWith("/git")) || !toks.includes("update-index")) return null;
|
|
3113
4130
|
const hide = toks.find((t) => /^--(?:skip-worktree|assume-unchanged)$/.test(t));
|
|
@@ -3129,6 +4146,7 @@ function repoRelative(path, ctx) {
|
|
|
3129
4146
|
const rel = relative(ctx.cwd, path);
|
|
3130
4147
|
return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
|
|
3131
4148
|
}
|
|
4149
|
+
var isHuskyScript = (path) => /(?:^|\/)\.husky\/[^/]+$/.test(path);
|
|
3132
4150
|
var hookTampering = {
|
|
3133
4151
|
id: RULE6,
|
|
3134
4152
|
surface: ["file", "command"],
|
|
@@ -3138,7 +4156,7 @@ var hookTampering = {
|
|
|
3138
4156
|
for (const c of changes) {
|
|
3139
4157
|
let semantic;
|
|
3140
4158
|
if (c.kind === "file") {
|
|
3141
|
-
const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false);
|
|
4159
|
+
const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false) || isClaudeSettings(c.path);
|
|
3142
4160
|
if (!targetsHook) {
|
|
3143
4161
|
if (isPackageJson(c.path) && c.before != null && c.after != null) {
|
|
3144
4162
|
for (const reason of packageJsonWeakening(c.before, c.after)) {
|
|
@@ -3182,12 +4200,12 @@ var hookTampering = {
|
|
|
3182
4200
|
remediation: "Renaming a hook out of place disables it. Restore the original path."
|
|
3183
4201
|
})
|
|
3184
4202
|
);
|
|
3185
|
-
} else if ((c.path.endsWith(POLICY_FILE) || isClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c,
|
|
4203
|
+
} else if ((c.path.endsWith(POLICY_FILE) || isClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c, policy)) !== null) {
|
|
3186
4204
|
out2.push(...semantic);
|
|
3187
4205
|
} else if (c.op === "rename") {
|
|
3188
4206
|
} else if (isCodeowners(c.path)) {
|
|
3189
4207
|
if (c.before != null && c.after != null) {
|
|
3190
|
-
for (const reason of codeownersWeakening(c.before, c.after)) {
|
|
4208
|
+
for (const reason of codeownersWeakening(c.before, c.after, [...repoGateFiles(ctx), c.path])) {
|
|
3191
4209
|
out2.push(
|
|
3192
4210
|
makeFinding(RULE6, policy, {
|
|
3193
4211
|
file: c.path,
|
|
@@ -3198,9 +4216,40 @@ var hookTampering = {
|
|
|
3198
4216
|
);
|
|
3199
4217
|
}
|
|
3200
4218
|
}
|
|
3201
|
-
} else if ((isLefthook(c.path) || isPreCommitConfig(c.path)) && (semantic = configFindings(c, policy)) !== null) {
|
|
4219
|
+
} else if ((isLefthook(c.path) || isPreCommitConfig(c.path)) && (semantic = configFindings(c, changes, policy, ctx)) !== null) {
|
|
3202
4220
|
out2.push(...semantic);
|
|
3203
4221
|
} else if (!c.path.endsWith(POLICY_FILE)) {
|
|
4222
|
+
const husky = isHuskyScript(c.path);
|
|
4223
|
+
const beforePin = c.before != null && c.after != null ? initScriptPin(c.before) : null;
|
|
4224
|
+
if (beforePin !== null) {
|
|
4225
|
+
const afterPin = initScriptPin(c.after);
|
|
4226
|
+
const kept = afterPin !== null && (beforePin === "" ? afterPin === "" || PLAIN_SEMVER.test(afterPin) : pinNotBelow(afterPin, beforePin));
|
|
4227
|
+
if (!kept) {
|
|
4228
|
+
out2.push(
|
|
4229
|
+
makeFinding(RULE6, policy, {
|
|
4230
|
+
file: c.path,
|
|
4231
|
+
message: "A protected hook script no longer matches the shape `tamperward init` writes.",
|
|
4232
|
+
evidence: "the gate script no longer matches the shape init wrote; re-run init or sign off",
|
|
4233
|
+
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."
|
|
4234
|
+
})
|
|
4235
|
+
);
|
|
4236
|
+
}
|
|
4237
|
+
continue;
|
|
4238
|
+
}
|
|
4239
|
+
if (!husky && c.after != null) {
|
|
4240
|
+
const problem = shebangProblem(c.after);
|
|
4241
|
+
if (problem && (c.before == null || problem !== shebangProblem(c.before))) {
|
|
4242
|
+
out2.push(
|
|
4243
|
+
makeFinding(RULE6, policy, {
|
|
4244
|
+
file: c.path,
|
|
4245
|
+
line: 1,
|
|
4246
|
+
message: `A protected hook's interpreter was changed: ${problem}.`,
|
|
4247
|
+
evidence: c.after.split("\n")[0],
|
|
4248
|
+
remediation: "Keep a shell in shebang position. git execs the hook directly, and an interpreter that never reads the script is a disabled hook."
|
|
4249
|
+
})
|
|
4250
|
+
);
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
3204
4253
|
const added = addedLines(c);
|
|
3205
4254
|
const earlyExit = added.find((l) => insertsPassingExit(l.content));
|
|
3206
4255
|
if (earlyExit) {
|
|
@@ -3228,7 +4277,7 @@ var hookTampering = {
|
|
|
3228
4277
|
}
|
|
3229
4278
|
const beforeLines = c.before != null ? c.before.split("\n") : removedLines(c).map((l) => l.content);
|
|
3230
4279
|
const afterLines = c.after != null ? c.after.split("\n") : added.map((l) => l.content);
|
|
3231
|
-
const lost = scriptWeakening(beforeLines, afterLines);
|
|
4280
|
+
const lost = scriptWeakening(beforeLines, afterLines, husky ? { errexit: true } : {});
|
|
3232
4281
|
if (lost.length) {
|
|
3233
4282
|
out2.push(
|
|
3234
4283
|
makeFinding(RULE6, policy, {
|
|
@@ -3272,6 +4321,7 @@ var hookTampering = {
|
|
|
3272
4321
|
}
|
|
3273
4322
|
}
|
|
3274
4323
|
} else {
|
|
4324
|
+
const fed = [];
|
|
3275
4325
|
for (const seg of segments(c.raw)) {
|
|
3276
4326
|
const toks = tokens(seg).map(unquote);
|
|
3277
4327
|
const hiding = updateIndexHiding(toks, policy);
|
|
@@ -3284,11 +4334,12 @@ var hookTampering = {
|
|
|
3284
4334
|
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
4335
|
})
|
|
3286
4336
|
);
|
|
4337
|
+
fed.push(...toks);
|
|
3287
4338
|
continue;
|
|
3288
4339
|
}
|
|
3289
|
-
const why = shellWritesHook(seg, toks, policy);
|
|
4340
|
+
const why = shellWritesHook(seg, toks, policy, ctx) ?? xargsWritesHook(toks, fed, policy, ctx);
|
|
3290
4341
|
if (why) {
|
|
3291
|
-
const target = shellHookTarget(seg, toks, policy);
|
|
4342
|
+
const target = shellHookTarget(seg, toks, policy, ctx) ?? shellHookTarget("", fed, policy, ctx);
|
|
3292
4343
|
out2.push(
|
|
3293
4344
|
makeFinding(RULE6, policy, {
|
|
3294
4345
|
...target ? { file: repoRelative(target, ctx) } : {},
|
|
@@ -3298,6 +4349,7 @@ var hookTampering = {
|
|
|
3298
4349
|
})
|
|
3299
4350
|
);
|
|
3300
4351
|
}
|
|
4352
|
+
fed.push(...toks);
|
|
3301
4353
|
}
|
|
3302
4354
|
}
|
|
3303
4355
|
}
|
|
@@ -3396,13 +4448,13 @@ function collect(root, runner) {
|
|
|
3396
4448
|
sel.opaque = true;
|
|
3397
4449
|
return;
|
|
3398
4450
|
}
|
|
3399
|
-
const
|
|
4451
|
+
const list2 = [];
|
|
3400
4452
|
for (const el of e.elements) {
|
|
3401
|
-
if (ts3.isObjectLiteralExpression(el))
|
|
4453
|
+
if (ts3.isObjectLiteralExpression(el)) list2.push(collect(el, runner));
|
|
3402
4454
|
else sel.opaque = true;
|
|
3403
4455
|
}
|
|
3404
|
-
sel.projects =
|
|
3405
|
-
if (
|
|
4456
|
+
sel.projects = list2;
|
|
4457
|
+
if (list2.some((p) => p.opaque)) sel.opaque = true;
|
|
3406
4458
|
};
|
|
3407
4459
|
const visit = (node) => {
|
|
3408
4460
|
if (ts3.isPropertyAssignment(node)) {
|
|
@@ -3461,14 +4513,14 @@ function parseSelection(src, runner, path = "") {
|
|
|
3461
4513
|
if (runner === "vitest" && isWorkspaceFile(path)) {
|
|
3462
4514
|
const arr = workspaceArray(sf);
|
|
3463
4515
|
if (arr) {
|
|
3464
|
-
const
|
|
4516
|
+
const list2 = [];
|
|
3465
4517
|
for (const el of arr.elements) {
|
|
3466
|
-
if (ts3.isObjectLiteralExpression(el))
|
|
4518
|
+
if (ts3.isObjectLiteralExpression(el)) list2.push(collect(el, runner));
|
|
3467
4519
|
else sel.opaque = true;
|
|
3468
4520
|
}
|
|
3469
|
-
sel.projects =
|
|
4521
|
+
sel.projects = list2;
|
|
3470
4522
|
sel.present = true;
|
|
3471
|
-
if (
|
|
4523
|
+
if (list2.some((p) => p.opaque)) sel.opaque = true;
|
|
3472
4524
|
} else if (src.trim()) sel.opaque = true;
|
|
3473
4525
|
} else sel = collect(sf, runner);
|
|
3474
4526
|
} catch {
|
|
@@ -4101,7 +5153,7 @@ var GUARDED_RULE = "hook-tampering";
|
|
|
4101
5153
|
var BASELINE_HOOKS = defaultPolicy().protected.hooks;
|
|
4102
5154
|
function isGuardedFinding(f) {
|
|
4103
5155
|
if (f.rule !== GUARDED_RULE || !f.file) return false;
|
|
4104
|
-
return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS);
|
|
5156
|
+
return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS) || isClaudeSettings(f.file);
|
|
4105
5157
|
}
|
|
4106
5158
|
function key(f) {
|
|
4107
5159
|
return `${f.rule}|${f.file ?? ""}|${f.line ?? ""}|${f.evidence}`;
|
|
@@ -4160,8 +5212,8 @@ function hasBlocking(findings) {
|
|
|
4160
5212
|
}
|
|
4161
5213
|
|
|
4162
5214
|
// src/policy-load.ts
|
|
4163
|
-
import { readFileSync as
|
|
4164
|
-
import { isAbsolute as isAbsolute2, join as
|
|
5215
|
+
import { readFileSync as readFileSync4, existsSync as existsSync2 } from "node:fs";
|
|
5216
|
+
import { isAbsolute as isAbsolute2, join as join4, posix } from "node:path";
|
|
4165
5217
|
import { parse as parse2 } from "yaml";
|
|
4166
5218
|
var PolicyError = class extends Error {
|
|
4167
5219
|
};
|
|
@@ -4242,8 +5294,8 @@ function parsePolicy(raw, where = POLICY_FILE) {
|
|
|
4242
5294
|
const version = normalizeVersion(r.version, where);
|
|
4243
5295
|
validate(r, where);
|
|
4244
5296
|
const base = defaultPolicy(version);
|
|
4245
|
-
const globs4 = (
|
|
4246
|
-
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat,
|
|
5297
|
+
const globs4 = (list2) => list2?.map(normalizeGlob);
|
|
5298
|
+
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list2]) => [cat, globs4(list2) ?? []])) : void 0;
|
|
4247
5299
|
const userRules = r.rules ? Object.fromEntries(
|
|
4248
5300
|
Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs4(cfg.exclude) } : cfg])
|
|
4249
5301
|
) : void 0;
|
|
@@ -4285,9 +5337,9 @@ function parseOrThrow(src, where) {
|
|
|
4285
5337
|
return parsePolicy(raw, where);
|
|
4286
5338
|
}
|
|
4287
5339
|
function loadPolicy(cwd = process.cwd()) {
|
|
4288
|
-
const path =
|
|
5340
|
+
const path = join4(cwd, POLICY_FILE);
|
|
4289
5341
|
if (!existsSync2(path)) return defaultPolicy();
|
|
4290
|
-
return parseOrThrow(
|
|
5342
|
+
return parseOrThrow(readFileSync4(path, "utf8"), POLICY_FILE);
|
|
4291
5343
|
}
|
|
4292
5344
|
function loadPolicyAt(rev, cwd) {
|
|
4293
5345
|
const src = fileAt(rev, POLICY_FILE, { cwd });
|
|
@@ -4297,8 +5349,8 @@ function loadPolicyAt(rev, cwd) {
|
|
|
4297
5349
|
|
|
4298
5350
|
// src/signoff.ts
|
|
4299
5351
|
import { createHash } from "node:crypto";
|
|
4300
|
-
import { appendFileSync, mkdirSync, readFileSync as
|
|
4301
|
-
import { dirname, join as
|
|
5352
|
+
import { appendFileSync, mkdirSync, readFileSync as readFileSync5, existsSync as existsSync3 } from "node:fs";
|
|
5353
|
+
import { dirname as dirname2, join as join5 } from "node:path";
|
|
4302
5354
|
var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
4303
5355
|
function fingerprint(rule, file, evidence) {
|
|
4304
5356
|
return createHash("sha256").update(`${rule}\0${file ?? ""}\0${evidence}`).digest("hex").slice(0, 16);
|
|
@@ -4309,13 +5361,13 @@ function ledgerPath(cwd, policy) {
|
|
|
4309
5361
|
if (!ledgerInsideRepo(rel)) {
|
|
4310
5362
|
throw new PolicyError(`signoff.ledger must be a relative path inside the repository, got ${JSON.stringify(rel)}`);
|
|
4311
5363
|
}
|
|
4312
|
-
return
|
|
5364
|
+
return join5(cwd, rel);
|
|
4313
5365
|
}
|
|
4314
5366
|
function readLedger(cwd, policy) {
|
|
4315
5367
|
const p = ledgerPath(cwd, policy);
|
|
4316
5368
|
if (!existsSync3(p)) return [];
|
|
4317
5369
|
const out2 = [];
|
|
4318
|
-
for (const line of
|
|
5370
|
+
for (const line of readFileSync5(p, "utf8").split("\n")) {
|
|
4319
5371
|
if (!line.trim()) continue;
|
|
4320
5372
|
try {
|
|
4321
5373
|
const e = JSON.parse(line);
|
|
@@ -4327,7 +5379,7 @@ function readLedger(cwd, policy) {
|
|
|
4327
5379
|
}
|
|
4328
5380
|
function appendEntry(cwd, policy, e) {
|
|
4329
5381
|
const p = ledgerPath(cwd, policy);
|
|
4330
|
-
mkdirSync(
|
|
5382
|
+
mkdirSync(dirname2(p), { recursive: true });
|
|
4331
5383
|
appendFileSync(p, JSON.stringify(e) + "\n");
|
|
4332
5384
|
}
|
|
4333
5385
|
function makeEntry(f, reason, now, ttlMs = DEFAULT_TTL_MS) {
|
|
@@ -4684,21 +5736,21 @@ function runCheck(opts) {
|
|
|
4684
5736
|
}
|
|
4685
5737
|
|
|
4686
5738
|
// src/cli/hook.ts
|
|
4687
|
-
import { readFileSync as
|
|
5739
|
+
import { readFileSync as readFileSync11, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7, statSync as statSync4 } from "node:fs";
|
|
4688
5740
|
import { createHash as createHash4 } from "node:crypto";
|
|
4689
|
-
import { join as
|
|
5741
|
+
import { join as join10 } from "node:path";
|
|
4690
5742
|
|
|
4691
5743
|
// src/adapters/claude/changes.ts
|
|
4692
5744
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
4693
|
-
import { readFileSync as
|
|
5745
|
+
import { readFileSync as readFileSync6, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
4694
5746
|
import { tmpdir } from "node:os";
|
|
4695
|
-
import { isAbsolute as isAbsolute3, join as
|
|
5747
|
+
import { isAbsolute as isAbsolute3, join as join6, relative as relative2, resolve } from "node:path";
|
|
4696
5748
|
function asStr(v) {
|
|
4697
5749
|
return typeof v === "string" ? v : "";
|
|
4698
5750
|
}
|
|
4699
5751
|
function readDisk(path) {
|
|
4700
5752
|
try {
|
|
4701
|
-
return existsSync4(path) ?
|
|
5753
|
+
return existsSync4(path) ? readFileSync6(path, "utf8") : null;
|
|
4702
5754
|
} catch {
|
|
4703
5755
|
return null;
|
|
4704
5756
|
}
|
|
@@ -4721,11 +5773,11 @@ function synthFileChange(displayPath, before, after) {
|
|
|
4721
5773
|
if (before === null) op = "add";
|
|
4722
5774
|
else if (after === null) op = "delete";
|
|
4723
5775
|
else op = "modify";
|
|
4724
|
-
const dir = mkdtempSync(
|
|
5776
|
+
const dir = mkdtempSync(join6(tmpdir(), "hf-"));
|
|
4725
5777
|
let raw = "";
|
|
4726
5778
|
try {
|
|
4727
|
-
const a =
|
|
4728
|
-
const b =
|
|
5779
|
+
const a = join6(dir, "a");
|
|
5780
|
+
const b = join6(dir, "b");
|
|
4729
5781
|
writeFileSync(a, before ?? "");
|
|
4730
5782
|
writeFileSync(b, after ?? "");
|
|
4731
5783
|
try {
|
|
@@ -4810,14 +5862,14 @@ function formatDenial(blocks) {
|
|
|
4810
5862
|
}
|
|
4811
5863
|
|
|
4812
5864
|
// src/session.ts
|
|
4813
|
-
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as
|
|
4814
|
-
import { dirname as
|
|
5865
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "node:fs";
|
|
5866
|
+
import { dirname as dirname3, join as join7 } from "node:path";
|
|
4815
5867
|
var UNSAFE = /[^A-Za-z0-9_-]/g;
|
|
4816
5868
|
var SHA = /^[0-9a-f]{7,40}$/;
|
|
4817
5869
|
function baselinePath(cwd, sessionId) {
|
|
4818
5870
|
const gd = gitDir(cwd);
|
|
4819
5871
|
if (!gd) return null;
|
|
4820
|
-
return
|
|
5872
|
+
return join7(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
|
|
4821
5873
|
}
|
|
4822
5874
|
function turnBaseline(cwd, sessionId) {
|
|
4823
5875
|
if (!sessionId) return null;
|
|
@@ -4825,12 +5877,12 @@ function turnBaseline(cwd, sessionId) {
|
|
|
4825
5877
|
const p = baselinePath(cwd, sessionId);
|
|
4826
5878
|
if (!p) return null;
|
|
4827
5879
|
if (existsSync5(p)) {
|
|
4828
|
-
const v =
|
|
5880
|
+
const v = readFileSync7(p, "utf8").trim();
|
|
4829
5881
|
if (SHA.test(v)) return v;
|
|
4830
5882
|
}
|
|
4831
5883
|
const head = headSha(cwd);
|
|
4832
5884
|
if (!head) return null;
|
|
4833
|
-
mkdirSync2(
|
|
5885
|
+
mkdirSync2(dirname3(p), { recursive: true });
|
|
4834
5886
|
writeFileSync2(p, head);
|
|
4835
5887
|
return head;
|
|
4836
5888
|
} catch {
|
|
@@ -4843,7 +5895,7 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
4843
5895
|
const p = baselinePath(cwd, sessionId);
|
|
4844
5896
|
const head = headSha(cwd);
|
|
4845
5897
|
if (!p || !head) return;
|
|
4846
|
-
mkdirSync2(
|
|
5898
|
+
mkdirSync2(dirname3(p), { recursive: true });
|
|
4847
5899
|
writeFileSync2(p, head);
|
|
4848
5900
|
} catch {
|
|
4849
5901
|
}
|
|
@@ -4851,8 +5903,8 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
4851
5903
|
|
|
4852
5904
|
// src/effect.ts
|
|
4853
5905
|
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
|
|
5906
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync8, renameSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
5907
|
+
import { isAbsolute as isAbsolute4, join as join8 } from "node:path";
|
|
4856
5908
|
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
4857
5909
|
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
4858
5910
|
var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
@@ -4864,7 +5916,7 @@ function gitDir2(cwd) {
|
|
|
4864
5916
|
stdio: ["ignore", "pipe", "pipe"]
|
|
4865
5917
|
}).trim();
|
|
4866
5918
|
if (!d) return null;
|
|
4867
|
-
return isAbsolute4(d) ? d :
|
|
5919
|
+
return isAbsolute4(d) ? d : join8(cwd, d);
|
|
4868
5920
|
} catch {
|
|
4869
5921
|
return null;
|
|
4870
5922
|
}
|
|
@@ -4873,8 +5925,8 @@ function statePath(cwd, sessionId, kind) {
|
|
|
4873
5925
|
if (!sessionId) return null;
|
|
4874
5926
|
const gd = gitDir2(cwd);
|
|
4875
5927
|
if (!gd) return null;
|
|
4876
|
-
const dir =
|
|
4877
|
-
return
|
|
5928
|
+
const dir = join8(gd, "tamperward");
|
|
5929
|
+
return join8(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
|
|
4878
5930
|
}
|
|
4879
5931
|
function ptreePath(cwd, sessionId) {
|
|
4880
5932
|
return statePath(cwd, sessionId, "ptree");
|
|
@@ -4888,8 +5940,8 @@ function contentHash(content) {
|
|
|
4888
5940
|
}
|
|
4889
5941
|
function snapshotProtected(cwd, policy, prev) {
|
|
4890
5942
|
const out2 = {};
|
|
4891
|
-
const
|
|
4892
|
-
const abs2 = rel ?
|
|
5943
|
+
const walk2 = (rel) => {
|
|
5944
|
+
const abs2 = rel ? join8(cwd, rel) : cwd;
|
|
4893
5945
|
let names;
|
|
4894
5946
|
try {
|
|
4895
5947
|
names = readdirSync(abs2);
|
|
@@ -4901,15 +5953,15 @@ function snapshotProtected(cwd, policy, prev) {
|
|
|
4901
5953
|
const r = rel ? `${rel}/${name}` : name;
|
|
4902
5954
|
let st;
|
|
4903
5955
|
try {
|
|
4904
|
-
st = statSync(
|
|
5956
|
+
st = statSync(join8(cwd, r));
|
|
4905
5957
|
} catch {
|
|
4906
5958
|
continue;
|
|
4907
5959
|
}
|
|
4908
|
-
if (st.isDirectory())
|
|
5960
|
+
if (st.isDirectory()) walk2(r);
|
|
4909
5961
|
else if (st.isFile() && isProtected(r, policy)) {
|
|
4910
5962
|
let hash = "";
|
|
4911
5963
|
try {
|
|
4912
|
-
hash = sha(
|
|
5964
|
+
hash = sha(readFileSync8(join8(cwd, r)));
|
|
4913
5965
|
} catch {
|
|
4914
5966
|
hash = "unreadable";
|
|
4915
5967
|
}
|
|
@@ -4917,7 +5969,7 @@ function snapshotProtected(cwd, policy, prev) {
|
|
|
4917
5969
|
}
|
|
4918
5970
|
}
|
|
4919
5971
|
};
|
|
4920
|
-
|
|
5972
|
+
walk2("");
|
|
4921
5973
|
return out2;
|
|
4922
5974
|
}
|
|
4923
5975
|
function driftBetween(expected, current) {
|
|
@@ -4937,7 +5989,7 @@ function driftBetween(expected, current) {
|
|
|
4937
5989
|
function loadTree(p) {
|
|
4938
5990
|
if (!p || !existsSync6(p)) return null;
|
|
4939
5991
|
try {
|
|
4940
|
-
return JSON.parse(
|
|
5992
|
+
return JSON.parse(readFileSync8(p, "utf8"));
|
|
4941
5993
|
} catch {
|
|
4942
5994
|
return null;
|
|
4943
5995
|
}
|
|
@@ -4945,7 +5997,7 @@ function loadTree(p) {
|
|
|
4945
5997
|
function saveTree(p, tree) {
|
|
4946
5998
|
if (!p) return;
|
|
4947
5999
|
try {
|
|
4948
|
-
mkdirSync3(
|
|
6000
|
+
mkdirSync3(join8(p, ".."), { recursive: true });
|
|
4949
6001
|
const tmp = `${p}.${process.pid}.tmp`;
|
|
4950
6002
|
writeFileSync3(tmp, JSON.stringify(tree));
|
|
4951
6003
|
renameSync(tmp, p);
|
|
@@ -4976,15 +6028,15 @@ function dropTurnTree(cwd, sessionId) {
|
|
|
4976
6028
|
}
|
|
4977
6029
|
|
|
4978
6030
|
// src/cli/watch.ts
|
|
4979
|
-
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as
|
|
4980
|
-
import { join as
|
|
6031
|
+
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync9, statSync as statSync2, watch } from "node:fs";
|
|
6032
|
+
import { join as join9 } from "node:path";
|
|
4981
6033
|
import { createHash as createHash3 } from "node:crypto";
|
|
4982
6034
|
var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
|
|
4983
6035
|
function snap(abs2) {
|
|
4984
6036
|
try {
|
|
4985
6037
|
const st = statSync2(abs2);
|
|
4986
6038
|
if (!st.isFile()) return { mode: st.mode, size: null, hash: null };
|
|
4987
|
-
const hash = createHash3("sha256").update(
|
|
6039
|
+
const hash = createHash3("sha256").update(readFileSync9(abs2)).digest("hex").slice(0, 16);
|
|
4988
6040
|
return { mode: st.mode, size: st.size, hash };
|
|
4989
6041
|
} catch {
|
|
4990
6042
|
return { mode: null, size: null, hash: null };
|
|
@@ -4992,7 +6044,7 @@ function snap(abs2) {
|
|
|
4992
6044
|
}
|
|
4993
6045
|
function defaultEventLog(cwd) {
|
|
4994
6046
|
if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
|
|
4995
|
-
return
|
|
6047
|
+
return join9(gitDir(cwd) ?? join9(cwd, ".git"), "tamperward", "fsevents.jsonl");
|
|
4996
6048
|
}
|
|
4997
6049
|
function watchTree(dir, cb) {
|
|
4998
6050
|
if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
|
|
@@ -5009,12 +6061,12 @@ function watchTree(dir, cb) {
|
|
|
5009
6061
|
if (watchers.has(rel) || SKIP2.test(rel + "/")) return;
|
|
5010
6062
|
let w;
|
|
5011
6063
|
try {
|
|
5012
|
-
w = watch(rel ?
|
|
6064
|
+
w = watch(rel ? join9(dir, rel) : dir, (kind, fname) => {
|
|
5013
6065
|
if (!fname) return;
|
|
5014
6066
|
const child = rel ? `${rel}/${String(fname)}` : String(fname);
|
|
5015
6067
|
cb(kind, child);
|
|
5016
6068
|
try {
|
|
5017
|
-
if (statSync2(
|
|
6069
|
+
if (statSync2(join9(dir, child)).isDirectory()) addDir(child);
|
|
5018
6070
|
} catch {
|
|
5019
6071
|
}
|
|
5020
6072
|
});
|
|
@@ -5023,11 +6075,11 @@ function watchTree(dir, cb) {
|
|
|
5023
6075
|
}
|
|
5024
6076
|
watchers.set(rel, w);
|
|
5025
6077
|
};
|
|
5026
|
-
const
|
|
6078
|
+
const walk2 = (rel) => {
|
|
5027
6079
|
addDir(rel);
|
|
5028
6080
|
let names = [];
|
|
5029
6081
|
try {
|
|
5030
|
-
names = readdirSync2(rel ?
|
|
6082
|
+
names = readdirSync2(rel ? join9(dir, rel) : dir);
|
|
5031
6083
|
} catch {
|
|
5032
6084
|
return;
|
|
5033
6085
|
}
|
|
@@ -5035,25 +6087,25 @@ function watchTree(dir, cb) {
|
|
|
5035
6087
|
const child = rel ? `${rel}/${name}` : name;
|
|
5036
6088
|
if (SKIP2.test(child + "/")) continue;
|
|
5037
6089
|
try {
|
|
5038
|
-
if (statSync2(
|
|
6090
|
+
if (statSync2(join9(dir, child)).isDirectory()) walk2(child);
|
|
5039
6091
|
} catch {
|
|
5040
6092
|
}
|
|
5041
6093
|
}
|
|
5042
6094
|
};
|
|
5043
|
-
|
|
6095
|
+
walk2("");
|
|
5044
6096
|
return { close: () => {
|
|
5045
6097
|
for (const w of watchers.values()) w.close();
|
|
5046
6098
|
} };
|
|
5047
6099
|
}
|
|
5048
6100
|
function startWatcher(dir, log, policy) {
|
|
5049
6101
|
try {
|
|
5050
|
-
mkdirSync4(
|
|
6102
|
+
mkdirSync4(join9(log, ".."), { recursive: true });
|
|
5051
6103
|
} catch {
|
|
5052
6104
|
}
|
|
5053
6105
|
const last = /* @__PURE__ */ new Map();
|
|
5054
6106
|
return watchTree(dir, (kind, rel) => {
|
|
5055
6107
|
if (SKIP2.test(rel) || !isProtected(rel, policy)) return;
|
|
5056
|
-
const s = snap(
|
|
6108
|
+
const s = snap(join9(dir, rel));
|
|
5057
6109
|
const key2 = `${s.hash}:${s.mode}`;
|
|
5058
6110
|
if (last.get(rel) === key2) return;
|
|
5059
6111
|
last.set(rel, key2);
|
|
@@ -5080,7 +6132,7 @@ function runWatch(args) {
|
|
|
5080
6132
|
}
|
|
5081
6133
|
|
|
5082
6134
|
// src/detectors/fs-events.ts
|
|
5083
|
-
import { readFileSync as
|
|
6135
|
+
import { readFileSync as readFileSync10, statSync as statSync3 } from "node:fs";
|
|
5084
6136
|
var RULE11 = "transient-protected-mutation";
|
|
5085
6137
|
function readEvents(log, offset) {
|
|
5086
6138
|
let size = 0;
|
|
@@ -5090,7 +6142,7 @@ function readEvents(log, offset) {
|
|
|
5090
6142
|
return { events: [], newOffset: offset };
|
|
5091
6143
|
}
|
|
5092
6144
|
if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
|
|
5093
|
-
const raw =
|
|
6145
|
+
const raw = readFileSync10(log, "utf8").slice(offset);
|
|
5094
6146
|
const events = [];
|
|
5095
6147
|
for (const line of raw.split("\n")) {
|
|
5096
6148
|
if (!line.trim()) continue;
|
|
@@ -5143,7 +6195,7 @@ var HookInputError = class extends Error {
|
|
|
5143
6195
|
};
|
|
5144
6196
|
function readStdin() {
|
|
5145
6197
|
try {
|
|
5146
|
-
return
|
|
6198
|
+
return readFileSync11(0, "utf8");
|
|
5147
6199
|
} catch (e) {
|
|
5148
6200
|
throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
|
|
5149
6201
|
}
|
|
@@ -5202,7 +6254,7 @@ function fileChanges(changes) {
|
|
|
5202
6254
|
}
|
|
5203
6255
|
function hashOnDisk(cwd, rel) {
|
|
5204
6256
|
try {
|
|
5205
|
-
return contentHash(
|
|
6257
|
+
return contentHash(readFileSync11(join10(cwd, rel)));
|
|
5206
6258
|
} catch {
|
|
5207
6259
|
return null;
|
|
5208
6260
|
}
|
|
@@ -5391,7 +6443,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
|
|
|
5391
6443
|
if (!existsSync7(log)) return none;
|
|
5392
6444
|
let offset = 0;
|
|
5393
6445
|
if (cp && existsSync7(cp)) {
|
|
5394
|
-
const n = Number(
|
|
6446
|
+
const n = Number(readFileSync11(cp, "utf8"));
|
|
5395
6447
|
offset = Number.isFinite(n) ? n : 0;
|
|
5396
6448
|
}
|
|
5397
6449
|
const { events, newOffset } = readEvents(log, offset);
|
|
@@ -5405,7 +6457,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
|
|
|
5405
6457
|
const finalHash = (path) => {
|
|
5406
6458
|
try {
|
|
5407
6459
|
if (!statSync4(`${cwd}/${path}`).isFile()) return null;
|
|
5408
|
-
return createHash4("sha256").update(
|
|
6460
|
+
return createHash4("sha256").update(readFileSync11(`${cwd}/${path}`)).digest("hex").slice(0, 16);
|
|
5409
6461
|
} catch {
|
|
5410
6462
|
return null;
|
|
5411
6463
|
}
|
|
@@ -5548,9 +6600,8 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
5548
6600
|
// src/cli/init.ts
|
|
5549
6601
|
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
5550
6602
|
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";
|
|
6603
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync12, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
|
|
6604
|
+
import { basename, dirname as dirname4, isAbsolute as isAbsolute5, join as join11, relative as relative3, resolve as resolve2 } from "node:path";
|
|
5554
6605
|
var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
|
|
5555
6606
|
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
5556
6607
|
function gitConfig(cwd, key2) {
|
|
@@ -5560,12 +6611,9 @@ function gitConfig(cwd, key2) {
|
|
|
5560
6611
|
return null;
|
|
5561
6612
|
}
|
|
5562
6613
|
}
|
|
5563
|
-
var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
|
|
5564
|
-
var MARKER = "# tamperward: block agent shortcuts before they land";
|
|
5565
6614
|
var HOOK_RE = /\btamperward(?:@\S+)?\s+hook\s+claude\b/;
|
|
5566
6615
|
var SWEEP_RE = /\btamperward(?:@\S+)?\s+sweep\s+claude\b/;
|
|
5567
6616
|
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
6617
|
var CODEOWNERS_MARK = "# tamperward: the gate cannot guard the file that defines it";
|
|
5570
6618
|
var CODEOWNERS_PATHS = ["/.github/workflows/", "/.tamperward.yml", "/.github/CODEOWNERS"];
|
|
5571
6619
|
function coveredBy(existing, critical) {
|
|
@@ -5609,24 +6657,6 @@ version: 1
|
|
|
5609
6657
|
# snapshot-rewrite: { severity: block }
|
|
5610
6658
|
# ignore: [] # visible blind spots \u2014 the count is always reported
|
|
5611
6659
|
`;
|
|
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
6660
|
function pinOf(command) {
|
|
5631
6661
|
const m = command.match(OURS);
|
|
5632
6662
|
return m ? m[1] ?? "" : null;
|
|
@@ -5721,13 +6751,13 @@ function readWorkflowMark(src) {
|
|
|
5721
6751
|
if (!m) return null;
|
|
5722
6752
|
return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
|
|
5723
6753
|
}
|
|
5724
|
-
function
|
|
6754
|
+
function toolSet(matcher2) {
|
|
5725
6755
|
const m = String(matcher2 ?? "").trim();
|
|
5726
6756
|
if (m === "" || m === "*") return null;
|
|
5727
6757
|
return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
|
|
5728
6758
|
}
|
|
5729
6759
|
function missingTools(matcher2) {
|
|
5730
|
-
const have =
|
|
6760
|
+
const have = toolSet(matcher2);
|
|
5731
6761
|
if (have === null) return [];
|
|
5732
6762
|
return PRE_MATCHER.split("|").filter((t) => !have.has(t));
|
|
5733
6763
|
}
|
|
@@ -5747,7 +6777,7 @@ function hooksShapeError(hooks) {
|
|
|
5747
6777
|
return null;
|
|
5748
6778
|
}
|
|
5749
6779
|
function planPolicy(cwd) {
|
|
5750
|
-
const path =
|
|
6780
|
+
const path = join11(cwd, POLICY_FILE);
|
|
5751
6781
|
if (existsSync8(path)) {
|
|
5752
6782
|
try {
|
|
5753
6783
|
loadPolicy(cwd);
|
|
@@ -5766,12 +6796,12 @@ function planPolicy(cwd) {
|
|
|
5766
6796
|
}
|
|
5767
6797
|
function planClaudeHooks(cwd) {
|
|
5768
6798
|
const rel = ".claude/settings.json";
|
|
5769
|
-
const path =
|
|
6799
|
+
const path = join11(cwd, rel);
|
|
5770
6800
|
let settings = {};
|
|
5771
6801
|
if (existsSync8(path)) {
|
|
5772
6802
|
let parsed;
|
|
5773
6803
|
try {
|
|
5774
|
-
parsed = JSON.parse(
|
|
6804
|
+
parsed = JSON.parse(readFileSync12(path, "utf8"));
|
|
5775
6805
|
} catch {
|
|
5776
6806
|
return {
|
|
5777
6807
|
item: "agent",
|
|
@@ -5824,12 +6854,12 @@ function planClaudeHooks(cwd) {
|
|
|
5824
6854
|
});
|
|
5825
6855
|
}
|
|
5826
6856
|
for (const m of stale) {
|
|
5827
|
-
m.matcher = [...
|
|
6857
|
+
m.matcher = [...toolSet(m.matcher) ?? /* @__PURE__ */ new Set(), ...missingTools(m.matcher)].join("|");
|
|
5828
6858
|
}
|
|
5829
6859
|
if (needStop) {
|
|
5830
6860
|
(hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
|
|
5831
6861
|
}
|
|
5832
|
-
mkdirSync5(
|
|
6862
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
5833
6863
|
writeFileSync5(path, JSON.stringify(settings, null, 2) + "\n");
|
|
5834
6864
|
}
|
|
5835
6865
|
};
|
|
@@ -5864,7 +6894,7 @@ function hooksDir(cwd) {
|
|
|
5864
6894
|
if (out2) return resolve2(cwd, out2);
|
|
5865
6895
|
} catch {
|
|
5866
6896
|
}
|
|
5867
|
-
return existsSync8(
|
|
6897
|
+
return existsSync8(join11(cwd, ".git")) ? join11(cwd, ".git", "hooks") : null;
|
|
5868
6898
|
}
|
|
5869
6899
|
function display(cwd, path) {
|
|
5870
6900
|
const rel = relative3(cwd, path);
|
|
@@ -5891,30 +6921,30 @@ function planPreCommit(cwd) {
|
|
|
5891
6921
|
const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
|
|
5892
6922
|
let path;
|
|
5893
6923
|
let note;
|
|
5894
|
-
if (existsSync8(
|
|
5895
|
-
path =
|
|
6924
|
+
if (existsSync8(join11(cwd, ".husky"))) {
|
|
6925
|
+
path = join11(cwd, ".husky", "pre-commit");
|
|
5896
6926
|
note = "husky";
|
|
5897
6927
|
} else {
|
|
5898
|
-
const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(
|
|
6928
|
+
const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join11(cwd, c))));
|
|
5899
6929
|
if (managed) {
|
|
5900
6930
|
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
6931
|
}
|
|
5902
6932
|
const dir = hooksDir(cwd);
|
|
5903
6933
|
if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
|
|
5904
|
-
if (basename(dir) === "_" && basename(
|
|
5905
|
-
path =
|
|
6934
|
+
if (basename(dir) === "_" && basename(dirname4(dir)) === ".husky") {
|
|
6935
|
+
path = join11(dirname4(dir), "pre-commit");
|
|
5906
6936
|
note = "husky";
|
|
5907
6937
|
} else {
|
|
5908
|
-
path =
|
|
6938
|
+
path = join11(dir, "pre-commit");
|
|
5909
6939
|
const rel2 = display(cwd, dir);
|
|
5910
|
-
note = rel2 ===
|
|
6940
|
+
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
6941
|
}
|
|
5912
6942
|
}
|
|
5913
6943
|
const rel = display(cwd, path);
|
|
5914
6944
|
if (existsSync8(path) && !statSync5(path).isFile()) {
|
|
5915
6945
|
return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
|
|
5916
6946
|
}
|
|
5917
|
-
const existing = existsSync8(path) ?
|
|
6947
|
+
const existing = existsSync8(path) ? readFileSync12(path, "utf8") : null;
|
|
5918
6948
|
if (existing === null) {
|
|
5919
6949
|
return {
|
|
5920
6950
|
item: "pre-commit",
|
|
@@ -5922,7 +6952,7 @@ function planPreCommit(cwd) {
|
|
|
5922
6952
|
status: "create",
|
|
5923
6953
|
detail: `create via ${note}`,
|
|
5924
6954
|
apply: () => {
|
|
5925
|
-
mkdirSync5(
|
|
6955
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
5926
6956
|
writeFileSync5(path, `#!/bin/sh
|
|
5927
6957
|
${block.join("\n")}
|
|
5928
6958
|
`);
|
|
@@ -6003,10 +7033,10 @@ ${block.join("\n")}
|
|
|
6003
7033
|
function planCodeowners(cwd) {
|
|
6004
7034
|
const rel = ".github/CODEOWNERS";
|
|
6005
7035
|
const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
|
|
6006
|
-
(r) => existsSync8(
|
|
7036
|
+
(r) => existsSync8(join11(cwd, r))
|
|
6007
7037
|
);
|
|
6008
|
-
const path =
|
|
6009
|
-
const existing = existingRel ?
|
|
7038
|
+
const path = join11(cwd, existingRel ?? rel);
|
|
7039
|
+
const existing = existingRel ? readFileSync12(path, "utf8") : null;
|
|
6010
7040
|
const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
|
|
6011
7041
|
if (missing.length === 0) {
|
|
6012
7042
|
return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
|
|
@@ -6032,16 +7062,16 @@ ${CODEOWNERS_MARK}
|
|
|
6032
7062
|
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
7063
|
} : {},
|
|
6034
7064
|
apply: () => {
|
|
6035
|
-
mkdirSync5(
|
|
7065
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
6036
7066
|
writeFileSync5(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
|
|
6037
7067
|
}
|
|
6038
7068
|
};
|
|
6039
7069
|
}
|
|
6040
7070
|
function planWorkflow(cwd, force) {
|
|
6041
7071
|
const rel = ".github/workflows/tamperward.yml";
|
|
6042
|
-
const path =
|
|
7072
|
+
const path = join11(cwd, rel);
|
|
6043
7073
|
const write = () => {
|
|
6044
|
-
mkdirSync5(
|
|
7074
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
6045
7075
|
writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
|
|
6046
7076
|
};
|
|
6047
7077
|
if (!existsSync8(path)) {
|
|
@@ -6055,7 +7085,7 @@ function planWorkflow(cwd, force) {
|
|
|
6055
7085
|
}
|
|
6056
7086
|
let src;
|
|
6057
7087
|
try {
|
|
6058
|
-
src =
|
|
7088
|
+
src = readFileSync12(path, "utf8");
|
|
6059
7089
|
} catch {
|
|
6060
7090
|
return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
|
|
6061
7091
|
}
|
|
@@ -6154,15 +7184,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
6154
7184
|
// src/cli/verify.ts
|
|
6155
7185
|
import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
|
|
6156
7186
|
import { createHash as createHash8 } from "node:crypto";
|
|
6157
|
-
import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as
|
|
7187
|
+
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
7188
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
6159
|
-
import { dirname as
|
|
7189
|
+
import { dirname as dirname5, join as join14, resolve as resolve3 } from "node:path";
|
|
6160
7190
|
|
|
6161
7191
|
// src/git/trusted.ts
|
|
6162
7192
|
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
6163
7193
|
import { createHash as createHash6 } from "node:crypto";
|
|
6164
|
-
import { existsSync as existsSync9, readFileSync as
|
|
6165
|
-
import { isAbsolute as isAbsolute6, join as
|
|
7194
|
+
import { existsSync as existsSync9, readFileSync as readFileSync13 } from "node:fs";
|
|
7195
|
+
import { isAbsolute as isAbsolute6, join as join12 } from "node:path";
|
|
6166
7196
|
function trustedGitEnv() {
|
|
6167
7197
|
return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
6168
7198
|
}
|
|
@@ -6175,7 +7205,7 @@ function gitDirOf(cwd) {
|
|
|
6175
7205
|
env: trustedGitEnv()
|
|
6176
7206
|
}).trim();
|
|
6177
7207
|
if (!d) return null;
|
|
6178
|
-
return isAbsolute6(d) ? d :
|
|
7208
|
+
return isAbsolute6(d) ? d : join12(cwd, d);
|
|
6179
7209
|
} catch {
|
|
6180
7210
|
return null;
|
|
6181
7211
|
}
|
|
@@ -6200,9 +7230,9 @@ function objectRewriteState(cwd) {
|
|
|
6200
7230
|
parts.push(`${rel}:<no-git-dir>`);
|
|
6201
7231
|
continue;
|
|
6202
7232
|
}
|
|
6203
|
-
const p =
|
|
7233
|
+
const p = join12(dir, rel);
|
|
6204
7234
|
try {
|
|
6205
|
-
parts.push(`${rel}:` + (existsSync9(p) ?
|
|
7235
|
+
parts.push(`${rel}:` + (existsSync9(p) ? readFileSync13(p, "utf8").trim() : ""));
|
|
6206
7236
|
} catch {
|
|
6207
7237
|
parts.push(`${rel}:<unreadable>`);
|
|
6208
7238
|
}
|
|
@@ -6213,8 +7243,8 @@ function objectRewriteState(cwd) {
|
|
|
6213
7243
|
// src/fingerprint.ts
|
|
6214
7244
|
import { execFileSync as execFileSync7 } from "node:child_process";
|
|
6215
7245
|
import { createHash as createHash7 } from "node:crypto";
|
|
6216
|
-
import { readdirSync as readdirSync3, readFileSync as
|
|
6217
|
-
import { join as
|
|
7246
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync14, readlinkSync, statSync as statSync6 } from "node:fs";
|
|
7247
|
+
import { join as join13 } from "node:path";
|
|
6218
7248
|
function treeFingerprint(cwd) {
|
|
6219
7249
|
const files = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
6220
7250
|
cwd,
|
|
@@ -6227,7 +7257,7 @@ function treeFingerprint(cwd) {
|
|
|
6227
7257
|
h.update(rel);
|
|
6228
7258
|
h.update("\0");
|
|
6229
7259
|
try {
|
|
6230
|
-
h.update(
|
|
7260
|
+
h.update(readFileSync14(join13(cwd, rel)));
|
|
6231
7261
|
} catch {
|
|
6232
7262
|
h.update("<unreadable>");
|
|
6233
7263
|
}
|
|
@@ -6236,9 +7266,9 @@ function treeFingerprint(cwd) {
|
|
|
6236
7266
|
return h.digest("hex");
|
|
6237
7267
|
}
|
|
6238
7268
|
function depsFingerprint(cwd) {
|
|
6239
|
-
const root =
|
|
7269
|
+
const root = join13(cwd, "node_modules");
|
|
6240
7270
|
const h = createHash7("sha256");
|
|
6241
|
-
const
|
|
7271
|
+
const walk2 = (dir) => {
|
|
6242
7272
|
let entries;
|
|
6243
7273
|
try {
|
|
6244
7274
|
entries = readdirSync3(dir, { withFileTypes: true });
|
|
@@ -6246,12 +7276,12 @@ function depsFingerprint(cwd) {
|
|
|
6246
7276
|
return;
|
|
6247
7277
|
}
|
|
6248
7278
|
for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
6249
|
-
const p =
|
|
6250
|
-
if (e.isDirectory())
|
|
7279
|
+
const p = join13(dir, e.name);
|
|
7280
|
+
if (e.isDirectory()) walk2(p);
|
|
6251
7281
|
else if (e.isFile()) {
|
|
6252
7282
|
h.update(p);
|
|
6253
7283
|
try {
|
|
6254
|
-
h.update(
|
|
7284
|
+
h.update(readFileSync14(p));
|
|
6255
7285
|
h.update(String(statSync6(p).mode));
|
|
6256
7286
|
} catch {
|
|
6257
7287
|
h.update("<unreadable>");
|
|
@@ -6271,7 +7301,7 @@ function depsFingerprint(cwd) {
|
|
|
6271
7301
|
} catch {
|
|
6272
7302
|
return "none";
|
|
6273
7303
|
}
|
|
6274
|
-
|
|
7304
|
+
walk2(root);
|
|
6275
7305
|
return h.digest("hex");
|
|
6276
7306
|
}
|
|
6277
7307
|
|
|
@@ -6366,7 +7396,7 @@ function baseIsAncestorOfHead(base, cwd) {
|
|
|
6366
7396
|
function materialize(cwd, dest) {
|
|
6367
7397
|
const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
6368
7398
|
for (const rel of listed) {
|
|
6369
|
-
const src =
|
|
7399
|
+
const src = join14(cwd, rel);
|
|
6370
7400
|
let st;
|
|
6371
7401
|
try {
|
|
6372
7402
|
st = statSync7(src);
|
|
@@ -6374,18 +7404,18 @@ function materialize(cwd, dest) {
|
|
|
6374
7404
|
continue;
|
|
6375
7405
|
}
|
|
6376
7406
|
if (!st.isFile()) continue;
|
|
6377
|
-
const out2 =
|
|
6378
|
-
mkdirSync6(
|
|
7407
|
+
const out2 = join14(dest, rel);
|
|
7408
|
+
mkdirSync6(dirname5(out2), { recursive: true });
|
|
6379
7409
|
dropSymlink(out2);
|
|
6380
7410
|
if (lstatSync(src).isSymbolicLink()) {
|
|
6381
|
-
writeFileSync6(out2,
|
|
7411
|
+
writeFileSync6(out2, readFileSync15(src));
|
|
6382
7412
|
} else {
|
|
6383
7413
|
cpSync(src, out2, { dereference: true });
|
|
6384
7414
|
}
|
|
6385
7415
|
chmodSync2(out2, st.mode);
|
|
6386
7416
|
}
|
|
6387
7417
|
const nm = resolve3(cwd, "node_modules");
|
|
6388
|
-
if (existsSync10(nm)) symlinkSync(nm,
|
|
7418
|
+
if (existsSync10(nm)) symlinkSync(nm, join14(dest, "node_modules"), "dir");
|
|
6389
7419
|
}
|
|
6390
7420
|
function dropSymlink(p) {
|
|
6391
7421
|
try {
|
|
@@ -6418,11 +7448,11 @@ function overlayDigest(dest, paths) {
|
|
|
6418
7448
|
for (const rel of [...paths].sort()) {
|
|
6419
7449
|
h.update(rel);
|
|
6420
7450
|
h.update("\0");
|
|
6421
|
-
const p =
|
|
7451
|
+
const p = join14(dest, rel);
|
|
6422
7452
|
try {
|
|
6423
7453
|
const st = lstatSync(p);
|
|
6424
7454
|
h.update(String(st.mode));
|
|
6425
|
-
h.update(st.isSymbolicLink() ? readlinkSync2(p) :
|
|
7455
|
+
h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync15(p));
|
|
6426
7456
|
} catch {
|
|
6427
7457
|
h.update("<gone>");
|
|
6428
7458
|
}
|
|
@@ -6445,8 +7475,8 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
|
6445
7475
|
throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
|
|
6446
7476
|
}
|
|
6447
7477
|
const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
6448
|
-
const out2 =
|
|
6449
|
-
mkdirSync6(
|
|
7478
|
+
const out2 = join14(dest, e.path);
|
|
7479
|
+
mkdirSync6(dirname5(out2), { recursive: true });
|
|
6450
7480
|
rmSync3(out2, { force: true });
|
|
6451
7481
|
if (e.mode === "120000") {
|
|
6452
7482
|
symlinkSync(content.toString("utf8"), out2);
|
|
@@ -6460,7 +7490,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
|
6460
7490
|
const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
6461
7491
|
for (const rel of inCopy) {
|
|
6462
7492
|
if (!isOverlay(rel) || baseProtected.has(rel)) continue;
|
|
6463
|
-
rmSync3(
|
|
7493
|
+
rmSync3(join14(dest, rel), { force: true });
|
|
6464
7494
|
removed++;
|
|
6465
7495
|
}
|
|
6466
7496
|
return { restored, removed };
|
|
@@ -6502,8 +7532,8 @@ function suiteEnv(scratch) {
|
|
|
6502
7532
|
if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
|
|
6503
7533
|
env[k] = v;
|
|
6504
7534
|
}
|
|
6505
|
-
const userRc =
|
|
6506
|
-
const globalRc =
|
|
7535
|
+
const userRc = join14(scratch, "npmrc-user");
|
|
7536
|
+
const globalRc = join14(scratch, "npmrc-global");
|
|
6507
7537
|
for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
|
|
6508
7538
|
env.npm_config_userconfig = userRc;
|
|
6509
7539
|
env.npm_config_globalconfig = globalRc;
|
|
@@ -6513,8 +7543,8 @@ function suiteEnv(scratch) {
|
|
|
6513
7543
|
}
|
|
6514
7544
|
function runSuite(dir, cmd, budgetSecs) {
|
|
6515
7545
|
const t0 = Date.now();
|
|
6516
|
-
const outDir = mkdtempSync2(
|
|
6517
|
-
const outFile =
|
|
7546
|
+
const outDir = mkdtempSync2(join14(tmpdir2(), "tw-verify-run-"));
|
|
7547
|
+
const outFile = join14(outDir, "result.json");
|
|
6518
7548
|
try {
|
|
6519
7549
|
spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
|
|
6520
7550
|
cwd: dir,
|
|
@@ -6527,7 +7557,7 @@ function runSuite(dir, cmd, budgetSecs) {
|
|
|
6527
7557
|
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
6528
7558
|
let r;
|
|
6529
7559
|
try {
|
|
6530
|
-
r = JSON.parse(
|
|
7560
|
+
r = JSON.parse(readFileSync15(outFile, "utf8"));
|
|
6531
7561
|
} catch {
|
|
6532
7562
|
return { exit: null, secs };
|
|
6533
7563
|
}
|
|
@@ -6578,8 +7608,8 @@ function runVerify(opts) {
|
|
|
6578
7608
|
out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
|
|
6579
7609
|
return 2;
|
|
6580
7610
|
}
|
|
6581
|
-
const visRoot = mkdtempSync2(
|
|
6582
|
-
const visDir =
|
|
7611
|
+
const visRoot = mkdtempSync2(join14(tmpdir2(), "tw-verify-vis-"));
|
|
7612
|
+
const visDir = join14(visRoot, "t");
|
|
6583
7613
|
const cleanup = (dirs) => {
|
|
6584
7614
|
if (opts.keep) return;
|
|
6585
7615
|
for (const d of dirs) rmSync3(d, { recursive: true, force: true });
|
|
@@ -6607,8 +7637,8 @@ function runVerify(opts) {
|
|
|
6607
7637
|
out2("pristine run executes through it too. Failing closed, not open.");
|
|
6608
7638
|
return 2;
|
|
6609
7639
|
}
|
|
6610
|
-
const priRoot = mkdtempSync2(
|
|
6611
|
-
const priDir =
|
|
7640
|
+
const priRoot = mkdtempSync2(join14(tmpdir2(), "tw-verify-pri-"));
|
|
7641
|
+
const priDir = join14(priRoot, "t");
|
|
6612
7642
|
let restored = [];
|
|
6613
7643
|
let removedAdded = 0;
|
|
6614
7644
|
try {
|
|
@@ -6720,7 +7750,7 @@ function parseVerify(args) {
|
|
|
6720
7750
|
|
|
6721
7751
|
// src/cli/run.ts
|
|
6722
7752
|
import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
|
|
6723
|
-
import { readdirSync as readdirSync4, readFileSync as
|
|
7753
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync16, realpathSync } from "node:fs";
|
|
6724
7754
|
import { resolve as resolve4 } from "node:path";
|
|
6725
7755
|
var out = (s) => process.stdout.write(s + "\n");
|
|
6726
7756
|
var err = (s) => process.stderr.write(s + "\n");
|
|
@@ -6729,7 +7759,7 @@ function git3(args, cwd) {
|
|
|
6729
7759
|
}
|
|
6730
7760
|
function nowTicks() {
|
|
6731
7761
|
try {
|
|
6732
|
-
return Math.floor(parseFloat(
|
|
7762
|
+
return Math.floor(parseFloat(readFileSync16("/proc/uptime", "utf8").split(" ")[0]) * 100);
|
|
6733
7763
|
} catch {
|
|
6734
7764
|
return Number.POSITIVE_INFINITY;
|
|
6735
7765
|
}
|
|
@@ -6759,7 +7789,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
6759
7789
|
}
|
|
6760
7790
|
if (held !== real && !held.startsWith(real + "/")) continue;
|
|
6761
7791
|
try {
|
|
6762
|
-
const stat =
|
|
7792
|
+
const stat = readFileSync16(`/proc/${pid}/stat`, "utf8");
|
|
6763
7793
|
const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
|
|
6764
7794
|
if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
|
|
6765
7795
|
} catch {
|