tamperward 2.9.0 → 2.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +215 -33
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -2703,7 +2703,7 @@ function pinNotBelow(now, floor) {
|
|
|
2703
2703
|
const c = compareVersions(now, floor);
|
|
2704
2704
|
return c !== null && c >= 0;
|
|
2705
2705
|
}
|
|
2706
|
-
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*$/;
|
|
2707
2707
|
function initScriptPin(src) {
|
|
2708
2708
|
const m = src.match(INIT_SCRIPT);
|
|
2709
2709
|
return m ? m[1] ?? "" : null;
|
|
@@ -2889,6 +2889,8 @@ var TOOL = /^(tamperward|jest|vitest|eslint|tsc|lint-staged|lefthook|husky|pytes
|
|
|
2889
2889
|
var SCRIPT = /^(test|tests|lint|typecheck|type-check|coverage|check)\b/;
|
|
2890
2890
|
var SUBCOMMAND_TOOLS = /* @__PURE__ */ new Set(["tamperward", "make", "cargo", "go", "lefthook", "husky", "pre-commit"]);
|
|
2891
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+)/;
|
|
2892
2894
|
function checkIdentity(segment) {
|
|
2893
2895
|
const core = segment.trim().replace(PREFIX, "").replace(/\s+/g, " ");
|
|
2894
2896
|
const viaRunner = RUNNER.test(core);
|
|
@@ -2899,8 +2901,10 @@ function checkIdentity(segment) {
|
|
|
2899
2901
|
if (tool === "tamperward") {
|
|
2900
2902
|
const sub = toks[1];
|
|
2901
2903
|
if (!sub || sub.startsWith("-")) return null;
|
|
2902
|
-
const
|
|
2903
|
-
|
|
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(" ");
|
|
2904
2908
|
}
|
|
2905
2909
|
if (SUBCOMMAND_TOOLS.has(tool) && toks[1] && !toks[1].startsWith("-")) return `${tool} ${toks[1]}`;
|
|
2906
2910
|
return tool;
|
|
@@ -2936,7 +2940,8 @@ function split(stmt) {
|
|
|
2936
2940
|
continue;
|
|
2937
2941
|
}
|
|
2938
2942
|
if (depth > 0) continue;
|
|
2939
|
-
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;
|
|
2940
2945
|
if (!op) continue;
|
|
2941
2946
|
segs.push(stmt.slice(last, i));
|
|
2942
2947
|
ops.push(op);
|
|
@@ -3165,7 +3170,10 @@ function prepare(lines, opts) {
|
|
|
3165
3170
|
}
|
|
3166
3171
|
return { top, fns, shadowed, pipefail, errexit };
|
|
3167
3172
|
}
|
|
3168
|
-
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*;?$/;
|
|
3169
3177
|
function statusAfterFailure(seg, vars) {
|
|
3170
3178
|
const inner = seg.trim().replace(/^[{(]\s*/, "").replace(/\s*[})]\s*;?\s*$/, "");
|
|
3171
3179
|
let status = "fail";
|
|
@@ -3214,7 +3222,7 @@ function reaches(segs, ops, i) {
|
|
|
3214
3222
|
if (!runs) continue;
|
|
3215
3223
|
const s = constStatus(segs[j]);
|
|
3216
3224
|
if (s === "unknown" && (ops[j] === "&&" || ops[j] === "||")) certain = false;
|
|
3217
|
-
if (ops[j] === ";" || ops[j] === "|") {
|
|
3225
|
+
if (ops[j] === ";" || ops[j] === "|" || ops[j] === "&") {
|
|
3218
3226
|
status = "ok";
|
|
3219
3227
|
certain = certain && true;
|
|
3220
3228
|
continue;
|
|
@@ -3223,7 +3231,7 @@ function reaches(segs, ops, i) {
|
|
|
3223
3231
|
}
|
|
3224
3232
|
const gate = i === 0 ? true : ops[i - 1] === "&&" ? status !== "fail" : ops[i - 1] === "||" ? status !== "ok" : true;
|
|
3225
3233
|
if (!gate) return "no";
|
|
3226
|
-
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";
|
|
3227
3235
|
if (!certain || status === "unknown") return "maybe";
|
|
3228
3236
|
return "yes";
|
|
3229
3237
|
}
|
|
@@ -3314,15 +3322,18 @@ function walk(lines, st, topIdx, depth) {
|
|
|
3314
3322
|
}
|
|
3315
3323
|
if (/^;;$/.test(stmt)) continue;
|
|
3316
3324
|
if (/^[{}()]$/.test(stmt)) continue;
|
|
3317
|
-
const
|
|
3318
|
-
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());
|
|
3319
3328
|
const { segs, ops } = split(stmt);
|
|
3320
3329
|
for (let s = 0; s < segs.length; s++) {
|
|
3321
3330
|
let seg = segs[s].trim();
|
|
3322
3331
|
const inverted = /^!\s+/.test(seg);
|
|
3323
3332
|
seg = seg.replace(/^!\s+/, "");
|
|
3324
3333
|
const execs = /^exec\s+/.test(seg) && !/^exec\s+(?:\d*[<>]|&>)/.test(seg);
|
|
3334
|
+
const whole = seg;
|
|
3325
3335
|
seg = seg.replace(PREFIX, "");
|
|
3336
|
+
const prefix = whole.slice(0, whole.length - seg.length);
|
|
3326
3337
|
const reach = dead() ? "no" : reaches(segs, ops, s);
|
|
3327
3338
|
const first = seg.split(/\s+/)[0] ?? "";
|
|
3328
3339
|
if (reach !== "no") {
|
|
@@ -3349,11 +3360,12 @@ function walk(lines, st, topIdx, depth) {
|
|
|
3349
3360
|
const sigs = trap[4].split(/\s+/);
|
|
3350
3361
|
if (sigs.some((x) => /^(?:EXIT|ERR|0|SIGERR)$/i.test(x)) && /(?:^|[;\s])(?:exit(?:\s+0+)?|true|:)\s*(?:;|$)/.test(action)) st.traps.push(idx);
|
|
3351
3362
|
}
|
|
3352
|
-
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*)=(.*)$/);
|
|
3353
3364
|
if (asg) {
|
|
3354
3365
|
const v = certain ? literal(asg[2], st.vars) : null;
|
|
3355
3366
|
if (v === null) st.vars.delete(asg[1]);
|
|
3356
3367
|
else st.vars.set(asg[1], v);
|
|
3368
|
+
if (SENSITIVE_ENV.test(asg[1])) st.redirected = true;
|
|
3357
3369
|
}
|
|
3358
3370
|
if (first === "cd") {
|
|
3359
3371
|
const home = cdStaysHome(seg.slice(2), st.vars);
|
|
@@ -3380,6 +3392,7 @@ function walk(lines, st, topIdx, depth) {
|
|
|
3380
3392
|
st.errexit = sub.errexit;
|
|
3381
3393
|
st.pipefail = sub.pipefail;
|
|
3382
3394
|
st.cdAway = sub.cdAway;
|
|
3395
|
+
st.redirected = sub.redirected;
|
|
3383
3396
|
const verdict2 = chainVerdict(segs, ops, s, st, inverted, bg);
|
|
3384
3397
|
for (let k = start; k < st.out.length; k++) {
|
|
3385
3398
|
const inv = st.out[k];
|
|
@@ -3424,8 +3437,18 @@ function walk(lines, st, topIdx, depth) {
|
|
|
3424
3437
|
state = "neutered";
|
|
3425
3438
|
why = "runs from another directory after a `cd` away from the repository root";
|
|
3426
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
|
+
}
|
|
3427
3450
|
if (state === "live") {
|
|
3428
|
-
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));
|
|
3429
3452
|
if (words2.length) {
|
|
3430
3453
|
state = "neutered";
|
|
3431
3454
|
why = `\`${words2[0]}\` is redefined by a function or alias in this script`;
|
|
@@ -3442,6 +3465,7 @@ function chainVerdict(segs, ops, i, st, inverted, bg) {
|
|
|
3442
3465
|
if (inverted) return { state: "neutered", why: "its status is inverted by `!`", passthrough: false };
|
|
3443
3466
|
if (bg) return { state: "neutered", why: "run in the background \u2014 the hook does not wait for its status", passthrough: false };
|
|
3444
3467
|
let cur = "fail";
|
|
3468
|
+
let waited = -1;
|
|
3445
3469
|
for (let j = i; j < ops.length; j++) {
|
|
3446
3470
|
const op = ops[j];
|
|
3447
3471
|
const next = segs[j + 1];
|
|
@@ -3449,6 +3473,13 @@ function chainVerdict(segs, ops, i, st, inverted, bg) {
|
|
|
3449
3473
|
if (!st.pipefail) return { state: "neutered", why: "piped into another command without `pipefail`", passthrough: false };
|
|
3450
3474
|
continue;
|
|
3451
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
|
+
}
|
|
3452
3483
|
if (cur === "exit") break;
|
|
3453
3484
|
if (op === "||") {
|
|
3454
3485
|
if (cur === "fail") cur = statusAfterFailure(next, st.vars);
|
|
@@ -3466,7 +3497,7 @@ function chainVerdict(segs, ops, i, st, inverted, bg) {
|
|
|
3466
3497
|
}
|
|
3467
3498
|
if (cur === "ok") return { state: "neutered", why: "the `||` chain after it ends in success", passthrough: false };
|
|
3468
3499
|
if (cur === "exit") return { state: "live", passthrough: false };
|
|
3469
|
-
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) };
|
|
3470
3501
|
}
|
|
3471
3502
|
function invocations(lines, opts = {}) {
|
|
3472
3503
|
const prep = prepare(lines, opts);
|
|
@@ -3474,6 +3505,7 @@ function invocations(lines, opts = {}) {
|
|
|
3474
3505
|
errexit: prep.errexit,
|
|
3475
3506
|
pipefail: prep.pipefail,
|
|
3476
3507
|
cdAway: false,
|
|
3508
|
+
redirected: false,
|
|
3477
3509
|
vars: /* @__PURE__ */ new Map(),
|
|
3478
3510
|
fns: prep.fns,
|
|
3479
3511
|
shadowed: prep.shadowed,
|
|
@@ -3507,6 +3539,27 @@ var strip = (inv) => {
|
|
|
3507
3539
|
const { idx: _i, errexit: _e, passthrough: _p, ...rest } = inv;
|
|
3508
3540
|
return rest;
|
|
3509
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
|
+
}
|
|
3510
3563
|
function scriptWeakening(before, after, opts = {}) {
|
|
3511
3564
|
const beforeInv = invocations(before, opts);
|
|
3512
3565
|
const afterInv = invocations(after, opts);
|
|
@@ -3516,7 +3569,7 @@ function scriptWeakening(before, after, opts = {}) {
|
|
|
3516
3569
|
const out2 = [];
|
|
3517
3570
|
for (const id of bl) {
|
|
3518
3571
|
if (al.has(id)) continue;
|
|
3519
|
-
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));
|
|
3520
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";
|
|
3521
3574
|
const evidence = (trace?.line ?? before.find((l) => checkIdentity(stripComment(l).trim().replace(/^!\s+/, "").replace(PREFIX, "")) === id) ?? id).trim();
|
|
3522
3575
|
out2.push({ reason: `the check invocation \`${id}\` was ${how}`, evidence });
|
|
@@ -3524,8 +3577,9 @@ function scriptWeakening(before, after, opts = {}) {
|
|
|
3524
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];
|
|
3525
3578
|
if (floor) {
|
|
3526
3579
|
for (const inv of afterInv) {
|
|
3527
|
-
if (inv.state !== "live" || !inv.pin
|
|
3528
|
-
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() });
|
|
3529
3583
|
}
|
|
3530
3584
|
}
|
|
3531
3585
|
return out2;
|
|
@@ -3631,8 +3685,22 @@ var isPackageJson = (path) => /(?:^|\/)package\.json$/.test(path);
|
|
|
3631
3685
|
var GATE = /\btamperward\b/;
|
|
3632
3686
|
var str = (v) => typeof v === "string" ? v : Array.isArray(v) ? v.map(String).join(" ") : v == null ? "" : JSON.stringify(v);
|
|
3633
3687
|
var list = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" ? v.split(/[\s,]+/).filter(Boolean) : [];
|
|
3634
|
-
function
|
|
3635
|
-
|
|
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;
|
|
3636
3704
|
}
|
|
3637
3705
|
function mergeDocs(base, over) {
|
|
3638
3706
|
const out2 = { ...base };
|
|
@@ -3657,8 +3725,11 @@ function lefthookEntries(doc) {
|
|
|
3657
3725
|
const named = GATE.test(name);
|
|
3658
3726
|
if (!GATE.test(str(run)) && !named) continue;
|
|
3659
3727
|
const tags = list(cfg.tags);
|
|
3728
|
+
const gate = run === void 0 ? named ? { identity: "tamperward check --staged", pin: "" } : null : gateOf(str(run));
|
|
3660
3729
|
out2.set(`${section}.${group}.${name}`, {
|
|
3661
|
-
live:
|
|
3730
|
+
live: gate !== null,
|
|
3731
|
+
gate,
|
|
3732
|
+
env: redirectingEnv(cfg.env),
|
|
3662
3733
|
skip: cfg.skip,
|
|
3663
3734
|
only: cfg.only,
|
|
3664
3735
|
sectionSkip: v.skip,
|
|
@@ -3684,6 +3755,7 @@ function lefthookWeakening(before, after) {
|
|
|
3684
3755
|
continue;
|
|
3685
3756
|
}
|
|
3686
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));
|
|
3687
3759
|
if (!off(now.skip) && off(was.skip)) out2.push(`lefthook entry ${key2} (the gate) is now \`skip: ${str(now.skip)}\``);
|
|
3688
3760
|
if (!off(now.sectionSkip) && off(was.sectionSkip)) out2.push(`the lefthook hook carrying ${key2} (the gate) is now \`skip: ${str(now.sectionSkip)}\``);
|
|
3689
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)})`);
|
|
@@ -3708,9 +3780,12 @@ function preCommitEntries(doc) {
|
|
|
3708
3780
|
for (const h of hooks) {
|
|
3709
3781
|
if (!isDoc(h)) continue;
|
|
3710
3782
|
if (!GATE.test(`${str(repo.repo)} ${str(h.id)} ${str(h.entry)} ${str(h.name)}`)) continue;
|
|
3711
|
-
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;
|
|
3712
3784
|
out2.set(`${str(repo.repo)}:${str(h.id)}`, {
|
|
3713
|
-
live,
|
|
3785
|
+
live: gate !== null,
|
|
3786
|
+
gate,
|
|
3787
|
+
env: redirectingEnv(h.env),
|
|
3788
|
+
deps: str(h.additional_dependencies),
|
|
3714
3789
|
stages: Array.isArray(h.stages) ? h.stages.map(String).map(normStage) : defaults,
|
|
3715
3790
|
explicitStages: Array.isArray(h.stages),
|
|
3716
3791
|
exclude: str(h.exclude),
|
|
@@ -3734,6 +3809,8 @@ function preCommitWeakening(before, after) {
|
|
|
3734
3809
|
continue;
|
|
3735
3810
|
}
|
|
3736
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`);
|
|
3737
3814
|
const lost = was.stages.filter((s) => GATE_STAGES.has(s) && !now.stages.includes(s));
|
|
3738
3815
|
if (lost.length && !now.stages.some((s) => GATE_STAGES.has(s))) {
|
|
3739
3816
|
out2.push(`pre-commit hook ${key2} (the gate) now runs only at stages [${now.stages.join(", ")}] \u2014 no commit or push triggers it`);
|
|
@@ -4549,7 +4626,63 @@ function repoRelative(path, ctx) {
|
|
|
4549
4626
|
const rel = relative(ctx.cwd, path);
|
|
4550
4627
|
return rel && !rel.startsWith("..") && !isAbsolute3(rel) ? rel : path;
|
|
4551
4628
|
}
|
|
4629
|
+
var HUSKY_SHIM = '#!/usr/bin/env sh\n. "$(dirname "$0")/h"';
|
|
4630
|
+
var HUSKY_SHIM_NAMES = /* @__PURE__ */ new Set(["applypatch-msg", "commit-msg", "post-applypatch", "post-checkout", "post-commit", "post-merge", "post-rewrite", "pre-applypatch", "pre-auto-gc", "pre-commit", "pre-merge-commit", "pre-push", "pre-rebase", "prepare-commit-msg"]);
|
|
4631
|
+
var HUSKY_H_KNOWN = [
|
|
4632
|
+
// husky 9.1.x
|
|
4633
|
+
`#!/usr/bin/env sh
|
|
4634
|
+
[ "$HUSKY" = "2" ] && set -x
|
|
4635
|
+
n=$(basename "$0")
|
|
4636
|
+
s=$(dirname "$(dirname "$0")")/$n
|
|
4637
|
+
|
|
4638
|
+
[ ! -f "$s" ] && exit 0
|
|
4639
|
+
|
|
4640
|
+
if [ -f "$HOME/.huskyrc" ]; then
|
|
4641
|
+
echo "husky - '~/.huskyrc' is DEPRECATED, please move your code to ~/.config/husky/init.sh"
|
|
4642
|
+
fi
|
|
4643
|
+
i="\${XDG_CONFIG_HOME:-$HOME/.config}/husky/init.sh"
|
|
4644
|
+
[ -f "$i" ] && . "$i"
|
|
4645
|
+
|
|
4646
|
+
[ "\${HUSKY-}" = "0" ] && exit 0
|
|
4647
|
+
|
|
4648
|
+
export PATH="node_modules/.bin:$PATH"
|
|
4649
|
+
sh -e "$s" "$@"
|
|
4650
|
+
c=$?
|
|
4651
|
+
|
|
4652
|
+
[ $c != 0 ] && echo "husky - $n script failed (code $c)"
|
|
4653
|
+
[ $c = 127 ] && echo "husky - command not found in PATH=$PATH"
|
|
4654
|
+
exit $c
|
|
4655
|
+
`
|
|
4656
|
+
];
|
|
4657
|
+
var HUSKY_DEPRECATION = `echo "husky - DEPRECATED
|
|
4658
|
+
|
|
4659
|
+
Please remove the following two lines from $0:
|
|
4660
|
+
|
|
4661
|
+
#!/usr/bin/env sh
|
|
4662
|
+
. \\"\\$(dirname -- \\"\\$0\\")/_/husky.sh\\"
|
|
4663
|
+
|
|
4664
|
+
They WILL FAIL in v10.0.0
|
|
4665
|
+
"`;
|
|
4666
|
+
var stripTrailingNl = (s) => s.endsWith("\n") ? s.slice(0, -1) : s;
|
|
4667
|
+
function huskyRuntimeWrite(path, content, ctx) {
|
|
4668
|
+
const m = path.match(/(?:^|\/)\.husky\/_\/([^/]+)$/);
|
|
4669
|
+
if (!m || content == null) return false;
|
|
4670
|
+
const name = m[1];
|
|
4671
|
+
const body = stripTrailingNl(content);
|
|
4672
|
+
const ok = name === ".gitignore" ? body === "*" : name === "h" ? HUSKY_H_KNOWN.some((t) => stripTrailingNl(t) === body) : name === "husky.sh" ? stripTrailingNl(HUSKY_DEPRECATION) === body : HUSKY_SHIM_NAMES.has(name) && body === HUSKY_SHIM;
|
|
4673
|
+
if (!ok) return false;
|
|
4674
|
+
if (!ctx?.cwd) return false;
|
|
4675
|
+
const root = path.slice(0, path.length - m[0].length + (m[0].startsWith("/") ? 1 : 0));
|
|
4676
|
+
const entry = inspectRel(join3(ctx.cwd, root), ".husky/pre-commit");
|
|
4677
|
+
if (entry.kind !== "file") return false;
|
|
4678
|
+
const gate = textOf(entry);
|
|
4679
|
+
if (gate == null) return false;
|
|
4680
|
+
return invocations(gate.split("\n"), { errexit: true }).some(
|
|
4681
|
+
(i) => i.identity === "tamperward check --staged" && i.state === "live" && i.pin === TW_VERSION
|
|
4682
|
+
);
|
|
4683
|
+
}
|
|
4552
4684
|
var isHuskyScript = (path) => /(?:^|\/)\.husky\/[^/]+$/.test(path);
|
|
4685
|
+
var isHookNote = (path) => /\.(?:md|markdown|txt)$|(?:^|\/)\.git(?:ignore|attributes)$/i.test(path);
|
|
4553
4686
|
var hookTampering = {
|
|
4554
4687
|
id: RULE6,
|
|
4555
4688
|
surface: ["file", "command"],
|
|
@@ -4622,7 +4755,9 @@ var hookTampering = {
|
|
|
4622
4755
|
} else if ((isLefthook(c.path) || isPreCommitConfig(c.path)) && (semantic = configFindings(c, changes, policy, ctx)) !== null) {
|
|
4623
4756
|
out2.push(...semantic);
|
|
4624
4757
|
} else if (!c.path.endsWith(POLICY_FILE)) {
|
|
4758
|
+
if ((c.op === "add" || c.op === "modify") && huskyRuntimeWrite(c.path, c.after, ctx)) continue;
|
|
4625
4759
|
const husky = isHuskyScript(c.path);
|
|
4760
|
+
const opts = husky ? { errexit: true } : {};
|
|
4626
4761
|
const beforePin = c.before != null && c.after != null ? initScriptPin(c.before) : null;
|
|
4627
4762
|
if (beforePin !== null) {
|
|
4628
4763
|
const afterPin = initScriptPin(c.after);
|
|
@@ -4639,22 +4774,71 @@ var hookTampering = {
|
|
|
4639
4774
|
}
|
|
4640
4775
|
continue;
|
|
4641
4776
|
}
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
|
|
4777
|
+
const added = addedLines(c);
|
|
4778
|
+
const removed = removedLines(c);
|
|
4779
|
+
const beforeLines = c.before != null ? c.before.split("\n") : removed.map((l) => l.content);
|
|
4780
|
+
const afterLines = c.after != null ? c.after.split("\n") : added.map((l) => l.content);
|
|
4781
|
+
const shebang = !husky && c.after != null ? shebangProblem(c.after) : null;
|
|
4782
|
+
const shebangChanged = shebang !== null && (c.before == null || shebang !== shebangProblem(c.before));
|
|
4783
|
+
const earlyExit = added.find((l) => insertsPassingExit(l.content));
|
|
4784
|
+
if (c.op === "add") {
|
|
4785
|
+
const gate = invocations(afterLines, opts).filter((i) => /^tamperward check\b/.test(i.identity));
|
|
4786
|
+
const live = gate.find((i) => i.state === "live");
|
|
4787
|
+
if (shebang || !live) {
|
|
4788
|
+
const why = shebang ?? (gate.length ? `its \`${gate[0].identity}\` is ${gate[0].state}${gate[0].why ? ` (${gate[0].why})` : ""}` : "it runs no `tamperward check`");
|
|
4789
|
+
out2.push(
|
|
4790
|
+
makeFinding(RULE6, policy, {
|
|
4791
|
+
file: c.path,
|
|
4792
|
+
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.`,
|
|
4793
|
+
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`,
|
|
4794
|
+
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."
|
|
4795
|
+
})
|
|
4796
|
+
);
|
|
4797
|
+
} else if (earlyExit) {
|
|
4645
4798
|
out2.push(
|
|
4646
4799
|
makeFinding(RULE6, policy, {
|
|
4647
4800
|
file: c.path,
|
|
4648
|
-
line:
|
|
4649
|
-
message: `
|
|
4650
|
-
evidence:
|
|
4651
|
-
remediation: "
|
|
4801
|
+
line: earlyExit.newLine ?? void 0,
|
|
4802
|
+
message: "An early `exit 0` was inserted into a protected hook \u2014 it now passes everything.",
|
|
4803
|
+
evidence: earlyExit.content.trim(),
|
|
4804
|
+
remediation: "Remove the early exit. A hook that always succeeds is a disabled hook."
|
|
4652
4805
|
})
|
|
4653
4806
|
);
|
|
4654
4807
|
}
|
|
4808
|
+
continue;
|
|
4809
|
+
}
|
|
4810
|
+
if (!isHookNote(c.path)) {
|
|
4811
|
+
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"));
|
|
4812
|
+
if (!clean) {
|
|
4813
|
+
const deadGuard2 = c.before == null || c.after == null ? added.find((l) => insertsDeadGuard(l.content)) : void 0;
|
|
4814
|
+
const detail = [
|
|
4815
|
+
shebangChanged ? `the interpreter changed: ${shebang}` : null,
|
|
4816
|
+
...scriptWeakening(beforeLines, afterLines, opts).map((l) => l.reason),
|
|
4817
|
+
earlyExit ? `an early \`exit 0\` was inserted (\`${earlyExit.content.trim()}\`)` : null,
|
|
4818
|
+
deadGuard2 ? `a guard that can never be true was inserted (\`${deadGuard2.content.trim()}\`)` : null
|
|
4819
|
+
].filter((d) => d !== null);
|
|
4820
|
+
out2.push(
|
|
4821
|
+
makeFinding(RULE6, policy, {
|
|
4822
|
+
file: c.path,
|
|
4823
|
+
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.`,
|
|
4824
|
+
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`,
|
|
4825
|
+
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."
|
|
4826
|
+
})
|
|
4827
|
+
);
|
|
4828
|
+
}
|
|
4829
|
+
continue;
|
|
4830
|
+
}
|
|
4831
|
+
if (shebangChanged && c.after != null) {
|
|
4832
|
+
out2.push(
|
|
4833
|
+
makeFinding(RULE6, policy, {
|
|
4834
|
+
file: c.path,
|
|
4835
|
+
line: 1,
|
|
4836
|
+
message: `A protected hook's interpreter was changed: ${shebang}.`,
|
|
4837
|
+
evidence: c.after.split("\n")[0],
|
|
4838
|
+
remediation: "Keep a shell in shebang position. git execs the hook directly, and an interpreter that never reads the script is a disabled hook."
|
|
4839
|
+
})
|
|
4840
|
+
);
|
|
4655
4841
|
}
|
|
4656
|
-
const added = addedLines(c);
|
|
4657
|
-
const earlyExit = added.find((l) => insertsPassingExit(l.content));
|
|
4658
4842
|
if (earlyExit) {
|
|
4659
4843
|
out2.push(
|
|
4660
4844
|
makeFinding(RULE6, policy, {
|
|
@@ -4678,9 +4862,7 @@ var hookTampering = {
|
|
|
4678
4862
|
})
|
|
4679
4863
|
);
|
|
4680
4864
|
}
|
|
4681
|
-
const
|
|
4682
|
-
const afterLines = c.after != null ? c.after.split("\n") : added.map((l) => l.content);
|
|
4683
|
-
const lost = scriptWeakening(beforeLines, afterLines, husky ? { errexit: true } : {});
|
|
4865
|
+
const lost = scriptWeakening(beforeLines, afterLines, opts);
|
|
4684
4866
|
if (lost.length) {
|
|
4685
4867
|
out2.push(
|
|
4686
4868
|
makeFinding(RULE6, policy, {
|
|
@@ -5927,7 +6109,7 @@ function oobHeadFromEnv(env = process.env) {
|
|
|
5927
6109
|
return v.length >= 7 ? v : void 0;
|
|
5928
6110
|
}
|
|
5929
6111
|
function oobFromEnv(env = process.env) {
|
|
5930
|
-
return (env.TAMPERWARD_OOB_SIGNOFF ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
6112
|
+
return (env.TAMPERWARD_OOB_SIGNOFF ?? "").split(",").map((s) => s.trim().replace(/^tamperward:allow:/, "")).filter(Boolean);
|
|
5931
6113
|
}
|
|
5932
6114
|
|
|
5933
6115
|
// src/cli/render/text.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.1",
|
|
4
4
|
"description": "The deterministic agent-integrity gate. One ruleset, evaluated on the actual diff/commands as a verdict, enforced everywhere a change can be made.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "hexrift",
|