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.
Files changed (2) hide show
  1. package/dist/cli/index.js +215 -33
  2. 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 cwd = rest.match(/\s--cwd(?:=|\s+)(\S+)/)?.[1];
2903
- return cwd ? `tamperward ${sub} --cwd ${cwd}` : `tamperward ${sub}`;
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 op = stmt.startsWith("&&", i) ? "&&" : stmt.startsWith("||", i) ? "||" : ch === ";" ? ";" : ch === "|" ? "|" : null;
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 TRANSPARENT = /^(?:fi|done|esac|\}|\)|;;|then|do|else|exit\s+"?\$\??"?|exit\s+"?\$\{\?\}"?)\s*;?$/;
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 bg = /(?<!&)&\s*$/.test(stmt) && !/&&\s*$/.test(stmt);
3318
- if (bg) stmt = stmt.replace(/\s*&\s*$/, "");
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.split(" --cwd ")[0] === id.split(" --cwd ")[0]);
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 || !PLAIN_SEMVER.test(inv.pin)) continue;
3528
- 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() });
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 runsGate(command) {
3635
- return invocations([command], { errexit: true }).some((i) => i.state === "live" && /^tamperward check\b/.test(i.identity));
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: run === void 0 ? named : runsGate(str(run)),
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 live = h.entry !== void 0 ? runsGate(str(h.entry)) : GATE.test(`${str(repo.repo)} ${str(h.id)}`);
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
- if (!husky && c.after != null) {
4643
- const problem = shebangProblem(c.after);
4644
- if (problem && (c.before == null || problem !== shebangProblem(c.before))) {
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: 1,
4649
- message: `A protected hook's interpreter was changed: ${problem}.`,
4650
- evidence: c.after.split("\n")[0],
4651
- remediation: "Keep a shell in shebang position. git execs the hook directly, and an interpreter that never reads the script is a disabled hook."
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 beforeLines = c.before != null ? c.before.split("\n") : removedLines(c).map((l) => l.content);
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.9.0",
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",