tamperward 2.8.0 → 2.10.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 +441 -156
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -1450,8 +1450,8 @@ function numericOf(e) {
|
|
|
1450
1450
|
}
|
|
1451
1451
|
return void 0;
|
|
1452
1452
|
}
|
|
1453
|
-
function metricsOf(expr,
|
|
1454
|
-
const obj =
|
|
1453
|
+
function metricsOf(expr, resolve6) {
|
|
1454
|
+
const obj = resolve6(expr);
|
|
1455
1455
|
if (!ts2.isObjectLiteralExpression(obj)) return void 0;
|
|
1456
1456
|
const out2 = { values: {}, opaque: false };
|
|
1457
1457
|
let any = false;
|
|
@@ -1465,7 +1465,7 @@ function metricsOf(expr, resolve5) {
|
|
|
1465
1465
|
const k = keyName(p.name);
|
|
1466
1466
|
if (k === null || !isMetric(k)) continue;
|
|
1467
1467
|
any = true;
|
|
1468
|
-
const n = numericOf(
|
|
1468
|
+
const n = numericOf(resolve6(p.initializer));
|
|
1469
1469
|
if (n === void 0) out2.opaque = true;
|
|
1470
1470
|
else out2.values[k] = n;
|
|
1471
1471
|
}
|
|
@@ -1511,17 +1511,17 @@ function parseThresholds(src) {
|
|
|
1511
1511
|
const res = { paths: /* @__PURE__ */ new Map(), present: false, opaque: false };
|
|
1512
1512
|
try {
|
|
1513
1513
|
const sf = sourceOf(src);
|
|
1514
|
-
const
|
|
1514
|
+
const resolve6 = resolverFor(sf);
|
|
1515
1515
|
const visit = (node) => {
|
|
1516
1516
|
if (ts2.isPropertyAssignment(node)) {
|
|
1517
1517
|
const key2 = keyName(node.name);
|
|
1518
1518
|
if (key2 === "coverageThreshold" || key2 === "thresholds" && underKey(node, "coverage")) {
|
|
1519
1519
|
res.present = true;
|
|
1520
|
-
const init =
|
|
1520
|
+
const init = resolve6(node.initializer);
|
|
1521
1521
|
if (!ts2.isObjectLiteralExpression(init)) {
|
|
1522
1522
|
res.opaque = true;
|
|
1523
1523
|
} else {
|
|
1524
|
-
const flat = metricsOf(init,
|
|
1524
|
+
const flat = metricsOf(init, resolve6);
|
|
1525
1525
|
if (flat) res.global = merge(res.global, flat);
|
|
1526
1526
|
for (const p of init.properties) {
|
|
1527
1527
|
if (ts2.isSpreadAssignment(p)) {
|
|
@@ -1535,7 +1535,7 @@ function parseThresholds(src) {
|
|
|
1535
1535
|
res.global = merge(res.global, { values: { branches: 100, functions: 100, lines: 100, statements: 100 }, opaque: false });
|
|
1536
1536
|
continue;
|
|
1537
1537
|
}
|
|
1538
|
-
const m = metricsOf(p.initializer,
|
|
1538
|
+
const m = metricsOf(p.initializer, resolve6);
|
|
1539
1539
|
if (!m) continue;
|
|
1540
1540
|
if (k === "global") res.global = merge(res.global, m);
|
|
1541
1541
|
else res.paths.set(norm(k), m);
|
|
@@ -1568,18 +1568,18 @@ function parseLists(src) {
|
|
|
1568
1568
|
const res = { collectFrom: null, collectKey: "collectCoverageFrom", ignore: [], opaqueCollect: false, opaqueIgnore: false };
|
|
1569
1569
|
try {
|
|
1570
1570
|
const sf = sourceOf(src);
|
|
1571
|
-
const
|
|
1571
|
+
const resolve6 = resolverFor(sf);
|
|
1572
1572
|
const visit = (node) => {
|
|
1573
1573
|
if (ts2.isPropertyAssignment(node)) {
|
|
1574
1574
|
const k = keyName(node.name);
|
|
1575
1575
|
const vitestCoverage = ownerKey(node) === "coverage";
|
|
1576
1576
|
if (k === "collectCoverageFrom" || k === "include" && vitestCoverage) {
|
|
1577
|
-
const { items, opaque } = literals(
|
|
1577
|
+
const { items, opaque } = literals(resolve6(node.initializer));
|
|
1578
1578
|
res.collectFrom = items;
|
|
1579
1579
|
res.collectKey = k === "include" ? "coverage.include" : k;
|
|
1580
1580
|
if (opaque) res.opaqueCollect = true;
|
|
1581
1581
|
} else if (k === "coveragePathIgnorePatterns" || k === "exclude" && vitestCoverage) {
|
|
1582
|
-
const { items, opaque } = literals(
|
|
1582
|
+
const { items, opaque } = literals(resolve6(node.initializer));
|
|
1583
1583
|
res.ignore.push(...items);
|
|
1584
1584
|
if (opaque) res.opaqueIgnore = true;
|
|
1585
1585
|
}
|
|
@@ -2660,12 +2660,13 @@ var ciTampering = {
|
|
|
2660
2660
|
};
|
|
2661
2661
|
|
|
2662
2662
|
// src/detectors/hook-tampering.ts
|
|
2663
|
-
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
2664
|
-
import { isAbsolute, join as join3, relative } from "node:path";
|
|
2663
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
2664
|
+
import { isAbsolute as isAbsolute3, join as join3, relative } from "node:path";
|
|
2665
2665
|
|
|
2666
2666
|
// src/wiring.ts
|
|
2667
|
-
import { readFileSync } from "node:fs";
|
|
2668
|
-
import {
|
|
2667
|
+
import { readFileSync, realpathSync as realpathSync2 } from "node:fs";
|
|
2668
|
+
import { homedir } from "node:os";
|
|
2669
|
+
import { basename, dirname, isAbsolute, join as join2, posix } from "node:path";
|
|
2669
2670
|
import { fileURLToPath } from "node:url";
|
|
2670
2671
|
function shippedVersion() {
|
|
2671
2672
|
try {
|
|
@@ -2689,7 +2690,7 @@ var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
|
|
|
2689
2690
|
var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
|
|
2690
2691
|
var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
|
|
2691
2692
|
var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
|
|
2692
|
-
var PLAIN_SEMVER =
|
|
2693
|
+
var PLAIN_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
|
|
2693
2694
|
function compareVersions(a, b) {
|
|
2694
2695
|
if (!PLAIN_SEMVER.test(a) || !PLAIN_SEMVER.test(b)) return null;
|
|
2695
2696
|
const pa = a.split(".").map(Number);
|
|
@@ -2702,13 +2703,46 @@ function pinNotBelow(now, floor) {
|
|
|
2702
2703
|
const c = compareVersions(now, floor);
|
|
2703
2704
|
return c !== null && c >= 0;
|
|
2704
2705
|
}
|
|
2705
|
-
var INIT_SCRIPT = /^#!\/bin\/sh\n# tamperward: block agent shortcuts before they land\nnpx --yes tamperward(?:@(\S+))? check --staged\n
|
|
2706
|
+
var INIT_SCRIPT = /^#!\/bin\/sh\n# tamperward: block agent shortcuts before they land\nnpx --yes tamperward(?:@(\S+))? check --staged\n*$/;
|
|
2706
2707
|
function initScriptPin(src) {
|
|
2707
2708
|
const m = src.match(INIT_SCRIPT);
|
|
2708
2709
|
return m ? m[1] ?? "" : null;
|
|
2709
2710
|
}
|
|
2711
|
+
function homeDir(env = process.env) {
|
|
2712
|
+
const h = (env.HOME ?? env.USERPROFILE ?? "").trim() || homedir();
|
|
2713
|
+
return h.replace(/\\/g, "/").replace(/(?<=.)\/+$/, "");
|
|
2714
|
+
}
|
|
2715
|
+
function claudeConfigDir(env = process.env) {
|
|
2716
|
+
const dir = (env.CLAUDE_CONFIG_DIR ?? "").trim().replace(/\\/g, "/").replace(/(?<=.)\/+$/, "");
|
|
2717
|
+
return dir || `${homeDir(env)}/.claude`;
|
|
2718
|
+
}
|
|
2710
2719
|
var CLAUDE_SETTINGS = /(?:^|\/)(?:\.claude\/settings(?:\.local)?\.json|managed-settings\.json)$/;
|
|
2711
|
-
|
|
2720
|
+
function isClaudeSettings(path, env = process.env) {
|
|
2721
|
+
const p = path.replace(/\\/g, "/");
|
|
2722
|
+
if (CLAUDE_SETTINGS.test(p)) return true;
|
|
2723
|
+
const dir = (env.CLAUDE_CONFIG_DIR ?? "").trim().replace(/\\/g, "/").replace(/(?<=.)\/+$/, "");
|
|
2724
|
+
return dir !== "" && (p === `${dir}/settings.json` || p === `${dir}/settings.local.json`);
|
|
2725
|
+
}
|
|
2726
|
+
function canonicalPath(path) {
|
|
2727
|
+
const tail = [];
|
|
2728
|
+
let cur = path;
|
|
2729
|
+
for (let i = 0; i < 256; i++) {
|
|
2730
|
+
try {
|
|
2731
|
+
const real = realpathSync2(cur).replace(/\\/g, "/");
|
|
2732
|
+
return tail.length ? posix.join(real, ...tail.reverse()) : real;
|
|
2733
|
+
} catch {
|
|
2734
|
+
}
|
|
2735
|
+
const parent = dirname(cur);
|
|
2736
|
+
if (parent === cur) break;
|
|
2737
|
+
tail.push(basename(cur));
|
|
2738
|
+
cur = parent;
|
|
2739
|
+
}
|
|
2740
|
+
return path;
|
|
2741
|
+
}
|
|
2742
|
+
function resolvesToClaudeSettings(path, env = process.env) {
|
|
2743
|
+
if (isClaudeSettings(path, env)) return true;
|
|
2744
|
+
return isAbsolute(path) && isClaudeSettings(canonicalPath(path), env);
|
|
2745
|
+
}
|
|
2712
2746
|
|
|
2713
2747
|
// src/detectors/policy-diff.ts
|
|
2714
2748
|
import { parse } from "yaml";
|
|
@@ -2835,6 +2869,8 @@ function policyAddWeakening(after) {
|
|
|
2835
2869
|
}
|
|
2836
2870
|
|
|
2837
2871
|
// src/detectors/hook-wiring.ts
|
|
2872
|
+
import { existsSync } from "node:fs";
|
|
2873
|
+
import { isAbsolute as isAbsolute2, resolve } from "node:path";
|
|
2838
2874
|
import { parse as parseYaml } from "yaml";
|
|
2839
2875
|
function stripComment(line) {
|
|
2840
2876
|
let single = false;
|
|
@@ -2853,6 +2889,8 @@ var TOOL = /^(tamperward|jest|vitest|eslint|tsc|lint-staged|lefthook|husky|pytes
|
|
|
2853
2889
|
var SCRIPT = /^(test|tests|lint|typecheck|type-check|coverage|check)\b/;
|
|
2854
2890
|
var SUBCOMMAND_TOOLS = /* @__PURE__ */ new Set(["tamperward", "make", "cargo", "go", "lefthook", "husky", "pre-commit"]);
|
|
2855
2891
|
var PREFIX = /^(?:(?:then|do|else|\{|\(|exec|command|time|nice|env|sudo|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"(?=\s|$)|'[^']*'(?=\s|$)|[^\s"']\S*|(?=\s)))\s+)*/;
|
|
2892
|
+
var MODE = /\s(--staged|--worktree|--diff(?:[=\s]+[^\s-][^\s;)}|&]*)?)(?=[\s;)}|&]|$)/;
|
|
2893
|
+
var CWD = /\s--cwd(?:=|\s+)("[^"]*"|'[^']*'|\S+)/;
|
|
2856
2894
|
function checkIdentity(segment) {
|
|
2857
2895
|
const core = segment.trim().replace(PREFIX, "").replace(/\s+/g, " ");
|
|
2858
2896
|
const viaRunner = RUNNER.test(core);
|
|
@@ -2863,8 +2901,10 @@ function checkIdentity(segment) {
|
|
|
2863
2901
|
if (tool === "tamperward") {
|
|
2864
2902
|
const sub = toks[1];
|
|
2865
2903
|
if (!sub || sub.startsWith("-")) return null;
|
|
2866
|
-
const
|
|
2867
|
-
|
|
2904
|
+
const mode = rest.match(MODE)?.[1].replace(/[=\s]+/, " ");
|
|
2905
|
+
const cwd = rest.match(CWD)?.[1];
|
|
2906
|
+
const away = cwd !== void 0 && !/rev-parse\s+--show-toplevel/.test(cwd) ? `--cwd ${cwd}` : void 0;
|
|
2907
|
+
return ["tamperward", sub, mode, away].filter((x) => !!x).join(" ");
|
|
2868
2908
|
}
|
|
2869
2909
|
if (SUBCOMMAND_TOOLS.has(tool) && toks[1] && !toks[1].startsWith("-")) return `${tool} ${toks[1]}`;
|
|
2870
2910
|
return tool;
|
|
@@ -2900,7 +2940,8 @@ function split(stmt) {
|
|
|
2900
2940
|
continue;
|
|
2901
2941
|
}
|
|
2902
2942
|
if (depth > 0) continue;
|
|
2903
|
-
const
|
|
2943
|
+
const bg = ch === "&" && stmt[i + 1] !== "&" && stmt[i + 1] !== ">" && !/[<>]$/.test(stmt.slice(0, i));
|
|
2944
|
+
const op = stmt.startsWith("&&", i) ? "&&" : stmt.startsWith("||", i) ? "||" : ch === ";" ? ";" : ch === "|" ? "|" : bg ? "&" : null;
|
|
2904
2945
|
if (!op) continue;
|
|
2905
2946
|
segs.push(stmt.slice(last, i));
|
|
2906
2947
|
ops.push(op);
|
|
@@ -3129,7 +3170,10 @@ function prepare(lines, opts) {
|
|
|
3129
3170
|
}
|
|
3130
3171
|
return { top, fns, shadowed, pipefail, errexit };
|
|
3131
3172
|
}
|
|
3132
|
-
var
|
|
3173
|
+
var SENSITIVE_ENV = /^(?:PATH|NODE_OPTIONS|NODE_PATH|HOME|LD_PRELOAD|BASH_ENV|ENV|npm_config_\w*|NPM_CONFIG_\w*)$/;
|
|
3174
|
+
var assignedNames = (prefix) => [...prefix.matchAll(/(?:^|\s)([A-Za-z_]\w*)=(?:"[^"]*"|'[^']*'|[^\s"']\S*|(?=\s))/g)].map((m) => m[1]);
|
|
3175
|
+
var WAIT_PID = /^wait\s+"?\$!"?\s*;?$/;
|
|
3176
|
+
var TRANSPARENT = /^(?:fi|done|esac|\}|\)|;;|then|do|else|exit\s+"?\$\??"?|exit\s+"?\$\{\?\}"?|wait\s+"?\$!"?)\s*;?$/;
|
|
3133
3177
|
function statusAfterFailure(seg, vars) {
|
|
3134
3178
|
const inner = seg.trim().replace(/^[{(]\s*/, "").replace(/\s*[})]\s*;?\s*$/, "");
|
|
3135
3179
|
let status = "fail";
|
|
@@ -3178,7 +3222,7 @@ function reaches(segs, ops, i) {
|
|
|
3178
3222
|
if (!runs) continue;
|
|
3179
3223
|
const s = constStatus(segs[j]);
|
|
3180
3224
|
if (s === "unknown" && (ops[j] === "&&" || ops[j] === "||")) certain = false;
|
|
3181
|
-
if (ops[j] === ";" || ops[j] === "|") {
|
|
3225
|
+
if (ops[j] === ";" || ops[j] === "|" || ops[j] === "&") {
|
|
3182
3226
|
status = "ok";
|
|
3183
3227
|
certain = certain && true;
|
|
3184
3228
|
continue;
|
|
@@ -3187,7 +3231,7 @@ function reaches(segs, ops, i) {
|
|
|
3187
3231
|
}
|
|
3188
3232
|
const gate = i === 0 ? true : ops[i - 1] === "&&" ? status !== "fail" : ops[i - 1] === "||" ? status !== "ok" : true;
|
|
3189
3233
|
if (!gate) return "no";
|
|
3190
|
-
if (i === 0 || ops[i - 1] === ";" || ops[i - 1] === "|") return "yes";
|
|
3234
|
+
if (i === 0 || ops[i - 1] === ";" || ops[i - 1] === "|" || ops[i - 1] === "&") return "yes";
|
|
3191
3235
|
if (!certain || status === "unknown") return "maybe";
|
|
3192
3236
|
return "yes";
|
|
3193
3237
|
}
|
|
@@ -3278,15 +3322,18 @@ function walk(lines, st, topIdx, depth) {
|
|
|
3278
3322
|
}
|
|
3279
3323
|
if (/^;;$/.test(stmt)) continue;
|
|
3280
3324
|
if (/^[{}()]$/.test(stmt)) continue;
|
|
3281
|
-
const
|
|
3282
|
-
if (
|
|
3325
|
+
const bgLine = /(?<![&<>])&\s*$/.test(stmt) && !/&&\s*$/.test(stmt);
|
|
3326
|
+
if (bgLine) stmt = stmt.replace(/\s*&\s*$/, "");
|
|
3327
|
+
const bg = bgLine && !WAIT_PID.test(stripComment(lines[i + 1] ?? "").trim());
|
|
3283
3328
|
const { segs, ops } = split(stmt);
|
|
3284
3329
|
for (let s = 0; s < segs.length; s++) {
|
|
3285
3330
|
let seg = segs[s].trim();
|
|
3286
3331
|
const inverted = /^!\s+/.test(seg);
|
|
3287
3332
|
seg = seg.replace(/^!\s+/, "");
|
|
3288
3333
|
const execs = /^exec\s+/.test(seg) && !/^exec\s+(?:\d*[<>]|&>)/.test(seg);
|
|
3334
|
+
const whole = seg;
|
|
3289
3335
|
seg = seg.replace(PREFIX, "");
|
|
3336
|
+
const prefix = whole.slice(0, whole.length - seg.length);
|
|
3290
3337
|
const reach = dead() ? "no" : reaches(segs, ops, s);
|
|
3291
3338
|
const first = seg.split(/\s+/)[0] ?? "";
|
|
3292
3339
|
if (reach !== "no") {
|
|
@@ -3313,11 +3360,12 @@ function walk(lines, st, topIdx, depth) {
|
|
|
3313
3360
|
const sigs = trap[4].split(/\s+/);
|
|
3314
3361
|
if (sigs.some((x) => /^(?:EXIT|ERR|0|SIGERR)$/i.test(x)) && /(?:^|[;\s])(?:exit(?:\s+0+)?|true|:)\s*(?:;|$)/.test(action)) st.traps.push(idx);
|
|
3315
3362
|
}
|
|
3316
|
-
const asg = seg.match(/^(?:export\s+|readonly\s+)?([A-Za-z_]\w*)=(.*)$/);
|
|
3363
|
+
const asg = seg.match(/^(?:export\s+|readonly\s+|declare\s+-\w+\s+|local\s+)?([A-Za-z_]\w*)=(.*)$/);
|
|
3317
3364
|
if (asg) {
|
|
3318
3365
|
const v = certain ? literal(asg[2], st.vars) : null;
|
|
3319
3366
|
if (v === null) st.vars.delete(asg[1]);
|
|
3320
3367
|
else st.vars.set(asg[1], v);
|
|
3368
|
+
if (SENSITIVE_ENV.test(asg[1])) st.redirected = true;
|
|
3321
3369
|
}
|
|
3322
3370
|
if (first === "cd") {
|
|
3323
3371
|
const home = cdStaysHome(seg.slice(2), st.vars);
|
|
@@ -3344,6 +3392,7 @@ function walk(lines, st, topIdx, depth) {
|
|
|
3344
3392
|
st.errexit = sub.errexit;
|
|
3345
3393
|
st.pipefail = sub.pipefail;
|
|
3346
3394
|
st.cdAway = sub.cdAway;
|
|
3395
|
+
st.redirected = sub.redirected;
|
|
3347
3396
|
const verdict2 = chainVerdict(segs, ops, s, st, inverted, bg);
|
|
3348
3397
|
for (let k = start; k < st.out.length; k++) {
|
|
3349
3398
|
const inv = st.out[k];
|
|
@@ -3388,8 +3437,18 @@ function walk(lines, st, topIdx, depth) {
|
|
|
3388
3437
|
state = "neutered";
|
|
3389
3438
|
why = "runs from another directory after a `cd` away from the repository root";
|
|
3390
3439
|
}
|
|
3440
|
+
if (state === "live" && /^tamperward\b/.test(id)) {
|
|
3441
|
+
const onGate = assignedNames(prefix).filter((n) => SENSITIVE_ENV.test(n));
|
|
3442
|
+
if (onGate.length) {
|
|
3443
|
+
state = "neutered";
|
|
3444
|
+
why = `runtime redirected \u2014 \`${onGate[0]}=\` in front of it decides what the gate resolves to`;
|
|
3445
|
+
} else if (st.redirected) {
|
|
3446
|
+
state = "neutered";
|
|
3447
|
+
why = "runtime redirected \u2014 PATH, NODE_OPTIONS or another variable the gate resolves through is set earlier in the script";
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3391
3450
|
if (state === "live") {
|
|
3392
|
-
const words2 = [first, "tamperward"].filter((w) => w && st.shadowed.has(w));
|
|
3451
|
+
const words2 = [...prefix.split(/\s+/), first, "tamperward"].filter((w) => w && st.shadowed.has(w));
|
|
3393
3452
|
if (words2.length) {
|
|
3394
3453
|
state = "neutered";
|
|
3395
3454
|
why = `\`${words2[0]}\` is redefined by a function or alias in this script`;
|
|
@@ -3406,6 +3465,7 @@ function chainVerdict(segs, ops, i, st, inverted, bg) {
|
|
|
3406
3465
|
if (inverted) return { state: "neutered", why: "its status is inverted by `!`", passthrough: false };
|
|
3407
3466
|
if (bg) return { state: "neutered", why: "run in the background \u2014 the hook does not wait for its status", passthrough: false };
|
|
3408
3467
|
let cur = "fail";
|
|
3468
|
+
let waited = -1;
|
|
3409
3469
|
for (let j = i; j < ops.length; j++) {
|
|
3410
3470
|
const op = ops[j];
|
|
3411
3471
|
const next = segs[j + 1];
|
|
@@ -3413,6 +3473,13 @@ function chainVerdict(segs, ops, i, st, inverted, bg) {
|
|
|
3413
3473
|
if (!st.pipefail) return { state: "neutered", why: "piped into another command without `pipefail`", passthrough: false };
|
|
3414
3474
|
continue;
|
|
3415
3475
|
}
|
|
3476
|
+
if (op === "&") {
|
|
3477
|
+
if (WAIT_PID.test((next ?? "").trim())) {
|
|
3478
|
+
waited = j + 1;
|
|
3479
|
+
continue;
|
|
3480
|
+
}
|
|
3481
|
+
return { state: "neutered", why: "run in the background \u2014 a `wait` without its pid returns 0, and without `wait` the hook does not wait for its status", passthrough: false };
|
|
3482
|
+
}
|
|
3416
3483
|
if (cur === "exit") break;
|
|
3417
3484
|
if (op === "||") {
|
|
3418
3485
|
if (cur === "fail") cur = statusAfterFailure(next, st.vars);
|
|
@@ -3430,7 +3497,7 @@ function chainVerdict(segs, ops, i, st, inverted, bg) {
|
|
|
3430
3497
|
}
|
|
3431
3498
|
if (cur === "ok") return { state: "neutered", why: "the `||` chain after it ends in success", passthrough: false };
|
|
3432
3499
|
if (cur === "exit") return { state: "live", passthrough: false };
|
|
3433
|
-
return { state: "live", passthrough: i === ops.length || ops.slice(i).every((o) => o === "&&" || o === "||") };
|
|
3500
|
+
return { state: "live", passthrough: i === ops.length || ops.slice(i).every((o, k) => o === "&&" || o === "||" || o === "&" && i + k + 1 === waited) };
|
|
3434
3501
|
}
|
|
3435
3502
|
function invocations(lines, opts = {}) {
|
|
3436
3503
|
const prep = prepare(lines, opts);
|
|
@@ -3438,6 +3505,7 @@ function invocations(lines, opts = {}) {
|
|
|
3438
3505
|
errexit: prep.errexit,
|
|
3439
3506
|
pipefail: prep.pipefail,
|
|
3440
3507
|
cdAway: false,
|
|
3508
|
+
redirected: false,
|
|
3441
3509
|
vars: /* @__PURE__ */ new Map(),
|
|
3442
3510
|
fns: prep.fns,
|
|
3443
3511
|
shadowed: prep.shadowed,
|
|
@@ -3471,6 +3539,27 @@ var strip = (inv) => {
|
|
|
3471
3539
|
const { idx: _i, errexit: _e, passthrough: _p, ...rest } = inv;
|
|
3472
3540
|
return rest;
|
|
3473
3541
|
};
|
|
3542
|
+
var baseIdentity = (id) => id.replace(/\s--.*$/, "");
|
|
3543
|
+
function pinRaiseOnly(before, after) {
|
|
3544
|
+
const mask = (src) => {
|
|
3545
|
+
const pins = [];
|
|
3546
|
+
const text = src.replace(/\n+$/, "").replace(/\btamperward(?:@(\d+\.\d+\.\d+))?(?=\s|$)/g, (_m, v) => {
|
|
3547
|
+
pins.push(v ?? "");
|
|
3548
|
+
return "tamperward@\0";
|
|
3549
|
+
});
|
|
3550
|
+
return { text, pins };
|
|
3551
|
+
};
|
|
3552
|
+
const b = mask(before);
|
|
3553
|
+
const a = mask(after);
|
|
3554
|
+
if (b.text !== a.text || b.pins.length !== a.pins.length) return false;
|
|
3555
|
+
return a.pins.every((now, i) => {
|
|
3556
|
+
const was = b.pins[i];
|
|
3557
|
+
if (now === was) return true;
|
|
3558
|
+
if (now === "") return false;
|
|
3559
|
+
if (was !== "" && (compareVersions(now, was) ?? -1) < 0) return false;
|
|
3560
|
+
return !PLAIN_SEMVER.test(TW_VERSION) || (compareVersions(now, TW_VERSION) ?? 1) <= 0;
|
|
3561
|
+
});
|
|
3562
|
+
}
|
|
3474
3563
|
function scriptWeakening(before, after, opts = {}) {
|
|
3475
3564
|
const beforeInv = invocations(before, opts);
|
|
3476
3565
|
const afterInv = invocations(after, opts);
|
|
@@ -3480,7 +3569,7 @@ function scriptWeakening(before, after, opts = {}) {
|
|
|
3480
3569
|
const out2 = [];
|
|
3481
3570
|
for (const id of bl) {
|
|
3482
3571
|
if (al.has(id)) continue;
|
|
3483
|
-
const trace = afterInv.find((i) => i.identity === id) ?? afterInv.find((i) => i.identity
|
|
3572
|
+
const trace = afterInv.find((i) => i.identity === id) ?? afterInv.find((i) => baseIdentity(i.identity) === baseIdentity(id));
|
|
3484
3573
|
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";
|
|
3485
3574
|
const evidence = (trace?.line ?? before.find((l) => checkIdentity(stripComment(l).trim().replace(/^!\s+/, "").replace(PREFIX, "")) === id) ?? id).trim();
|
|
3486
3575
|
out2.push({ reason: `the check invocation \`${id}\` was ${how}`, evidence });
|
|
@@ -3488,8 +3577,9 @@ function scriptWeakening(before, after, opts = {}) {
|
|
|
3488
3577
|
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];
|
|
3489
3578
|
if (floor) {
|
|
3490
3579
|
for (const inv of afterInv) {
|
|
3491
|
-
if (inv.state !== "live" || !inv.pin
|
|
3492
|
-
if ((
|
|
3580
|
+
if (inv.state !== "live" || !inv.pin) continue;
|
|
3581
|
+
if (!PLAIN_SEMVER.test(inv.pin)) out2.push({ reason: `the gate's pin was changed from ${floor} to \`${inv.pin}\`, which is not a plain version \u2014 a range, tag or pre-release resolves to whatever the registry serves`, evidence: inv.line.trim() });
|
|
3582
|
+
else 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() });
|
|
3493
3583
|
}
|
|
3494
3584
|
}
|
|
3495
3585
|
return out2;
|
|
@@ -3595,8 +3685,22 @@ var isPackageJson = (path) => /(?:^|\/)package\.json$/.test(path);
|
|
|
3595
3685
|
var GATE = /\btamperward\b/;
|
|
3596
3686
|
var str = (v) => typeof v === "string" ? v : Array.isArray(v) ? v.map(String).join(" ") : v == null ? "" : JSON.stringify(v);
|
|
3597
3687
|
var list = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" ? v.split(/[\s,]+/).filter(Boolean) : [];
|
|
3598
|
-
function
|
|
3599
|
-
|
|
3688
|
+
function gateOf(command) {
|
|
3689
|
+
const live = invocations([command], { errexit: true }).find((i) => i.state === "live" && /^tamperward check\b/.test(i.identity));
|
|
3690
|
+
return live ? { identity: live.identity, pin: live.pin ?? "" } : null;
|
|
3691
|
+
}
|
|
3692
|
+
var redirectingEnv = (env) => isDoc(env) ? Object.keys(env).filter((k) => SENSITIVE_ENV.test(k)).sort().map((k) => `${k}=${str(env[k])}`) : [];
|
|
3693
|
+
function gateDrift(what, was, now) {
|
|
3694
|
+
const out2 = [];
|
|
3695
|
+
if (was.gate && now.gate) {
|
|
3696
|
+
if (now.gate.identity !== was.gate.identity) out2.push(`${what} now runs \`${now.gate.identity}\` instead of \`${was.gate.identity}\` \u2014 another check`);
|
|
3697
|
+
if (PLAIN_SEMVER.test(was.gate.pin) && now.gate.pin) {
|
|
3698
|
+
if (!PLAIN_SEMVER.test(now.gate.pin)) out2.push(`${what} pins the gate to \`${now.gate.pin}\`, which is not a plain version (was ${was.gate.pin}) \u2014 a range, tag or pre-release resolves to whatever the registry serves`);
|
|
3699
|
+
else if ((compareVersions(now.gate.pin, was.gate.pin) ?? 0) < 0) out2.push(`${what} lowered the gate's pin from ${was.gate.pin} to ${now.gate.pin}`);
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
if (now.env.length && now.env.join(",") !== was.env.join(",")) out2.push(`${what} sets ${now.env.map((e) => e.split("=")[0]).join(", ")} in its \`env\` \u2014 the gate's runtime is redirected`);
|
|
3703
|
+
return out2;
|
|
3600
3704
|
}
|
|
3601
3705
|
function mergeDocs(base, over) {
|
|
3602
3706
|
const out2 = { ...base };
|
|
@@ -3621,8 +3725,11 @@ function lefthookEntries(doc) {
|
|
|
3621
3725
|
const named = GATE.test(name);
|
|
3622
3726
|
if (!GATE.test(str(run)) && !named) continue;
|
|
3623
3727
|
const tags = list(cfg.tags);
|
|
3728
|
+
const gate = run === void 0 ? named ? { identity: "tamperward check --staged", pin: "" } : null : gateOf(str(run));
|
|
3624
3729
|
out2.set(`${section}.${group}.${name}`, {
|
|
3625
|
-
live:
|
|
3730
|
+
live: gate !== null,
|
|
3731
|
+
gate,
|
|
3732
|
+
env: redirectingEnv(cfg.env),
|
|
3626
3733
|
skip: cfg.skip,
|
|
3627
3734
|
only: cfg.only,
|
|
3628
3735
|
sectionSkip: v.skip,
|
|
@@ -3648,6 +3755,7 @@ function lefthookWeakening(before, after) {
|
|
|
3648
3755
|
continue;
|
|
3649
3756
|
}
|
|
3650
3757
|
if (!now.live) out2.push(`lefthook entry ${key2} no longer runs \`tamperward check\` live`);
|
|
3758
|
+
out2.push(...gateDrift(`lefthook entry ${key2} (the gate)`, was, now));
|
|
3651
3759
|
if (!off(now.skip) && off(was.skip)) out2.push(`lefthook entry ${key2} (the gate) is now \`skip: ${str(now.skip)}\``);
|
|
3652
3760
|
if (!off(now.sectionSkip) && off(was.sectionSkip)) out2.push(`the lefthook hook carrying ${key2} (the gate) is now \`skip: ${str(now.sectionSkip)}\``);
|
|
3653
3761
|
if (!off(now.only) && str(now.only) !== str(was.only)) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`only\` (${str(now.only)})`);
|
|
@@ -3672,9 +3780,12 @@ function preCommitEntries(doc) {
|
|
|
3672
3780
|
for (const h of hooks) {
|
|
3673
3781
|
if (!isDoc(h)) continue;
|
|
3674
3782
|
if (!GATE.test(`${str(repo.repo)} ${str(h.id)} ${str(h.entry)} ${str(h.name)}`)) continue;
|
|
3675
|
-
const
|
|
3783
|
+
const gate = h.entry !== void 0 ? gateOf(`${str(h.entry)} ${list(h.args).join(" ")}`.trim()) : GATE.test(`${str(repo.repo)} ${str(h.id)}`) ? { identity: "tamperward check --staged", pin: "" } : null;
|
|
3676
3784
|
out2.set(`${str(repo.repo)}:${str(h.id)}`, {
|
|
3677
|
-
live,
|
|
3785
|
+
live: gate !== null,
|
|
3786
|
+
gate,
|
|
3787
|
+
env: redirectingEnv(h.env),
|
|
3788
|
+
deps: str(h.additional_dependencies),
|
|
3678
3789
|
stages: Array.isArray(h.stages) ? h.stages.map(String).map(normStage) : defaults,
|
|
3679
3790
|
explicitStages: Array.isArray(h.stages),
|
|
3680
3791
|
exclude: str(h.exclude),
|
|
@@ -3698,6 +3809,8 @@ function preCommitWeakening(before, after) {
|
|
|
3698
3809
|
continue;
|
|
3699
3810
|
}
|
|
3700
3811
|
if (!now.live) out2.push(`pre-commit hook ${key2} no longer runs \`tamperward check\` live`);
|
|
3812
|
+
out2.push(...gateDrift(`pre-commit hook ${key2} (the gate)`, was, now));
|
|
3813
|
+
if (now.deps && now.deps !== was.deps) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`additional_dependencies\` (${now.deps}) \u2014 they choose which gate the hook installs`);
|
|
3701
3814
|
const lost = was.stages.filter((s) => GATE_STAGES.has(s) && !now.stages.includes(s));
|
|
3702
3815
|
if (lost.length && !now.stages.some((s) => GATE_STAGES.has(s))) {
|
|
3703
3816
|
out2.push(`pre-commit hook ${key2} (the gate) now runs only at stages [${now.stages.join(", ")}] \u2014 no commit or push triggers it`);
|
|
@@ -3825,7 +3938,8 @@ var WRAPPERS = /^(?:sudo|command|exec|time|nice|env|[A-Za-z_][A-Za-z0-9_]*=\S*)$
|
|
|
3825
3938
|
var INTERPRETERS = /* @__PURE__ */ new Set(["python", "python2", "python3", "node", "perl", "ruby", "php", "deno", "bun"]);
|
|
3826
3939
|
var INLINE_FLAG = /^-(?:c|e|r|E|p|pe|pi|i|ne|ni|le)$|^--eval$|^--print$|^--exec$/;
|
|
3827
3940
|
var EDITORS = /* @__PURE__ */ new Set(["ex", "ed", "vim", "vi", "nvim"]);
|
|
3828
|
-
var
|
|
3941
|
+
var SCRIPT_WORD = /[\w.~/$%{}\\-]+/g;
|
|
3942
|
+
var HOOK_BASENAMES = ["pre-commit", "pre-push", "commit-msg", "settings.json", "settings.local.json", "managed-settings.json", ".tamperward.yml", "lefthook.yml", "lefthook.yaml", "lefthook-local.yml", ".pre-commit-config.yaml", "CODEOWNERS"];
|
|
3829
3943
|
function nameGlob(pattern) {
|
|
3830
3944
|
const re = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
|
|
3831
3945
|
return (n) => re.test(n);
|
|
@@ -3864,24 +3978,56 @@ function destinations(cmd, args, isDir) {
|
|
|
3864
3978
|
return [dest];
|
|
3865
3979
|
}
|
|
3866
3980
|
var REDIRECT_TARGET = /(?:^|\s)(?:\d*>{1,2}\|?|&>{1,2})\s*(\S+)/g;
|
|
3981
|
+
function expandShellPath(t, env = process.env) {
|
|
3982
|
+
const home = homeDir(env);
|
|
3983
|
+
return t.replace(/\\/g, "/").replace(/^~(?=\/|$)/, home).replace(/\$(?:\{HOME\}|HOME\b|ENV\{HOME\})/g, home).replace(/%USERPROFILE%/gi, home).replace(/\$(?:\{CLAUDE_CONFIG_DIR\}|CLAUDE_CONFIG_DIR\b)/g, claudeConfigDir(env));
|
|
3984
|
+
}
|
|
3867
3985
|
function hookTests(policy, ctx) {
|
|
3986
|
+
const cwd = ctx?.cwd;
|
|
3987
|
+
const onDisk = cwd !== void 0 && existsSync(cwd);
|
|
3988
|
+
const root = onDisk ? canonicalPath(cwd) : cwd;
|
|
3989
|
+
const memo = /* @__PURE__ */ new Map();
|
|
3868
3990
|
const rel = (t) => {
|
|
3869
|
-
|
|
3870
|
-
if (
|
|
3871
|
-
|
|
3991
|
+
const hit = memo.get(t);
|
|
3992
|
+
if (hit !== void 0) return hit;
|
|
3993
|
+
let p = expandShellPath(t.replace(/^["']|["']$/g, ""));
|
|
3994
|
+
const pathy = isAbsolute2(p) || p.includes("/") && !/^[-:]/.test(p);
|
|
3995
|
+
if (pathy && (isAbsolute2(p) || onDisk)) {
|
|
3996
|
+
p = canonicalPath(isAbsolute2(p) ? p : resolve(cwd, p));
|
|
3997
|
+
if (root !== void 0) {
|
|
3998
|
+
if (p === root) p = ".";
|
|
3999
|
+
else if (p.startsWith(root + "/")) p = p.slice(root.length + 1);
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
if (cwd !== void 0 && p.startsWith(cwd + "/")) p = p.slice(cwd.length + 1);
|
|
4003
|
+
p = p.replace(/^\.\/(?=.)/, "");
|
|
4004
|
+
memo.set(t, p);
|
|
4005
|
+
return p;
|
|
3872
4006
|
};
|
|
3873
|
-
const hook = (t) =>
|
|
3874
|
-
|
|
3875
|
-
|
|
4007
|
+
const hook = (t) => {
|
|
4008
|
+
const p = rel(t);
|
|
4009
|
+
return isProtected(p, policy, "hooks") || isClaudeSettings(p) || isClaudeSettings(expandShellPath(t.replace(/^["']|["']$/g, "")));
|
|
4010
|
+
};
|
|
4011
|
+
const whole = (t) => /^(?:\.|\.\/|:\/|\*)$/.test(rel(t)) || cwd !== void 0 && rel(t) === cwd;
|
|
4012
|
+
const userSettings = `${claudeConfigDir()}/settings.json`;
|
|
4013
|
+
const userSettingsUnder = (t) => {
|
|
4014
|
+
const dir = rel(t).replace(/\/+$/, "");
|
|
4015
|
+
return dir !== "" && dir !== "/" && userSettings.startsWith(dir + "/") ? userSettings : null;
|
|
4016
|
+
};
|
|
4017
|
+
const holds = (t) => !t.startsWith("-") && (whole(t) || userSettingsUnder(t) !== null || containsProtected(rel(t), policy, "hooks", ctx));
|
|
3876
4018
|
const hookish = (t) => hook(t) || holds(t);
|
|
3877
|
-
return { rel, hook, whole, holds, hookish };
|
|
4019
|
+
return { rel, hook, whole, holds, hookish, userSettingsUnder };
|
|
3878
4020
|
}
|
|
3879
4021
|
function shellHookTarget(seg, toks, policy, ctx) {
|
|
3880
|
-
const { rel, hook, whole, holds } = hookTests(policy, ctx);
|
|
4022
|
+
const { rel, hook, whole, holds, userSettingsUnder } = hookTests(policy, ctx);
|
|
3881
4023
|
for (const m of seg.matchAll(REDIRECT_TARGET)) {
|
|
3882
4024
|
if (hook(m[1])) return rel(m[1]);
|
|
3883
4025
|
}
|
|
3884
4026
|
const args = toks.map((t) => t.replace(/^(?:of=|--(?:include|exclude|directory)=)/, ""));
|
|
4027
|
+
for (let i = 1; i < args.length; i++) {
|
|
4028
|
+
if (!INLINE_FLAG.test(args[i - 1])) continue;
|
|
4029
|
+
for (const word of args[i].match(SCRIPT_WORD) ?? []) if (hook(word)) return rel(word);
|
|
4030
|
+
}
|
|
3885
4031
|
const fileLike = (p) => {
|
|
3886
4032
|
const b = p.replace(/\/+$/, "").split("/").pop() ?? "";
|
|
3887
4033
|
return HOOK_BASENAMES.includes(b) || /\.[A-Za-z0-9]+$/.test(b.replace(/^\.+/, "")) || (trackedFiles(ctx)?.includes(p) ?? false);
|
|
@@ -3889,6 +4035,8 @@ function shellHookTarget(seg, toks, policy, ctx) {
|
|
|
3889
4035
|
for (const t of args) if (hook(t) && fileLike(rel(t))) return rel(t);
|
|
3890
4036
|
for (const t of args) {
|
|
3891
4037
|
if (t.startsWith("-") || !holds(t)) continue;
|
|
4038
|
+
const user = userSettingsUnder(t);
|
|
4039
|
+
if (user !== null) return user;
|
|
3892
4040
|
const dir = rel(t).replace(/\/+$/, "");
|
|
3893
4041
|
const under = whole(t) ? "" : dir + "/";
|
|
3894
4042
|
const listed = trackedFiles(ctx)?.find((f) => f.startsWith(under) && isProtected(f, policy, "hooks"));
|
|
@@ -3912,7 +4060,7 @@ function shellWritesHook(seg, toks, policy, ctx) {
|
|
|
3912
4060
|
const anyHook = args.some(hook);
|
|
3913
4061
|
const inPlace = args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a));
|
|
3914
4062
|
const scripts = args.filter((_, i) => i > 0 && INLINE_FLAG.test(args[i - 1]));
|
|
3915
|
-
const namesHookInText = () => scripts.some((a) => (a.match(
|
|
4063
|
+
const namesHookInText = () => scripts.some((a) => (a.match(SCRIPT_WORD) ?? []).some(hook));
|
|
3916
4064
|
switch (cmd) {
|
|
3917
4065
|
case "rm":
|
|
3918
4066
|
case "unlink":
|
|
@@ -3996,8 +4144,8 @@ function shellWritesHook(seg, toks, policy, ctx) {
|
|
|
3996
4144
|
function xargsWritesHook(toks, fed, policy, ctx) {
|
|
3997
4145
|
const at = toks.findIndex((t) => t === "xargs" || t.endsWith("/xargs"));
|
|
3998
4146
|
if (at === -1) return null;
|
|
3999
|
-
const
|
|
4000
|
-
const fedHook = fed.some(
|
|
4147
|
+
const { hookish } = hookTests(policy, ctx);
|
|
4148
|
+
const fedHook = fed.some(hookish);
|
|
4001
4149
|
if (!fedHook) return null;
|
|
4002
4150
|
let i = at + 1;
|
|
4003
4151
|
while (i < toks.length && toks[i].startsWith("-")) i += /^-(?:I|n|L|P|d|a|s)$/.test(toks[i]) ? 2 : 1;
|
|
@@ -4029,11 +4177,23 @@ function eventEntries(s, event) {
|
|
|
4029
4177
|
return Array.isArray(arr) ? arr : null;
|
|
4030
4178
|
}
|
|
4031
4179
|
var declares = (s, event) => eventEntries(s, event) !== null;
|
|
4032
|
-
var GATE_KEYS = /* @__PURE__ */ new Set(["type", "command", "timeout"]);
|
|
4180
|
+
var GATE_KEYS = /* @__PURE__ */ new Set(["type", "command", "timeout", "statusMessage"]);
|
|
4181
|
+
var HOOK_TYPES = /* @__PURE__ */ new Set(["command", "prompt", "agent", "http"]);
|
|
4182
|
+
var SIBLING_KEYS = GATE_KEYS;
|
|
4033
4183
|
var CANONICAL_CMD = /^npx --yes tamperward@(\S+) (hook|sweep) claude$/;
|
|
4034
4184
|
var MENTIONS_GATE = /\btamperward\b.*\b(?:hook|sweep)\b/;
|
|
4035
4185
|
var FORGERY = /hookSpecificOutput|permissionDecision|updatedInput|"decision"|"continue"/;
|
|
4036
|
-
var
|
|
4186
|
+
var ENV_MECHANISM = [
|
|
4187
|
+
[/^PATH$/, "the gate's launcher is looked up through it"],
|
|
4188
|
+
[/^(?:NODE_OPTIONS|NODE_PATH)$/, "node loads what it names before the gate runs"],
|
|
4189
|
+
[/^HOME$/, "npx's cache and configuration live under it"],
|
|
4190
|
+
[/^npm_config_/i, "npx reads it as npm configuration, the registry the gate is fetched from included"],
|
|
4191
|
+
[/^(?:BASH_ENV|ENV)$/, "a non-interactive shell sources it before every hook command"],
|
|
4192
|
+
[/^BASH_FUNC_/, "bash imports it as a function that shadows the command it names"],
|
|
4193
|
+
[/^(?:LD|DYLD)_/, "the dynamic loader injects it into every process, the gate included"],
|
|
4194
|
+
[/^TAMPERWARD_/, "it configures the gate itself"]
|
|
4195
|
+
];
|
|
4196
|
+
var envMechanism = (k) => ENV_MECHANISM.find(([re]) => re.test(k))?.[1] ?? null;
|
|
4037
4197
|
function analyseGateCommand(cmd, event) {
|
|
4038
4198
|
const want = event === "PreToolUse" ? "hook" : "sweep";
|
|
4039
4199
|
const problems = [];
|
|
@@ -4092,7 +4252,7 @@ function matcherSelection(m) {
|
|
|
4092
4252
|
if (m === "" || m === "*") return { tools: null, problem: null };
|
|
4093
4253
|
let problem = null;
|
|
4094
4254
|
if (/[^\x00-\x7f]/.test(m)) problem = "the matcher contains non-ASCII characters that look like tool names but are not";
|
|
4095
|
-
if (/^[A-Za-z0-9_
|
|
4255
|
+
if (/^[A-Za-z0-9_ ,|-]+$/.test(m)) return { tools: new Set(m.split(/[|,]/).map((t) => t.trim()).filter(Boolean)), problem };
|
|
4096
4256
|
let re;
|
|
4097
4257
|
try {
|
|
4098
4258
|
re = new RegExp(m);
|
|
@@ -4101,48 +4261,58 @@ function matcherSelection(m) {
|
|
|
4101
4261
|
}
|
|
4102
4262
|
return { tools: new Set(PRE_TOOLS.filter((t) => re.test(t))), problem };
|
|
4103
4263
|
}
|
|
4264
|
+
function entryShape(h) {
|
|
4265
|
+
const problems = [];
|
|
4266
|
+
let runs = true;
|
|
4267
|
+
if (h.type !== "command") {
|
|
4268
|
+
problems.push(h.type === void 0 ? "no `type`" : `\`type\` is ${JSON.stringify(h.type)}`);
|
|
4269
|
+
if (h.type !== void 0) runs = false;
|
|
4270
|
+
}
|
|
4271
|
+
for (const k of Object.keys(h)) {
|
|
4272
|
+
if (GATE_KEYS.has(k)) continue;
|
|
4273
|
+
problems.push(`carries \`${k}\``);
|
|
4274
|
+
runs = false;
|
|
4275
|
+
}
|
|
4276
|
+
if ("timeout" in h) {
|
|
4277
|
+
const t = h.timeout;
|
|
4278
|
+
if (typeof t !== "number" || !Number.isInteger(t) || t < 120) {
|
|
4279
|
+
problems.push(`\`timeout\` ${JSON.stringify(t)} \u2014 the gate is cut off before it answers`);
|
|
4280
|
+
runs = false;
|
|
4281
|
+
}
|
|
4282
|
+
}
|
|
4283
|
+
if ("statusMessage" in h && typeof h.statusMessage !== "string") {
|
|
4284
|
+
problems.push(`\`statusMessage\` ${JSON.stringify(h.statusMessage)} is not a string`);
|
|
4285
|
+
runs = false;
|
|
4286
|
+
}
|
|
4287
|
+
return { problems, runs };
|
|
4288
|
+
}
|
|
4289
|
+
var mentionsGate = (v) => /\btamperward\b/.test(JSON.stringify(v) ?? "");
|
|
4104
4290
|
function gateCandidates(s, event) {
|
|
4105
4291
|
const out2 = [];
|
|
4106
|
-
const mentions = (v) => /\btamperward\b/.test(JSON.stringify(v) ?? "");
|
|
4107
4292
|
for (const group of eventEntries(s, event) ?? []) {
|
|
4108
4293
|
if (!isObj(group)) {
|
|
4109
|
-
if (
|
|
4294
|
+
if (mentionsGate(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["the matcher entry is not an object"], pin: null, tools: null });
|
|
4110
4295
|
continue;
|
|
4111
4296
|
}
|
|
4112
4297
|
if (!Array.isArray(group.hooks)) {
|
|
4113
|
-
if (
|
|
4298
|
+
if (mentionsGate(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["`hooks` is not a list"], pin: null, tools: null });
|
|
4114
4299
|
continue;
|
|
4115
4300
|
}
|
|
4116
4301
|
for (const h of group.hooks) {
|
|
4117
4302
|
if (!isObj(h)) {
|
|
4118
|
-
if (
|
|
4303
|
+
if (mentionsGate(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the hook entry is not an object"], pin: null, tools: null });
|
|
4119
4304
|
continue;
|
|
4120
4305
|
}
|
|
4121
4306
|
const cmd = typeof h.command === "string" ? h.command : null;
|
|
4122
4307
|
if (cmd === null) {
|
|
4123
|
-
if (
|
|
4308
|
+
if (mentionsGate(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the gate is not in a `command` string"], pin: null, tools: null });
|
|
4124
4309
|
continue;
|
|
4125
4310
|
}
|
|
4126
4311
|
if (!MENTIONS_GATE.test(cmd)) continue;
|
|
4127
4312
|
const a = analyseGateCommand(cmd, event);
|
|
4128
|
-
const
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
problems.push(h.type === void 0 ? "no `type`" : `\`type\` is ${JSON.stringify(h.type)}`);
|
|
4132
|
-
if (h.type !== void 0) runs = false;
|
|
4133
|
-
}
|
|
4134
|
-
for (const k of Object.keys(h)) {
|
|
4135
|
-
if (GATE_KEYS.has(k)) continue;
|
|
4136
|
-
problems.push(`carries \`${k}\``);
|
|
4137
|
-
runs = false;
|
|
4138
|
-
}
|
|
4139
|
-
if ("timeout" in h) {
|
|
4140
|
-
const t = h.timeout;
|
|
4141
|
-
if (typeof t !== "number" || !Number.isInteger(t) || t < 120) {
|
|
4142
|
-
problems.push(`\`timeout\` ${JSON.stringify(t)} \u2014 the gate is cut off before it answers`);
|
|
4143
|
-
runs = false;
|
|
4144
|
-
}
|
|
4145
|
-
}
|
|
4313
|
+
const shape = entryShape(h);
|
|
4314
|
+
const problems = [...a.problems, ...shape.problems];
|
|
4315
|
+
let runs = a.runs && shape.runs;
|
|
4146
4316
|
let tools = null;
|
|
4147
4317
|
if (event === "PreToolUse") {
|
|
4148
4318
|
const sel = matcherSelection(group.matcher);
|
|
@@ -4159,6 +4329,7 @@ function gateCandidates(s, event) {
|
|
|
4159
4329
|
}
|
|
4160
4330
|
return out2;
|
|
4161
4331
|
}
|
|
4332
|
+
var carriesGate = (s) => EVENTS.some((e) => gateCandidates(s, e).length > 0);
|
|
4162
4333
|
function otherCommands(s, event) {
|
|
4163
4334
|
const out2 = [];
|
|
4164
4335
|
for (const group of eventEntries(s, event) ?? []) {
|
|
@@ -4170,6 +4341,35 @@ function otherCommands(s, event) {
|
|
|
4170
4341
|
}
|
|
4171
4342
|
return out2;
|
|
4172
4343
|
}
|
|
4344
|
+
function allEntries(s) {
|
|
4345
|
+
const hooks = s.hooks;
|
|
4346
|
+
if (!isObj(hooks)) return [];
|
|
4347
|
+
const out2 = [];
|
|
4348
|
+
for (const [event, arr] of Object.entries(hooks)) {
|
|
4349
|
+
if (!Array.isArray(arr)) continue;
|
|
4350
|
+
for (const group of arr) {
|
|
4351
|
+
if (!isObj(group) || !Array.isArray(group.hooks)) continue;
|
|
4352
|
+
for (const h of group.hooks) out2.push({ event, key: JSON.stringify({ e: event, m: group.matcher, h }), group, h });
|
|
4353
|
+
}
|
|
4354
|
+
}
|
|
4355
|
+
return out2;
|
|
4356
|
+
}
|
|
4357
|
+
function schemaProblem(group, h) {
|
|
4358
|
+
if (group.matcher !== void 0 && typeof group.matcher !== "string") return `the matcher ${JSON.stringify(group.matcher)} is not a string`;
|
|
4359
|
+
if (!isObj(h)) return `the hook entry ${JSON.stringify(h)} is not an object`;
|
|
4360
|
+
if (typeof h.type !== "string" || !HOOK_TYPES.has(h.type)) return `\`type\` ${JSON.stringify(h.type)} is not one the runtime accepts`;
|
|
4361
|
+
if (h.type === "command" && typeof h.command !== "string") return "`command` is not a string";
|
|
4362
|
+
if ("timeout" in h && (typeof h.timeout !== "number" || !Number.isFinite(h.timeout) || h.timeout <= 0)) return `\`timeout\` ${JSON.stringify(h.timeout)} is not a positive number`;
|
|
4363
|
+
return null;
|
|
4364
|
+
}
|
|
4365
|
+
function siblingProblem(h) {
|
|
4366
|
+
const cmd = typeof h.command === "string" ? h.command : "";
|
|
4367
|
+
if (FORGERY.test(cmd)) return `emits a hook decision of its own (\`${cmd.slice(0, 60)}\`)`;
|
|
4368
|
+
if (h.type !== "command") return `is not a plain command entry (\`type\` is ${JSON.stringify(h.type)})`;
|
|
4369
|
+
const extra = Object.keys(h).filter((k) => !SIBLING_KEYS.has(k));
|
|
4370
|
+
if (extra.length) return `is not a plain command entry (carries \`${extra.join("`, `")}\`)`;
|
|
4371
|
+
return `was added (\`${cmd.slice(0, 60)}\`) \u2014 its output combines with the gate's verdict (an \`updatedInput\` after an allow, a \`continue: false\` over a block), and init writes no entry beside the gate`;
|
|
4372
|
+
}
|
|
4173
4373
|
function coverage(cs) {
|
|
4174
4374
|
const live = cs.filter((c) => c.runs);
|
|
4175
4375
|
if (live.length === 0) return "none";
|
|
@@ -4220,16 +4420,26 @@ function duplicateJsonKeys(src) {
|
|
|
4220
4420
|
}
|
|
4221
4421
|
function settingsWeakening(before, after, local) {
|
|
4222
4422
|
const reasons = [];
|
|
4423
|
+
const gated = carriesGate(after);
|
|
4424
|
+
const gatedBefore = before !== null && carriesGate(before);
|
|
4223
4425
|
const dis = after.disableAllHooks;
|
|
4224
4426
|
if (dis !== void 0 && dis !== false && !(before !== null && before.disableAllHooks === dis)) {
|
|
4225
4427
|
reasons.push(`\`disableAllHooks: ${JSON.stringify(dis)}\` switches every hook off, the gate included`);
|
|
4226
4428
|
}
|
|
4429
|
+
if (gated && dis === void 0) {
|
|
4430
|
+
if (before !== null && before.disableAllHooks === false) {
|
|
4431
|
+
reasons.push("`disableAllHooks: false` was removed \u2014 init declares it so that a `true` in the user settings file cannot switch the gate off");
|
|
4432
|
+
} else if (!gatedBefore) {
|
|
4433
|
+
reasons.push("`disableAllHooks: false` is not declared \u2014 init declares it in the file it writes, so that a `true` in the user settings file cannot switch the gate off");
|
|
4434
|
+
}
|
|
4435
|
+
}
|
|
4227
4436
|
if (isObj(after.env)) {
|
|
4228
4437
|
const was = before !== null && isObj(before.env) ? before.env : {};
|
|
4229
4438
|
for (const [k, v] of Object.entries(after.env)) {
|
|
4230
|
-
if (
|
|
4231
|
-
|
|
4232
|
-
}
|
|
4439
|
+
if (k in was && JSON.stringify(was[k]) === JSON.stringify(v)) continue;
|
|
4440
|
+
const how = envMechanism(k);
|
|
4441
|
+
if (how) reasons.push(`a top-level \`env\` sets ${k} \u2014 ${how}`);
|
|
4442
|
+
else if (gated) reasons.push(`a top-level \`env\` sets ${k} \u2014 every hook command runs with it, and init writes no \`env\``);
|
|
4233
4443
|
}
|
|
4234
4444
|
}
|
|
4235
4445
|
for (const event of EVENTS) {
|
|
@@ -4257,15 +4467,36 @@ function settingsWeakening(before, after, local) {
|
|
|
4257
4467
|
if (c.problems.length) reasons.push(`the ${event} gate entry does not match the shape \`tamperward init\` writes: ${c.problems.join("; ")}`);
|
|
4258
4468
|
}
|
|
4259
4469
|
const pins = bc.map((c) => c.pin).filter((p) => !!p && PLAIN_SEMVER.test(p)).sort((a, b) => compareVersions(b, a) ?? 0);
|
|
4260
|
-
const
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4470
|
+
const judging = PLAIN_SEMVER.test(TW_VERSION) ? TW_VERSION : null;
|
|
4471
|
+
const floor = pins[0] ?? (bc.length === 0 ? judging : null);
|
|
4472
|
+
for (const c of fresh) {
|
|
4473
|
+
if (c.pin === null || c.pin !== "" && !PLAIN_SEMVER.test(c.pin)) continue;
|
|
4474
|
+
if (floor !== null && !pinNotBelow(c.pin, floor)) {
|
|
4475
|
+
reasons.push(
|
|
4476
|
+
c.pin === "" ? `the ${event} gate is unpinned, below ${floor} \u2014 \`npx\` then runs whatever \`tamperward\` resolves to on this machine` : `the ${event} gate is pinned to tamperward@${c.pin}, below ${floor}`
|
|
4477
|
+
);
|
|
4478
|
+
} else if (judging !== null && c.pin !== "" && (compareVersions(c.pin, judging) ?? 0) > 0) {
|
|
4479
|
+
reasons.push(`the ${event} gate is pinned to tamperward@${c.pin}, above the ${judging} judging it \u2014 a pin npm cannot resolve fails the hook open; raise it by running \`tamperward init\` from the newer version, which raises the judging gate with it`);
|
|
4480
|
+
}
|
|
4481
|
+
}
|
|
4482
|
+
}
|
|
4483
|
+
if (gated) {
|
|
4484
|
+
const had = new Set(before !== null ? allEntries(before).map((e) => e.key) : []);
|
|
4485
|
+
for (const e of allEntries(after)) {
|
|
4486
|
+
if (had.has(e.key) || mentionsGate(e.h)) continue;
|
|
4487
|
+
const schema = schemaProblem(e.group, e.h);
|
|
4488
|
+
if (schema) {
|
|
4489
|
+
reasons.push(`a ${e.event} hook entry carries a value the runtime's schema rejects (${schema}) \u2014 the runtime continues without the broken file, the gate included`);
|
|
4490
|
+
} else if (e.event === "PreToolUse" || e.event === "Stop") {
|
|
4491
|
+
reasons.push(`a ${e.event} hook entry beside the gate ${siblingProblem(e.h)}`);
|
|
4264
4492
|
}
|
|
4265
4493
|
}
|
|
4266
|
-
|
|
4267
|
-
for (const
|
|
4268
|
-
|
|
4494
|
+
} else {
|
|
4495
|
+
for (const event of EVENTS) {
|
|
4496
|
+
const hadOther = new Set(before !== null ? otherCommands(before, event) : []);
|
|
4497
|
+
for (const cmd of otherCommands(after, event)) {
|
|
4498
|
+
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)}\`)`);
|
|
4499
|
+
}
|
|
4269
4500
|
}
|
|
4270
4501
|
}
|
|
4271
4502
|
return reasons;
|
|
@@ -4278,7 +4509,7 @@ function claudeSettingsFindings(c, policy) {
|
|
|
4278
4509
|
file: c.path,
|
|
4279
4510
|
message: `The Claude Code hook wiring was weakened: ${reason}.`,
|
|
4280
4511
|
evidence: reason,
|
|
4281
|
-
remediation: "Leave the PreToolUse and Stop entries that run tamperward,
|
|
4512
|
+
remediation: "Leave the PreToolUse and Stop entries that run tamperward, the matcher that covers every editing tool, and `disableAllHooks: false` exactly as `tamperward init` wrote them, with no `env` and no hook entry beside the gate. Editing the wiring is editing the gate: re-run `tamperward init` to restore it, or sign off."
|
|
4282
4513
|
});
|
|
4283
4514
|
if (c.before != null && before === null) return null;
|
|
4284
4515
|
if (after === null) {
|
|
@@ -4321,7 +4552,7 @@ function lefthookBase(c, changes, ctx) {
|
|
|
4321
4552
|
for (const n of names) {
|
|
4322
4553
|
try {
|
|
4323
4554
|
const p = join3(ctx.cwd, n);
|
|
4324
|
-
if (
|
|
4555
|
+
if (existsSync2(p)) return parseDoc(readFileSync2(p, "utf8"));
|
|
4325
4556
|
} catch {
|
|
4326
4557
|
}
|
|
4327
4558
|
}
|
|
@@ -4391,11 +4622,12 @@ function updateIndexHiding(toks, policy) {
|
|
|
4391
4622
|
return null;
|
|
4392
4623
|
}
|
|
4393
4624
|
function repoRelative(path, ctx) {
|
|
4394
|
-
if (!ctx?.cwd || !
|
|
4625
|
+
if (!ctx?.cwd || !isAbsolute3(path)) return path;
|
|
4395
4626
|
const rel = relative(ctx.cwd, path);
|
|
4396
|
-
return rel && !rel.startsWith("..") && !
|
|
4627
|
+
return rel && !rel.startsWith("..") && !isAbsolute3(rel) ? rel : path;
|
|
4397
4628
|
}
|
|
4398
4629
|
var isHuskyScript = (path) => /(?:^|\/)\.husky\/[^/]+$/.test(path);
|
|
4630
|
+
var isHookNote = (path) => /\.(?:md|markdown|txt)$|(?:^|\/)\.git(?:ignore|attributes)$/i.test(path);
|
|
4399
4631
|
var hookTampering = {
|
|
4400
4632
|
id: RULE6,
|
|
4401
4633
|
surface: ["file", "command"],
|
|
@@ -4405,7 +4637,7 @@ var hookTampering = {
|
|
|
4405
4637
|
for (const c of changes) {
|
|
4406
4638
|
let semantic;
|
|
4407
4639
|
if (c.kind === "file") {
|
|
4408
|
-
const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false) ||
|
|
4640
|
+
const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false) || resolvesToClaudeSettings(c.path);
|
|
4409
4641
|
if (!targetsHook) {
|
|
4410
4642
|
if (isPackageJson(c.path) && c.before != null && c.after != null) {
|
|
4411
4643
|
for (const reason of packageJsonWeakening(c.before, c.after)) {
|
|
@@ -4449,7 +4681,7 @@ var hookTampering = {
|
|
|
4449
4681
|
remediation: "Renaming a hook out of place disables it. Restore the original path."
|
|
4450
4682
|
})
|
|
4451
4683
|
);
|
|
4452
|
-
} else if ((c.path.endsWith(POLICY_FILE) ||
|
|
4684
|
+
} else if ((c.path.endsWith(POLICY_FILE) || resolvesToClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c, policy)) !== null) {
|
|
4453
4685
|
out2.push(...semantic);
|
|
4454
4686
|
} else if (c.op === "rename") {
|
|
4455
4687
|
} else if (isCodeowners(c.path)) {
|
|
@@ -4469,6 +4701,7 @@ var hookTampering = {
|
|
|
4469
4701
|
out2.push(...semantic);
|
|
4470
4702
|
} else if (!c.path.endsWith(POLICY_FILE)) {
|
|
4471
4703
|
const husky = isHuskyScript(c.path);
|
|
4704
|
+
const opts = husky ? { errexit: true } : {};
|
|
4472
4705
|
const beforePin = c.before != null && c.after != null ? initScriptPin(c.before) : null;
|
|
4473
4706
|
if (beforePin !== null) {
|
|
4474
4707
|
const afterPin = initScriptPin(c.after);
|
|
@@ -4485,22 +4718,71 @@ var hookTampering = {
|
|
|
4485
4718
|
}
|
|
4486
4719
|
continue;
|
|
4487
4720
|
}
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4721
|
+
const added = addedLines(c);
|
|
4722
|
+
const removed = removedLines(c);
|
|
4723
|
+
const beforeLines = c.before != null ? c.before.split("\n") : removed.map((l) => l.content);
|
|
4724
|
+
const afterLines = c.after != null ? c.after.split("\n") : added.map((l) => l.content);
|
|
4725
|
+
const shebang = !husky && c.after != null ? shebangProblem(c.after) : null;
|
|
4726
|
+
const shebangChanged = shebang !== null && (c.before == null || shebang !== shebangProblem(c.before));
|
|
4727
|
+
const earlyExit = added.find((l) => insertsPassingExit(l.content));
|
|
4728
|
+
if (c.op === "add") {
|
|
4729
|
+
const gate = invocations(afterLines, opts).filter((i) => /^tamperward check\b/.test(i.identity));
|
|
4730
|
+
const live = gate.find((i) => i.state === "live");
|
|
4731
|
+
if (shebang || !live) {
|
|
4732
|
+
const why = shebang ?? (gate.length ? `its \`${gate[0].identity}\` is ${gate[0].state}${gate[0].why ? ` (${gate[0].why})` : ""}` : "it runs no `tamperward check`");
|
|
4491
4733
|
out2.push(
|
|
4492
4734
|
makeFinding(RULE6, policy, {
|
|
4493
4735
|
file: c.path,
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
|
|
4736
|
+
message: `A protected hook script was added that does not run the gate live: ${why}. A new gate script needs a sign-off unless the gate is live in it.`,
|
|
4737
|
+
evidence: `a hook script was added; sign off with \`tamperward allow hook-tampering --file ${c.path} --reason "..."\` locally or the \`tamperward:allow:hook-tampering@<head-sha>\` label in CI, or make the gate live in it`,
|
|
4738
|
+
remediation: "Run `tamperward check --staged` in the new hook, in a position where its failure fails the hook, or have a human sign off on a hook that does not."
|
|
4739
|
+
})
|
|
4740
|
+
);
|
|
4741
|
+
} else if (earlyExit) {
|
|
4742
|
+
out2.push(
|
|
4743
|
+
makeFinding(RULE6, policy, {
|
|
4744
|
+
file: c.path,
|
|
4745
|
+
line: earlyExit.newLine ?? void 0,
|
|
4746
|
+
message: "An early `exit 0` was inserted into a protected hook \u2014 it now passes everything.",
|
|
4747
|
+
evidence: earlyExit.content.trim(),
|
|
4748
|
+
remediation: "Remove the early exit. A hook that always succeeds is a disabled hook."
|
|
4498
4749
|
})
|
|
4499
4750
|
);
|
|
4500
4751
|
}
|
|
4752
|
+
continue;
|
|
4753
|
+
}
|
|
4754
|
+
if (!isHookNote(c.path)) {
|
|
4755
|
+
const clean = c.before != null && c.after != null ? pinRaiseOnly(c.before, c.after) : pinRaiseOnly(removed.map((l) => l.content).join("\n"), added.map((l) => l.content).join("\n"));
|
|
4756
|
+
if (!clean) {
|
|
4757
|
+
const deadGuard2 = c.before == null || c.after == null ? added.find((l) => insertsDeadGuard(l.content)) : void 0;
|
|
4758
|
+
const detail = [
|
|
4759
|
+
shebangChanged ? `the interpreter changed: ${shebang}` : null,
|
|
4760
|
+
...scriptWeakening(beforeLines, afterLines, opts).map((l) => l.reason),
|
|
4761
|
+
earlyExit ? `an early \`exit 0\` was inserted (\`${earlyExit.content.trim()}\`)` : null,
|
|
4762
|
+
deadGuard2 ? `a guard that can never be true was inserted (\`${deadGuard2.content.trim()}\`)` : null
|
|
4763
|
+
].filter((d) => d !== null);
|
|
4764
|
+
out2.push(
|
|
4765
|
+
makeFinding(RULE6, policy, {
|
|
4766
|
+
file: c.path,
|
|
4767
|
+
message: `A hand-written protected hook script was changed: ${detail.length ? detail.join("; ") : "an edit other than a pin raise"}. Every edit to a gate script other than raising its pin needs a sign-off.`,
|
|
4768
|
+
evidence: `the gate script changed; sign off with \`tamperward allow hook-tampering --file ${c.path} --reason "..."\` locally or the \`tamperward:allow:hook-tampering@<head-sha>\` label in CI, or restore it`,
|
|
4769
|
+
remediation: "Restore the script, or have a human sign off. A line-by-line reading of a shell script cannot tell an honest restructuring from a neutered gate, so the gate script is held byte-for-byte: only raising its pin passes without a sign-off."
|
|
4770
|
+
})
|
|
4771
|
+
);
|
|
4772
|
+
}
|
|
4773
|
+
continue;
|
|
4774
|
+
}
|
|
4775
|
+
if (shebangChanged && c.after != null) {
|
|
4776
|
+
out2.push(
|
|
4777
|
+
makeFinding(RULE6, policy, {
|
|
4778
|
+
file: c.path,
|
|
4779
|
+
line: 1,
|
|
4780
|
+
message: `A protected hook's interpreter was changed: ${shebang}.`,
|
|
4781
|
+
evidence: c.after.split("\n")[0],
|
|
4782
|
+
remediation: "Keep a shell in shebang position. git execs the hook directly, and an interpreter that never reads the script is a disabled hook."
|
|
4783
|
+
})
|
|
4784
|
+
);
|
|
4501
4785
|
}
|
|
4502
|
-
const added = addedLines(c);
|
|
4503
|
-
const earlyExit = added.find((l) => insertsPassingExit(l.content));
|
|
4504
4786
|
if (earlyExit) {
|
|
4505
4787
|
out2.push(
|
|
4506
4788
|
makeFinding(RULE6, policy, {
|
|
@@ -4524,9 +4806,7 @@ var hookTampering = {
|
|
|
4524
4806
|
})
|
|
4525
4807
|
);
|
|
4526
4808
|
}
|
|
4527
|
-
const
|
|
4528
|
-
const afterLines = c.after != null ? c.after.split("\n") : added.map((l) => l.content);
|
|
4529
|
-
const lost = scriptWeakening(beforeLines, afterLines, husky ? { errexit: true } : {});
|
|
4809
|
+
const lost = scriptWeakening(beforeLines, afterLines, opts);
|
|
4530
4810
|
if (lost.length) {
|
|
4531
4811
|
out2.push(
|
|
4532
4812
|
makeFinding(RULE6, policy, {
|
|
@@ -4589,10 +4869,12 @@ var hookTampering = {
|
|
|
4589
4869
|
const why = shellWritesHook(seg, toks, policy, ctx) ?? xargsWritesHook(toks, fed, policy, ctx);
|
|
4590
4870
|
if (why) {
|
|
4591
4871
|
const target = shellHookTarget(seg, toks, policy, ctx) ?? shellHookTarget("", fed, policy, ctx);
|
|
4872
|
+
const file = target ? repoRelative(target, ctx) : void 0;
|
|
4873
|
+
const outside = file !== void 0 && (isAbsolute3(file) || /^[A-Za-z]:\//.test(file)) && isClaudeSettings(file) ? ` \u2014 ${file} is a Claude Code settings file the runtime reads hooks from` : "";
|
|
4592
4874
|
out2.push(
|
|
4593
4875
|
makeFinding(RULE6, policy, {
|
|
4594
|
-
...
|
|
4595
|
-
message: `Hook tampering via shell: ${why}.`,
|
|
4876
|
+
...file !== void 0 ? { file } : {},
|
|
4877
|
+
message: `Hook tampering via shell: ${why}${outside}.`,
|
|
4596
4878
|
evidence: seg,
|
|
4597
4879
|
remediation: "Leave the hooks in place. Mutating them from the shell is still tampering."
|
|
4598
4880
|
})
|
|
@@ -5497,7 +5779,7 @@ var GUARDED_RULE = "hook-tampering";
|
|
|
5497
5779
|
var BASELINE_HOOKS = defaultPolicy().protected.hooks;
|
|
5498
5780
|
function isGuardedFinding(f) {
|
|
5499
5781
|
if (f.rule !== GUARDED_RULE || !f.file) return false;
|
|
5500
|
-
return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS) ||
|
|
5782
|
+
return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS) || resolvesToClaudeSettings(f.file);
|
|
5501
5783
|
}
|
|
5502
5784
|
function key(f) {
|
|
5503
5785
|
return `${f.rule}|${f.file ?? ""}|${f.line ?? ""}|${f.evidence}`;
|
|
@@ -5556,8 +5838,8 @@ function hasBlocking(findings) {
|
|
|
5556
5838
|
}
|
|
5557
5839
|
|
|
5558
5840
|
// src/policy-load.ts
|
|
5559
|
-
import { readFileSync as readFileSync3, existsSync as
|
|
5560
|
-
import { isAbsolute as
|
|
5841
|
+
import { readFileSync as readFileSync3, existsSync as existsSync3 } from "node:fs";
|
|
5842
|
+
import { isAbsolute as isAbsolute4, join as join4, posix as posix2 } from "node:path";
|
|
5561
5843
|
import { parse as parse2 } from "yaml";
|
|
5562
5844
|
var PolicyError = class extends Error {
|
|
5563
5845
|
};
|
|
@@ -5574,8 +5856,8 @@ var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "strin
|
|
|
5574
5856
|
var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
5575
5857
|
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "protected", "rules", "ignore", "signoff", "verify"]);
|
|
5576
5858
|
function ledgerInsideRepo(ledger) {
|
|
5577
|
-
if (!ledger.trim() ||
|
|
5578
|
-
const norm2 =
|
|
5859
|
+
if (!ledger.trim() || isAbsolute4(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
|
|
5860
|
+
const norm2 = posix2.normalize(ledger.replace(/\\/g, "/"));
|
|
5579
5861
|
return norm2 !== ".." && !norm2.startsWith("../") && !norm2.startsWith("/");
|
|
5580
5862
|
}
|
|
5581
5863
|
function validate(r, where) {
|
|
@@ -5682,7 +5964,7 @@ function parseOrThrow(src, where) {
|
|
|
5682
5964
|
}
|
|
5683
5965
|
function loadPolicy(cwd = process.cwd()) {
|
|
5684
5966
|
const path = join4(cwd, POLICY_FILE);
|
|
5685
|
-
if (!
|
|
5967
|
+
if (!existsSync3(path)) return defaultPolicy();
|
|
5686
5968
|
return parseOrThrow(readFileSync3(path, "utf8"), POLICY_FILE);
|
|
5687
5969
|
}
|
|
5688
5970
|
function loadPolicyAt(rev, cwd) {
|
|
@@ -5693,7 +5975,7 @@ function loadPolicyAt(rev, cwd) {
|
|
|
5693
5975
|
|
|
5694
5976
|
// src/signoff.ts
|
|
5695
5977
|
import { createHash } from "node:crypto";
|
|
5696
|
-
import { appendFileSync, mkdirSync, readFileSync as readFileSync4, existsSync as
|
|
5978
|
+
import { appendFileSync, mkdirSync, readFileSync as readFileSync4, existsSync as existsSync4 } from "node:fs";
|
|
5697
5979
|
import { dirname as dirname2, join as join5 } from "node:path";
|
|
5698
5980
|
var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
5699
5981
|
function fingerprint(rule, file, evidence) {
|
|
@@ -5709,7 +5991,7 @@ function ledgerPath(cwd, policy) {
|
|
|
5709
5991
|
}
|
|
5710
5992
|
function readLedger(cwd, policy) {
|
|
5711
5993
|
const p = ledgerPath(cwd, policy);
|
|
5712
|
-
if (!
|
|
5994
|
+
if (!existsSync4(p)) return [];
|
|
5713
5995
|
const out2 = [];
|
|
5714
5996
|
for (const line of readFileSync4(p, "utf8").split("\n")) {
|
|
5715
5997
|
if (!line.trim()) continue;
|
|
@@ -5771,7 +6053,7 @@ function oobHeadFromEnv(env = process.env) {
|
|
|
5771
6053
|
return v.length >= 7 ? v : void 0;
|
|
5772
6054
|
}
|
|
5773
6055
|
function oobFromEnv(env = process.env) {
|
|
5774
|
-
return (env.TAMPERWARD_OOB_SIGNOFF ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
6056
|
+
return (env.TAMPERWARD_OOB_SIGNOFF ?? "").split(",").map((s) => s.trim().replace(/^tamperward:allow:/, "")).filter(Boolean);
|
|
5775
6057
|
}
|
|
5776
6058
|
|
|
5777
6059
|
// src/cli/render/text.ts
|
|
@@ -6081,14 +6363,14 @@ function runCheck(opts) {
|
|
|
6081
6363
|
}
|
|
6082
6364
|
|
|
6083
6365
|
// src/cli/hook.ts
|
|
6084
|
-
import { readFileSync as readFileSync9, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as
|
|
6366
|
+
import { readFileSync as readFileSync9, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7 } from "node:fs";
|
|
6085
6367
|
import { createHash as createHash4 } from "node:crypto";
|
|
6086
6368
|
|
|
6087
6369
|
// src/adapters/claude/changes.ts
|
|
6088
6370
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
6089
6371
|
import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
6090
6372
|
import { tmpdir } from "node:os";
|
|
6091
|
-
import { isAbsolute as
|
|
6373
|
+
import { isAbsolute as isAbsolute5, join as join6, relative as relative2, resolve as resolve2 } from "node:path";
|
|
6092
6374
|
function asStr(v) {
|
|
6093
6375
|
return typeof v === "string" ? v : "";
|
|
6094
6376
|
}
|
|
@@ -6096,11 +6378,11 @@ function readDisk(path) {
|
|
|
6096
6378
|
return textOf(inspectResolved(path));
|
|
6097
6379
|
}
|
|
6098
6380
|
function abs(path, cwd) {
|
|
6099
|
-
return
|
|
6381
|
+
return resolve2(cwd, path);
|
|
6100
6382
|
}
|
|
6101
6383
|
function relForDisplay(path, cwd) {
|
|
6102
6384
|
const rel = relative2(cwd, path);
|
|
6103
|
-
return rel && !rel.startsWith("..") && !
|
|
6385
|
+
return rel && !rel.startsWith("..") && !isAbsolute5(rel) ? rel : path;
|
|
6104
6386
|
}
|
|
6105
6387
|
function applyEdit(content, oldStr, newStr) {
|
|
6106
6388
|
if (content === null) return newStr;
|
|
@@ -6202,7 +6484,7 @@ function formatDenial(blocks) {
|
|
|
6202
6484
|
}
|
|
6203
6485
|
|
|
6204
6486
|
// src/session.ts
|
|
6205
|
-
import { existsSync as
|
|
6487
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "node:fs";
|
|
6206
6488
|
import { dirname as dirname3, join as join7 } from "node:path";
|
|
6207
6489
|
var UNSAFE = /[^A-Za-z0-9_-]/g;
|
|
6208
6490
|
var SHA = /^[0-9a-f]{7,40}$/;
|
|
@@ -6216,7 +6498,7 @@ function turnBaseline(cwd, sessionId) {
|
|
|
6216
6498
|
try {
|
|
6217
6499
|
const p = baselinePath(cwd, sessionId);
|
|
6218
6500
|
if (!p) return null;
|
|
6219
|
-
if (
|
|
6501
|
+
if (existsSync5(p)) {
|
|
6220
6502
|
const v = readFileSync5(p, "utf8").trim();
|
|
6221
6503
|
if (SHA.test(v)) return v;
|
|
6222
6504
|
}
|
|
@@ -6243,8 +6525,8 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
6243
6525
|
|
|
6244
6526
|
// src/effect.ts
|
|
6245
6527
|
import { createHash as createHash2 } from "node:crypto";
|
|
6246
|
-
import { existsSync as
|
|
6247
|
-
import { isAbsolute as
|
|
6528
|
+
import { existsSync as existsSync6, lstatSync as lstatSync2, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
6529
|
+
import { isAbsolute as isAbsolute6, join as join8 } from "node:path";
|
|
6248
6530
|
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
6249
6531
|
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
6250
6532
|
var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
@@ -6256,7 +6538,7 @@ function gitDir2(cwd) {
|
|
|
6256
6538
|
stdio: ["ignore", "pipe", "pipe"]
|
|
6257
6539
|
}).trim();
|
|
6258
6540
|
if (!d) return null;
|
|
6259
|
-
return
|
|
6541
|
+
return isAbsolute6(d) ? d : join8(cwd, d);
|
|
6260
6542
|
} catch {
|
|
6261
6543
|
return null;
|
|
6262
6544
|
}
|
|
@@ -6335,7 +6617,7 @@ function driftBetween(expected, current) {
|
|
|
6335
6617
|
return { changed, deleted, added };
|
|
6336
6618
|
}
|
|
6337
6619
|
function loadTree(p) {
|
|
6338
|
-
if (!p || !
|
|
6620
|
+
if (!p || !existsSync6(p)) return null;
|
|
6339
6621
|
try {
|
|
6340
6622
|
return JSON.parse(readFileSync6(p, "utf8"));
|
|
6341
6623
|
} catch {
|
|
@@ -6793,9 +7075,9 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
|
|
|
6793
7075
|
} };
|
|
6794
7076
|
const log = defaultEventLog(cwd);
|
|
6795
7077
|
const cp = cursorPath(cwd, sessionId);
|
|
6796
|
-
if (!
|
|
7078
|
+
if (!existsSync7(log)) return none;
|
|
6797
7079
|
let offset = 0;
|
|
6798
|
-
if (cp &&
|
|
7080
|
+
if (cp && existsSync7(cp)) {
|
|
6799
7081
|
const n = Number(readFileSync9(cp, "utf8"));
|
|
6800
7082
|
offset = Number.isFinite(n) ? n : 0;
|
|
6801
7083
|
}
|
|
@@ -6949,8 +7231,8 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
6949
7231
|
// src/cli/init.ts
|
|
6950
7232
|
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
6951
7233
|
import { createHash as createHash5 } from "node:crypto";
|
|
6952
|
-
import { existsSync as
|
|
6953
|
-
import { basename, dirname as dirname4, isAbsolute as
|
|
7234
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync, statSync as statSync3 } from "node:fs";
|
|
7235
|
+
import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute7, join as join10, relative as relative3, resolve as resolve3 } from "node:path";
|
|
6954
7236
|
var errText3 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
|
|
6955
7237
|
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
6956
7238
|
function gitConfig(cwd, key2) {
|
|
@@ -7103,7 +7385,7 @@ function readWorkflowMark(src) {
|
|
|
7103
7385
|
function toolSet(matcher) {
|
|
7104
7386
|
const m = String(matcher ?? "").trim();
|
|
7105
7387
|
if (m === "" || m === "*") return null;
|
|
7106
|
-
return new Set(m.split(
|
|
7388
|
+
return new Set(m.split(/[|,]/).map((t) => t.trim()).filter(Boolean));
|
|
7107
7389
|
}
|
|
7108
7390
|
function missingTools(matcher) {
|
|
7109
7391
|
const have = toolSet(matcher);
|
|
@@ -7127,7 +7409,7 @@ function hooksShapeError(hooks) {
|
|
|
7127
7409
|
}
|
|
7128
7410
|
function planPolicy(cwd) {
|
|
7129
7411
|
const path = join10(cwd, POLICY_FILE);
|
|
7130
|
-
if (
|
|
7412
|
+
if (existsSync8(path)) {
|
|
7131
7413
|
try {
|
|
7132
7414
|
loadPolicy(cwd);
|
|
7133
7415
|
} catch (e) {
|
|
@@ -7147,7 +7429,7 @@ function planClaudeHooks(cwd) {
|
|
|
7147
7429
|
const rel = ".claude/settings.json";
|
|
7148
7430
|
const path = join10(cwd, rel);
|
|
7149
7431
|
let settings = {};
|
|
7150
|
-
if (
|
|
7432
|
+
if (existsSync8(path)) {
|
|
7151
7433
|
let parsed;
|
|
7152
7434
|
try {
|
|
7153
7435
|
parsed = JSON.parse(readFileSync10(path, "utf8"));
|
|
@@ -7176,7 +7458,8 @@ function planClaudeHooks(cwd) {
|
|
|
7176
7458
|
const needStop = stopEntries.length === 0;
|
|
7177
7459
|
const stale = preEntries.filter((m) => missingTools(m.matcher).length > 0);
|
|
7178
7460
|
const repin = [...preEntries, ...stopEntries].flatMap((m) => m.hooks ?? []).filter((h) => stalePin(String(h.command ?? "")));
|
|
7179
|
-
|
|
7461
|
+
const needDisableFalse = settings.disableAllHooks !== false;
|
|
7462
|
+
if (!needPre && !needStop && stale.length === 0 && repin.length === 0 && !needDisableFalse) {
|
|
7180
7463
|
return { item: "agent", path: rel, status: "ok", detail: "PreToolUse + Stop hooks already wired" };
|
|
7181
7464
|
}
|
|
7182
7465
|
const repairing = stale.length > 0 ? [...new Set(stale.flatMap((m) => missingTools(m.matcher)))] : [];
|
|
@@ -7184,12 +7467,13 @@ function planClaudeHooks(cwd) {
|
|
|
7184
7467
|
return {
|
|
7185
7468
|
item: "agent",
|
|
7186
7469
|
path: rel,
|
|
7187
|
-
status:
|
|
7470
|
+
status: existsSync8(path) ? "update" : "create",
|
|
7188
7471
|
detail: [
|
|
7189
7472
|
needPre && "wire PreToolUse deny",
|
|
7190
7473
|
needStop && "wire Stop sweep",
|
|
7191
7474
|
repairing.length > 0 && `widen the PreToolUse matcher to cover ${repairing.join(", ")}`,
|
|
7192
|
-
repin.length > 0 && `re-pin the hook commands to tamperward@${TW_VERSION} (was ${pins.join(", ")})
|
|
7475
|
+
repin.length > 0 && `re-pin the hook commands to tamperward@${TW_VERSION} (was ${pins.join(", ")})`,
|
|
7476
|
+
needDisableFalse && (settings.disableAllHooks === void 0 ? "declare disableAllHooks: false so the user settings file cannot switch the hooks off" : `set disableAllHooks: false (was ${JSON.stringify(settings.disableAllHooks)})`)
|
|
7193
7477
|
].filter(Boolean).join(" + "),
|
|
7194
7478
|
apply: () => {
|
|
7195
7479
|
for (const h of repin) {
|
|
@@ -7208,6 +7492,7 @@ function planClaudeHooks(cwd) {
|
|
|
7208
7492
|
if (needStop) {
|
|
7209
7493
|
(hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
|
|
7210
7494
|
}
|
|
7495
|
+
if (needDisableFalse) settings.disableAllHooks = false;
|
|
7211
7496
|
mkdirSync5(dirname4(path), { recursive: true });
|
|
7212
7497
|
writeFileSync5(path, JSON.stringify(settings, null, 2) + "\n");
|
|
7213
7498
|
}
|
|
@@ -7240,14 +7525,14 @@ var MANAGED_HOOKS = [
|
|
|
7240
7525
|
function hooksDir(cwd) {
|
|
7241
7526
|
try {
|
|
7242
7527
|
const out2 = execFileSync5("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
7243
|
-
if (out2) return
|
|
7528
|
+
if (out2) return resolve3(cwd, out2);
|
|
7244
7529
|
} catch {
|
|
7245
7530
|
}
|
|
7246
|
-
return
|
|
7531
|
+
return existsSync8(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
|
|
7247
7532
|
}
|
|
7248
7533
|
function display(cwd, path) {
|
|
7249
7534
|
const rel = relative3(cwd, path);
|
|
7250
|
-
return rel && !rel.startsWith("..") && !
|
|
7535
|
+
return rel && !rel.startsWith("..") && !isAbsolute7(rel) ? rel : path;
|
|
7251
7536
|
}
|
|
7252
7537
|
function unconditionalExitAt(lines) {
|
|
7253
7538
|
let depth = 0;
|
|
@@ -7270,17 +7555,17 @@ function planPreCommit(cwd) {
|
|
|
7270
7555
|
const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
|
|
7271
7556
|
let path;
|
|
7272
7557
|
let note;
|
|
7273
|
-
if (
|
|
7558
|
+
if (existsSync8(join10(cwd, ".husky"))) {
|
|
7274
7559
|
path = join10(cwd, ".husky", "pre-commit");
|
|
7275
7560
|
note = "husky";
|
|
7276
7561
|
} else {
|
|
7277
|
-
const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) =>
|
|
7562
|
+
const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join10(cwd, c))));
|
|
7278
7563
|
if (managed) {
|
|
7279
7564
|
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}`);
|
|
7280
7565
|
}
|
|
7281
7566
|
const dir = hooksDir(cwd);
|
|
7282
7567
|
if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
|
|
7283
|
-
if (
|
|
7568
|
+
if (basename2(dir) === "_" && basename2(dirname4(dir)) === ".husky") {
|
|
7284
7569
|
path = join10(dirname4(dir), "pre-commit");
|
|
7285
7570
|
note = "husky";
|
|
7286
7571
|
} else {
|
|
@@ -7290,10 +7575,10 @@ function planPreCommit(cwd) {
|
|
|
7290
7575
|
}
|
|
7291
7576
|
}
|
|
7292
7577
|
const rel = display(cwd, path);
|
|
7293
|
-
if (
|
|
7578
|
+
if (existsSync8(path) && !statSync3(path).isFile()) {
|
|
7294
7579
|
return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
|
|
7295
7580
|
}
|
|
7296
|
-
const existing =
|
|
7581
|
+
const existing = existsSync8(path) ? readFileSync10(path, "utf8") : null;
|
|
7297
7582
|
if (existing === null) {
|
|
7298
7583
|
return {
|
|
7299
7584
|
item: "pre-commit",
|
|
@@ -7382,7 +7667,7 @@ ${block.join("\n")}
|
|
|
7382
7667
|
function planCodeowners(cwd) {
|
|
7383
7668
|
const rel = ".github/CODEOWNERS";
|
|
7384
7669
|
const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
|
|
7385
|
-
(r) =>
|
|
7670
|
+
(r) => existsSync8(join10(cwd, r))
|
|
7386
7671
|
);
|
|
7387
7672
|
const path = join10(cwd, existingRel ?? rel);
|
|
7388
7673
|
const existing = existingRel ? readFileSync10(path, "utf8") : null;
|
|
@@ -7423,7 +7708,7 @@ function planWorkflow(cwd, force) {
|
|
|
7423
7708
|
mkdirSync5(dirname4(path), { recursive: true });
|
|
7424
7709
|
writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
|
|
7425
7710
|
};
|
|
7426
|
-
if (!
|
|
7711
|
+
if (!existsSync8(path)) {
|
|
7427
7712
|
return {
|
|
7428
7713
|
item: "ci",
|
|
7429
7714
|
path: rel,
|
|
@@ -7533,15 +7818,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
7533
7818
|
// src/cli/verify.ts
|
|
7534
7819
|
import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
|
|
7535
7820
|
import { createHash as createHash8 } from "node:crypto";
|
|
7536
|
-
import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync3, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync3, rmSync as rmSync3, statSync as statSync5, symlinkSync, writeFileSync as writeFileSync6, existsSync as
|
|
7821
|
+
import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync3, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync3, rmSync as rmSync3, statSync as statSync5, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
|
|
7537
7822
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
7538
|
-
import { dirname as dirname5, join as join13, resolve as
|
|
7823
|
+
import { dirname as dirname5, join as join13, resolve as resolve4 } from "node:path";
|
|
7539
7824
|
|
|
7540
7825
|
// src/git/trusted.ts
|
|
7541
7826
|
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
7542
7827
|
import { createHash as createHash6 } from "node:crypto";
|
|
7543
|
-
import { existsSync as
|
|
7544
|
-
import { isAbsolute as
|
|
7828
|
+
import { existsSync as existsSync9, readFileSync as readFileSync11 } from "node:fs";
|
|
7829
|
+
import { isAbsolute as isAbsolute8, join as join11 } from "node:path";
|
|
7545
7830
|
function trustedGitEnv() {
|
|
7546
7831
|
return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
7547
7832
|
}
|
|
@@ -7554,7 +7839,7 @@ function gitDirOf(cwd) {
|
|
|
7554
7839
|
env: trustedGitEnv()
|
|
7555
7840
|
}).trim();
|
|
7556
7841
|
if (!d) return null;
|
|
7557
|
-
return
|
|
7842
|
+
return isAbsolute8(d) ? d : join11(cwd, d);
|
|
7558
7843
|
} catch {
|
|
7559
7844
|
return null;
|
|
7560
7845
|
}
|
|
@@ -7581,7 +7866,7 @@ function objectRewriteState(cwd) {
|
|
|
7581
7866
|
}
|
|
7582
7867
|
const p = join11(dir, rel);
|
|
7583
7868
|
try {
|
|
7584
|
-
parts.push(`${rel}:` + (
|
|
7869
|
+
parts.push(`${rel}:` + (existsSync9(p) ? readFileSync11(p, "utf8").trim() : ""));
|
|
7585
7870
|
} catch {
|
|
7586
7871
|
parts.push(`${rel}:<unreadable>`);
|
|
7587
7872
|
}
|
|
@@ -7759,8 +8044,8 @@ function materialize(cwd, dest) {
|
|
|
7759
8044
|
}
|
|
7760
8045
|
chmodSync2(out2, st.mode);
|
|
7761
8046
|
}
|
|
7762
|
-
const nm =
|
|
7763
|
-
if (
|
|
8047
|
+
const nm = resolve4(cwd, "node_modules");
|
|
8048
|
+
if (existsSync10(nm)) symlinkSync(nm, join13(dest, "node_modules"), "dir");
|
|
7764
8049
|
}
|
|
7765
8050
|
function dropSymlink(p) {
|
|
7766
8051
|
try {
|
|
@@ -8095,8 +8380,8 @@ function parseVerify(args) {
|
|
|
8095
8380
|
|
|
8096
8381
|
// src/cli/run.ts
|
|
8097
8382
|
import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
|
|
8098
|
-
import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync as
|
|
8099
|
-
import { resolve as
|
|
8383
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync as realpathSync3 } from "node:fs";
|
|
8384
|
+
import { resolve as resolve5 } from "node:path";
|
|
8100
8385
|
var out = (s) => process.stdout.write(s + "\n");
|
|
8101
8386
|
var err = (s) => process.stderr.write(s + "\n");
|
|
8102
8387
|
function git3(args, cwd) {
|
|
@@ -8113,7 +8398,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
8113
8398
|
const out2 = [];
|
|
8114
8399
|
let real;
|
|
8115
8400
|
try {
|
|
8116
|
-
real =
|
|
8401
|
+
real = realpathSync3(cwd);
|
|
8117
8402
|
} catch {
|
|
8118
8403
|
return out2;
|
|
8119
8404
|
}
|
|
@@ -8128,7 +8413,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
8128
8413
|
if (n === process.pid) continue;
|
|
8129
8414
|
let held;
|
|
8130
8415
|
try {
|
|
8131
|
-
held =
|
|
8416
|
+
held = realpathSync3(`/proc/${pid}/cwd`);
|
|
8132
8417
|
} catch {
|
|
8133
8418
|
continue;
|
|
8134
8419
|
}
|
|
@@ -8143,7 +8428,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
8143
8428
|
return out2;
|
|
8144
8429
|
}
|
|
8145
8430
|
function runEnvelope(opts) {
|
|
8146
|
-
const cwd =
|
|
8431
|
+
const cwd = resolve5(opts.cwd ?? process.cwd());
|
|
8147
8432
|
if (!opts.argv.length) {
|
|
8148
8433
|
err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
|
|
8149
8434
|
return 2;
|