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.
Files changed (2) hide show
  1. package/dist/cli/index.js +441 -156
  2. 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, resolve5) {
1454
- const obj = resolve5(expr);
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(resolve5(p.initializer));
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 resolve5 = resolverFor(sf);
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 = resolve5(node.initializer);
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, resolve5);
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, resolve5);
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 resolve5 = resolverFor(sf);
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(resolve5(node.initializer));
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(resolve5(node.initializer));
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 { dirname, join as join2 } from "node:path";
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 = /^\d+\.\d+\.\d+$/;
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
- var isClaudeSettings = (path) => CLAUDE_SETTINGS.test(path);
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 cwd = rest.match(/\s--cwd(?:=|\s+)(\S+)/)?.[1];
2867
- 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(" ");
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 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;
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 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*;?$/;
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 bg = /(?<!&)&\s*$/.test(stmt) && !/&&\s*$/.test(stmt);
3282
- 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());
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.split(" --cwd ")[0] === id.split(" --cwd ")[0]);
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 || !PLAIN_SEMVER.test(inv.pin)) continue;
3492
- 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() });
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 runsGate(command) {
3599
- 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;
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: run === void 0 ? named : runsGate(str(run)),
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 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;
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 HOOK_BASENAMES = ["pre-commit", "pre-push", "commit-msg", "settings.json", "settings.local.json", ".tamperward.yml", "lefthook.yml", "lefthook.yaml", "lefthook-local.yml", ".pre-commit-config.yaml", "CODEOWNERS"];
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
- let p = t.replace(/^["']|["']$/g, "");
3870
- if (ctx?.cwd && p.startsWith(ctx.cwd + "/")) p = p.slice(ctx.cwd.length + 1);
3871
- return p.replace(/^\.\/(?=.)/, "");
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) => isProtected(rel(t), policy, "hooks");
3874
- const whole = (t) => /^(?:\.|\.\/|:\/|\*)$/.test(rel(t)) || ctx?.cwd !== void 0 && rel(t) === ctx.cwd;
3875
- const holds = (t) => !t.startsWith("-") && (whole(t) || containsProtected(rel(t), policy, "hooks", ctx));
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(/[\w.~/-]+/g) ?? []).some(hook));
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 rel = (t) => (ctx?.cwd && t.startsWith(ctx.cwd + "/") ? t.slice(ctx.cwd.length + 1) : t).replace(/^\.\//, "");
4000
- const fedHook = fed.some((t) => isProtected(rel(t), policy, "hooks") || !t.startsWith("-") && containsProtected(rel(t), policy, "hooks", ctx));
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 SENSITIVE_ENV = /^(?:PATH|NODE_OPTIONS|NODE_PATH|HOME|npm_config_.*|NPM_CONFIG_.*)$/;
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_ |-]+$/.test(m)) return { tools: new Set(m.split("|").map((t) => t.trim()).filter(Boolean)), problem };
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 (mentions(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["the matcher entry is not an object"], pin: null, tools: null });
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 (mentions(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["`hooks` is not a list"], pin: null, tools: null });
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 (mentions(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the hook entry is not an object"], pin: null, tools: null });
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 (mentions(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the gate is not in a `command` string"], pin: null, tools: null });
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 problems = [...a.problems];
4129
- let runs = a.runs;
4130
- if (h.type !== "command") {
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 (SENSITIVE_ENV.test(k) && JSON.stringify(was[k]) !== JSON.stringify(v)) {
4231
- reasons.push(`a top-level \`env\` sets ${k} \u2014 the gate command resolves through it`);
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 floor = pins[0] ?? (before === null && PLAIN_SEMVER.test(TW_VERSION) ? TW_VERSION : null);
4261
- if (floor) {
4262
- for (const c of fresh) {
4263
- if (c.pin && PLAIN_SEMVER.test(c.pin) && !pinNotBelow(c.pin, floor)) reasons.push(`the ${event} gate is pinned to tamperward@${c.pin}, below ${floor}`);
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
- const hadOther = new Set(before !== null ? otherCommands(before, event) : []);
4267
- for (const cmd of otherCommands(after, event)) {
4268
- 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)}\`)`);
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, and the matcher that covers every editing tool, exactly as `tamperward init` wrote them. Editing the wiring is editing the gate: re-run `tamperward init` to restore it, or sign off."
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 (existsSync(p)) return parseDoc(readFileSync2(p, "utf8"));
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 || !isAbsolute(path)) return path;
4625
+ if (!ctx?.cwd || !isAbsolute3(path)) return path;
4395
4626
  const rel = relative(ctx.cwd, path);
4396
- return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
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) || isClaudeSettings(c.path);
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) || isClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c, policy)) !== null) {
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
- if (!husky && c.after != null) {
4489
- const problem = shebangProblem(c.after);
4490
- if (problem && (c.before == null || problem !== shebangProblem(c.before))) {
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
- line: 1,
4495
- message: `A protected hook's interpreter was changed: ${problem}.`,
4496
- evidence: c.after.split("\n")[0],
4497
- remediation: "Keep a shell in shebang position. git execs the hook directly, and an interpreter that never reads the script is a disabled hook."
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 beforeLines = c.before != null ? c.before.split("\n") : removedLines(c).map((l) => l.content);
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
- ...target ? { file: repoRelative(target, ctx) } : {},
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) || isClaudeSettings(f.file);
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 existsSync2 } from "node:fs";
5560
- import { isAbsolute as isAbsolute2, join as join4, posix } from "node:path";
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() || isAbsolute2(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
5578
- const norm2 = posix.normalize(ledger.replace(/\\/g, "/"));
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 (!existsSync2(path)) return defaultPolicy();
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 existsSync3 } from "node:fs";
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 (!existsSync3(p)) return [];
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 existsSync6 } from "node:fs";
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 isAbsolute3, join as join6, relative as relative2, resolve } from "node:path";
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 resolve(cwd, path);
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("..") && !isAbsolute3(rel) ? rel : path;
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 existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "node:fs";
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 (existsSync4(p)) {
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 existsSync5, lstatSync as lstatSync2, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
6247
- import { isAbsolute as isAbsolute4, join as join8 } from "node:path";
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 isAbsolute4(d) ? d : join8(cwd, d);
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 || !existsSync5(p)) return null;
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 (!existsSync6(log)) return none;
7078
+ if (!existsSync7(log)) return none;
6797
7079
  let offset = 0;
6798
- if (cp && existsSync6(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 existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync, statSync as statSync3 } from "node:fs";
6953
- import { basename, dirname as dirname4, isAbsolute as isAbsolute5, join as join10, relative as relative3, resolve as resolve2 } from "node:path";
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("|").map((t) => t.trim()).filter(Boolean));
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 (existsSync7(path)) {
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 (existsSync7(path)) {
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
- if (!needPre && !needStop && stale.length === 0 && repin.length === 0) {
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: existsSync7(path) ? "update" : "create",
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 resolve2(cwd, out2);
7528
+ if (out2) return resolve3(cwd, out2);
7244
7529
  } catch {
7245
7530
  }
7246
- return existsSync7(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
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("..") && !isAbsolute5(rel) ? rel : path;
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 (existsSync7(join10(cwd, ".husky"))) {
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) => existsSync7(join10(cwd, 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 (basename(dir) === "_" && basename(dirname4(dir)) === ".husky") {
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 (existsSync7(path) && !statSync3(path).isFile()) {
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 = existsSync7(path) ? readFileSync10(path, "utf8") : null;
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) => existsSync7(join10(cwd, 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 (!existsSync7(path)) {
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 existsSync9 } from "node:fs";
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 resolve3 } from "node:path";
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 existsSync8, readFileSync as readFileSync11 } from "node:fs";
7544
- import { isAbsolute as isAbsolute6, join as join11 } from "node:path";
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 isAbsolute6(d) ? d : join11(cwd, d);
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}:` + (existsSync8(p) ? readFileSync11(p, "utf8").trim() : ""));
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 = resolve3(cwd, "node_modules");
7763
- if (existsSync9(nm)) symlinkSync(nm, join13(dest, "node_modules"), "dir");
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 realpathSync2 } from "node:fs";
8099
- import { resolve as resolve4 } from "node:path";
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 = realpathSync2(cwd);
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 = realpathSync2(`/proc/${pid}/cwd`);
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 = resolve4(opts.cwd ?? process.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;