tamperward 2.8.0 → 2.9.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 +282 -123
  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);
@@ -2707,8 +2708,41 @@ 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;
@@ -3825,7 +3861,8 @@ var WRAPPERS = /^(?:sudo|command|exec|time|nice|env|[A-Za-z_][A-Za-z0-9_]*=\S*)$
3825
3861
  var INTERPRETERS = /* @__PURE__ */ new Set(["python", "python2", "python3", "node", "perl", "ruby", "php", "deno", "bun"]);
3826
3862
  var INLINE_FLAG = /^-(?:c|e|r|E|p|pe|pi|i|ne|ni|le)$|^--eval$|^--print$|^--exec$/;
3827
3863
  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"];
3864
+ var SCRIPT_WORD = /[\w.~/$%{}\\-]+/g;
3865
+ 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
3866
  function nameGlob(pattern) {
3830
3867
  const re = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
3831
3868
  return (n) => re.test(n);
@@ -3864,24 +3901,56 @@ function destinations(cmd, args, isDir) {
3864
3901
  return [dest];
3865
3902
  }
3866
3903
  var REDIRECT_TARGET = /(?:^|\s)(?:\d*>{1,2}\|?|&>{1,2})\s*(\S+)/g;
3904
+ function expandShellPath(t, env = process.env) {
3905
+ const home = homeDir(env);
3906
+ 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));
3907
+ }
3867
3908
  function hookTests(policy, ctx) {
3909
+ const cwd = ctx?.cwd;
3910
+ const onDisk = cwd !== void 0 && existsSync(cwd);
3911
+ const root = onDisk ? canonicalPath(cwd) : cwd;
3912
+ const memo = /* @__PURE__ */ new Map();
3868
3913
  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(/^\.\/(?=.)/, "");
3914
+ const hit = memo.get(t);
3915
+ if (hit !== void 0) return hit;
3916
+ let p = expandShellPath(t.replace(/^["']|["']$/g, ""));
3917
+ const pathy = isAbsolute2(p) || p.includes("/") && !/^[-:]/.test(p);
3918
+ if (pathy && (isAbsolute2(p) || onDisk)) {
3919
+ p = canonicalPath(isAbsolute2(p) ? p : resolve(cwd, p));
3920
+ if (root !== void 0) {
3921
+ if (p === root) p = ".";
3922
+ else if (p.startsWith(root + "/")) p = p.slice(root.length + 1);
3923
+ }
3924
+ }
3925
+ if (cwd !== void 0 && p.startsWith(cwd + "/")) p = p.slice(cwd.length + 1);
3926
+ p = p.replace(/^\.\/(?=.)/, "");
3927
+ memo.set(t, p);
3928
+ return p;
3929
+ };
3930
+ const hook = (t) => {
3931
+ const p = rel(t);
3932
+ return isProtected(p, policy, "hooks") || isClaudeSettings(p) || isClaudeSettings(expandShellPath(t.replace(/^["']|["']$/g, "")));
3872
3933
  };
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));
3934
+ const whole = (t) => /^(?:\.|\.\/|:\/|\*)$/.test(rel(t)) || cwd !== void 0 && rel(t) === cwd;
3935
+ const userSettings = `${claudeConfigDir()}/settings.json`;
3936
+ const userSettingsUnder = (t) => {
3937
+ const dir = rel(t).replace(/\/+$/, "");
3938
+ return dir !== "" && dir !== "/" && userSettings.startsWith(dir + "/") ? userSettings : null;
3939
+ };
3940
+ const holds = (t) => !t.startsWith("-") && (whole(t) || userSettingsUnder(t) !== null || containsProtected(rel(t), policy, "hooks", ctx));
3876
3941
  const hookish = (t) => hook(t) || holds(t);
3877
- return { rel, hook, whole, holds, hookish };
3942
+ return { rel, hook, whole, holds, hookish, userSettingsUnder };
3878
3943
  }
3879
3944
  function shellHookTarget(seg, toks, policy, ctx) {
3880
- const { rel, hook, whole, holds } = hookTests(policy, ctx);
3945
+ const { rel, hook, whole, holds, userSettingsUnder } = hookTests(policy, ctx);
3881
3946
  for (const m of seg.matchAll(REDIRECT_TARGET)) {
3882
3947
  if (hook(m[1])) return rel(m[1]);
3883
3948
  }
3884
3949
  const args = toks.map((t) => t.replace(/^(?:of=|--(?:include|exclude|directory)=)/, ""));
3950
+ for (let i = 1; i < args.length; i++) {
3951
+ if (!INLINE_FLAG.test(args[i - 1])) continue;
3952
+ for (const word of args[i].match(SCRIPT_WORD) ?? []) if (hook(word)) return rel(word);
3953
+ }
3885
3954
  const fileLike = (p) => {
3886
3955
  const b = p.replace(/\/+$/, "").split("/").pop() ?? "";
3887
3956
  return HOOK_BASENAMES.includes(b) || /\.[A-Za-z0-9]+$/.test(b.replace(/^\.+/, "")) || (trackedFiles(ctx)?.includes(p) ?? false);
@@ -3889,6 +3958,8 @@ function shellHookTarget(seg, toks, policy, ctx) {
3889
3958
  for (const t of args) if (hook(t) && fileLike(rel(t))) return rel(t);
3890
3959
  for (const t of args) {
3891
3960
  if (t.startsWith("-") || !holds(t)) continue;
3961
+ const user = userSettingsUnder(t);
3962
+ if (user !== null) return user;
3892
3963
  const dir = rel(t).replace(/\/+$/, "");
3893
3964
  const under = whole(t) ? "" : dir + "/";
3894
3965
  const listed = trackedFiles(ctx)?.find((f) => f.startsWith(under) && isProtected(f, policy, "hooks"));
@@ -3912,7 +3983,7 @@ function shellWritesHook(seg, toks, policy, ctx) {
3912
3983
  const anyHook = args.some(hook);
3913
3984
  const inPlace = args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a));
3914
3985
  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));
3986
+ const namesHookInText = () => scripts.some((a) => (a.match(SCRIPT_WORD) ?? []).some(hook));
3916
3987
  switch (cmd) {
3917
3988
  case "rm":
3918
3989
  case "unlink":
@@ -3996,8 +4067,8 @@ function shellWritesHook(seg, toks, policy, ctx) {
3996
4067
  function xargsWritesHook(toks, fed, policy, ctx) {
3997
4068
  const at = toks.findIndex((t) => t === "xargs" || t.endsWith("/xargs"));
3998
4069
  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));
4070
+ const { hookish } = hookTests(policy, ctx);
4071
+ const fedHook = fed.some(hookish);
4001
4072
  if (!fedHook) return null;
4002
4073
  let i = at + 1;
4003
4074
  while (i < toks.length && toks[i].startsWith("-")) i += /^-(?:I|n|L|P|d|a|s)$/.test(toks[i]) ? 2 : 1;
@@ -4029,11 +4100,23 @@ function eventEntries(s, event) {
4029
4100
  return Array.isArray(arr) ? arr : null;
4030
4101
  }
4031
4102
  var declares = (s, event) => eventEntries(s, event) !== null;
4032
- var GATE_KEYS = /* @__PURE__ */ new Set(["type", "command", "timeout"]);
4103
+ var GATE_KEYS = /* @__PURE__ */ new Set(["type", "command", "timeout", "statusMessage"]);
4104
+ var HOOK_TYPES = /* @__PURE__ */ new Set(["command", "prompt", "agent", "http"]);
4105
+ var SIBLING_KEYS = GATE_KEYS;
4033
4106
  var CANONICAL_CMD = /^npx --yes tamperward@(\S+) (hook|sweep) claude$/;
4034
4107
  var MENTIONS_GATE = /\btamperward\b.*\b(?:hook|sweep)\b/;
4035
4108
  var FORGERY = /hookSpecificOutput|permissionDecision|updatedInput|"decision"|"continue"/;
4036
- var SENSITIVE_ENV = /^(?:PATH|NODE_OPTIONS|NODE_PATH|HOME|npm_config_.*|NPM_CONFIG_.*)$/;
4109
+ var ENV_MECHANISM = [
4110
+ [/^PATH$/, "the gate's launcher is looked up through it"],
4111
+ [/^(?:NODE_OPTIONS|NODE_PATH)$/, "node loads what it names before the gate runs"],
4112
+ [/^HOME$/, "npx's cache and configuration live under it"],
4113
+ [/^npm_config_/i, "npx reads it as npm configuration, the registry the gate is fetched from included"],
4114
+ [/^(?:BASH_ENV|ENV)$/, "a non-interactive shell sources it before every hook command"],
4115
+ [/^BASH_FUNC_/, "bash imports it as a function that shadows the command it names"],
4116
+ [/^(?:LD|DYLD)_/, "the dynamic loader injects it into every process, the gate included"],
4117
+ [/^TAMPERWARD_/, "it configures the gate itself"]
4118
+ ];
4119
+ var envMechanism = (k) => ENV_MECHANISM.find(([re]) => re.test(k))?.[1] ?? null;
4037
4120
  function analyseGateCommand(cmd, event) {
4038
4121
  const want = event === "PreToolUse" ? "hook" : "sweep";
4039
4122
  const problems = [];
@@ -4092,7 +4175,7 @@ function matcherSelection(m) {
4092
4175
  if (m === "" || m === "*") return { tools: null, problem: null };
4093
4176
  let problem = null;
4094
4177
  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 };
4178
+ if (/^[A-Za-z0-9_ ,|-]+$/.test(m)) return { tools: new Set(m.split(/[|,]/).map((t) => t.trim()).filter(Boolean)), problem };
4096
4179
  let re;
4097
4180
  try {
4098
4181
  re = new RegExp(m);
@@ -4101,48 +4184,58 @@ function matcherSelection(m) {
4101
4184
  }
4102
4185
  return { tools: new Set(PRE_TOOLS.filter((t) => re.test(t))), problem };
4103
4186
  }
4187
+ function entryShape(h) {
4188
+ const problems = [];
4189
+ let runs = true;
4190
+ if (h.type !== "command") {
4191
+ problems.push(h.type === void 0 ? "no `type`" : `\`type\` is ${JSON.stringify(h.type)}`);
4192
+ if (h.type !== void 0) runs = false;
4193
+ }
4194
+ for (const k of Object.keys(h)) {
4195
+ if (GATE_KEYS.has(k)) continue;
4196
+ problems.push(`carries \`${k}\``);
4197
+ runs = false;
4198
+ }
4199
+ if ("timeout" in h) {
4200
+ const t = h.timeout;
4201
+ if (typeof t !== "number" || !Number.isInteger(t) || t < 120) {
4202
+ problems.push(`\`timeout\` ${JSON.stringify(t)} \u2014 the gate is cut off before it answers`);
4203
+ runs = false;
4204
+ }
4205
+ }
4206
+ if ("statusMessage" in h && typeof h.statusMessage !== "string") {
4207
+ problems.push(`\`statusMessage\` ${JSON.stringify(h.statusMessage)} is not a string`);
4208
+ runs = false;
4209
+ }
4210
+ return { problems, runs };
4211
+ }
4212
+ var mentionsGate = (v) => /\btamperward\b/.test(JSON.stringify(v) ?? "");
4104
4213
  function gateCandidates(s, event) {
4105
4214
  const out2 = [];
4106
- const mentions = (v) => /\btamperward\b/.test(JSON.stringify(v) ?? "");
4107
4215
  for (const group of eventEntries(s, event) ?? []) {
4108
4216
  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 });
4217
+ if (mentionsGate(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["the matcher entry is not an object"], pin: null, tools: null });
4110
4218
  continue;
4111
4219
  }
4112
4220
  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 });
4221
+ if (mentionsGate(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["`hooks` is not a list"], pin: null, tools: null });
4114
4222
  continue;
4115
4223
  }
4116
4224
  for (const h of group.hooks) {
4117
4225
  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 });
4226
+ if (mentionsGate(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the hook entry is not an object"], pin: null, tools: null });
4119
4227
  continue;
4120
4228
  }
4121
4229
  const cmd = typeof h.command === "string" ? h.command : null;
4122
4230
  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 });
4231
+ 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
4232
  continue;
4125
4233
  }
4126
4234
  if (!MENTIONS_GATE.test(cmd)) continue;
4127
4235
  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
- }
4236
+ const shape = entryShape(h);
4237
+ const problems = [...a.problems, ...shape.problems];
4238
+ let runs = a.runs && shape.runs;
4146
4239
  let tools = null;
4147
4240
  if (event === "PreToolUse") {
4148
4241
  const sel = matcherSelection(group.matcher);
@@ -4159,6 +4252,7 @@ function gateCandidates(s, event) {
4159
4252
  }
4160
4253
  return out2;
4161
4254
  }
4255
+ var carriesGate = (s) => EVENTS.some((e) => gateCandidates(s, e).length > 0);
4162
4256
  function otherCommands(s, event) {
4163
4257
  const out2 = [];
4164
4258
  for (const group of eventEntries(s, event) ?? []) {
@@ -4170,6 +4264,35 @@ function otherCommands(s, event) {
4170
4264
  }
4171
4265
  return out2;
4172
4266
  }
4267
+ function allEntries(s) {
4268
+ const hooks = s.hooks;
4269
+ if (!isObj(hooks)) return [];
4270
+ const out2 = [];
4271
+ for (const [event, arr] of Object.entries(hooks)) {
4272
+ if (!Array.isArray(arr)) continue;
4273
+ for (const group of arr) {
4274
+ if (!isObj(group) || !Array.isArray(group.hooks)) continue;
4275
+ for (const h of group.hooks) out2.push({ event, key: JSON.stringify({ e: event, m: group.matcher, h }), group, h });
4276
+ }
4277
+ }
4278
+ return out2;
4279
+ }
4280
+ function schemaProblem(group, h) {
4281
+ if (group.matcher !== void 0 && typeof group.matcher !== "string") return `the matcher ${JSON.stringify(group.matcher)} is not a string`;
4282
+ if (!isObj(h)) return `the hook entry ${JSON.stringify(h)} is not an object`;
4283
+ if (typeof h.type !== "string" || !HOOK_TYPES.has(h.type)) return `\`type\` ${JSON.stringify(h.type)} is not one the runtime accepts`;
4284
+ if (h.type === "command" && typeof h.command !== "string") return "`command` is not a string";
4285
+ 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`;
4286
+ return null;
4287
+ }
4288
+ function siblingProblem(h) {
4289
+ const cmd = typeof h.command === "string" ? h.command : "";
4290
+ if (FORGERY.test(cmd)) return `emits a hook decision of its own (\`${cmd.slice(0, 60)}\`)`;
4291
+ if (h.type !== "command") return `is not a plain command entry (\`type\` is ${JSON.stringify(h.type)})`;
4292
+ const extra = Object.keys(h).filter((k) => !SIBLING_KEYS.has(k));
4293
+ if (extra.length) return `is not a plain command entry (carries \`${extra.join("`, `")}\`)`;
4294
+ 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`;
4295
+ }
4173
4296
  function coverage(cs) {
4174
4297
  const live = cs.filter((c) => c.runs);
4175
4298
  if (live.length === 0) return "none";
@@ -4220,16 +4343,26 @@ function duplicateJsonKeys(src) {
4220
4343
  }
4221
4344
  function settingsWeakening(before, after, local) {
4222
4345
  const reasons = [];
4346
+ const gated = carriesGate(after);
4347
+ const gatedBefore = before !== null && carriesGate(before);
4223
4348
  const dis = after.disableAllHooks;
4224
4349
  if (dis !== void 0 && dis !== false && !(before !== null && before.disableAllHooks === dis)) {
4225
4350
  reasons.push(`\`disableAllHooks: ${JSON.stringify(dis)}\` switches every hook off, the gate included`);
4226
4351
  }
4352
+ if (gated && dis === void 0) {
4353
+ if (before !== null && before.disableAllHooks === false) {
4354
+ reasons.push("`disableAllHooks: false` was removed \u2014 init declares it so that a `true` in the user settings file cannot switch the gate off");
4355
+ } else if (!gatedBefore) {
4356
+ 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");
4357
+ }
4358
+ }
4227
4359
  if (isObj(after.env)) {
4228
4360
  const was = before !== null && isObj(before.env) ? before.env : {};
4229
4361
  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
- }
4362
+ if (k in was && JSON.stringify(was[k]) === JSON.stringify(v)) continue;
4363
+ const how = envMechanism(k);
4364
+ if (how) reasons.push(`a top-level \`env\` sets ${k} \u2014 ${how}`);
4365
+ else if (gated) reasons.push(`a top-level \`env\` sets ${k} \u2014 every hook command runs with it, and init writes no \`env\``);
4233
4366
  }
4234
4367
  }
4235
4368
  for (const event of EVENTS) {
@@ -4257,15 +4390,36 @@ function settingsWeakening(before, after, local) {
4257
4390
  if (c.problems.length) reasons.push(`the ${event} gate entry does not match the shape \`tamperward init\` writes: ${c.problems.join("; ")}`);
4258
4391
  }
4259
4392
  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}`);
4393
+ const judging = PLAIN_SEMVER.test(TW_VERSION) ? TW_VERSION : null;
4394
+ const floor = pins[0] ?? (bc.length === 0 ? judging : null);
4395
+ for (const c of fresh) {
4396
+ if (c.pin === null || c.pin !== "" && !PLAIN_SEMVER.test(c.pin)) continue;
4397
+ if (floor !== null && !pinNotBelow(c.pin, floor)) {
4398
+ reasons.push(
4399
+ 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}`
4400
+ );
4401
+ } else if (judging !== null && c.pin !== "" && (compareVersions(c.pin, judging) ?? 0) > 0) {
4402
+ 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`);
4403
+ }
4404
+ }
4405
+ }
4406
+ if (gated) {
4407
+ const had = new Set(before !== null ? allEntries(before).map((e) => e.key) : []);
4408
+ for (const e of allEntries(after)) {
4409
+ if (had.has(e.key) || mentionsGate(e.h)) continue;
4410
+ const schema = schemaProblem(e.group, e.h);
4411
+ if (schema) {
4412
+ 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`);
4413
+ } else if (e.event === "PreToolUse" || e.event === "Stop") {
4414
+ reasons.push(`a ${e.event} hook entry beside the gate ${siblingProblem(e.h)}`);
4264
4415
  }
4265
4416
  }
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)}\`)`);
4417
+ } else {
4418
+ for (const event of EVENTS) {
4419
+ const hadOther = new Set(before !== null ? otherCommands(before, event) : []);
4420
+ for (const cmd of otherCommands(after, event)) {
4421
+ 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)}\`)`);
4422
+ }
4269
4423
  }
4270
4424
  }
4271
4425
  return reasons;
@@ -4278,7 +4432,7 @@ function claudeSettingsFindings(c, policy) {
4278
4432
  file: c.path,
4279
4433
  message: `The Claude Code hook wiring was weakened: ${reason}.`,
4280
4434
  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."
4435
+ 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
4436
  });
4283
4437
  if (c.before != null && before === null) return null;
4284
4438
  if (after === null) {
@@ -4321,7 +4475,7 @@ function lefthookBase(c, changes, ctx) {
4321
4475
  for (const n of names) {
4322
4476
  try {
4323
4477
  const p = join3(ctx.cwd, n);
4324
- if (existsSync(p)) return parseDoc(readFileSync2(p, "utf8"));
4478
+ if (existsSync2(p)) return parseDoc(readFileSync2(p, "utf8"));
4325
4479
  } catch {
4326
4480
  }
4327
4481
  }
@@ -4391,9 +4545,9 @@ function updateIndexHiding(toks, policy) {
4391
4545
  return null;
4392
4546
  }
4393
4547
  function repoRelative(path, ctx) {
4394
- if (!ctx?.cwd || !isAbsolute(path)) return path;
4548
+ if (!ctx?.cwd || !isAbsolute3(path)) return path;
4395
4549
  const rel = relative(ctx.cwd, path);
4396
- return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
4550
+ return rel && !rel.startsWith("..") && !isAbsolute3(rel) ? rel : path;
4397
4551
  }
4398
4552
  var isHuskyScript = (path) => /(?:^|\/)\.husky\/[^/]+$/.test(path);
4399
4553
  var hookTampering = {
@@ -4405,7 +4559,7 @@ var hookTampering = {
4405
4559
  for (const c of changes) {
4406
4560
  let semantic;
4407
4561
  if (c.kind === "file") {
4408
- const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false) || isClaudeSettings(c.path);
4562
+ const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false) || resolvesToClaudeSettings(c.path);
4409
4563
  if (!targetsHook) {
4410
4564
  if (isPackageJson(c.path) && c.before != null && c.after != null) {
4411
4565
  for (const reason of packageJsonWeakening(c.before, c.after)) {
@@ -4449,7 +4603,7 @@ var hookTampering = {
4449
4603
  remediation: "Renaming a hook out of place disables it. Restore the original path."
4450
4604
  })
4451
4605
  );
4452
- } else if ((c.path.endsWith(POLICY_FILE) || isClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c, policy)) !== null) {
4606
+ } else if ((c.path.endsWith(POLICY_FILE) || resolvesToClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c, policy)) !== null) {
4453
4607
  out2.push(...semantic);
4454
4608
  } else if (c.op === "rename") {
4455
4609
  } else if (isCodeowners(c.path)) {
@@ -4589,10 +4743,12 @@ var hookTampering = {
4589
4743
  const why = shellWritesHook(seg, toks, policy, ctx) ?? xargsWritesHook(toks, fed, policy, ctx);
4590
4744
  if (why) {
4591
4745
  const target = shellHookTarget(seg, toks, policy, ctx) ?? shellHookTarget("", fed, policy, ctx);
4746
+ const file = target ? repoRelative(target, ctx) : void 0;
4747
+ 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
4748
  out2.push(
4593
4749
  makeFinding(RULE6, policy, {
4594
- ...target ? { file: repoRelative(target, ctx) } : {},
4595
- message: `Hook tampering via shell: ${why}.`,
4750
+ ...file !== void 0 ? { file } : {},
4751
+ message: `Hook tampering via shell: ${why}${outside}.`,
4596
4752
  evidence: seg,
4597
4753
  remediation: "Leave the hooks in place. Mutating them from the shell is still tampering."
4598
4754
  })
@@ -5497,7 +5653,7 @@ var GUARDED_RULE = "hook-tampering";
5497
5653
  var BASELINE_HOOKS = defaultPolicy().protected.hooks;
5498
5654
  function isGuardedFinding(f) {
5499
5655
  if (f.rule !== GUARDED_RULE || !f.file) return false;
5500
- return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS) || isClaudeSettings(f.file);
5656
+ return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS) || resolvesToClaudeSettings(f.file);
5501
5657
  }
5502
5658
  function key(f) {
5503
5659
  return `${f.rule}|${f.file ?? ""}|${f.line ?? ""}|${f.evidence}`;
@@ -5556,8 +5712,8 @@ function hasBlocking(findings) {
5556
5712
  }
5557
5713
 
5558
5714
  // 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";
5715
+ import { readFileSync as readFileSync3, existsSync as existsSync3 } from "node:fs";
5716
+ import { isAbsolute as isAbsolute4, join as join4, posix as posix2 } from "node:path";
5561
5717
  import { parse as parse2 } from "yaml";
5562
5718
  var PolicyError = class extends Error {
5563
5719
  };
@@ -5574,8 +5730,8 @@ var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "strin
5574
5730
  var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
5575
5731
  var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "protected", "rules", "ignore", "signoff", "verify"]);
5576
5732
  function ledgerInsideRepo(ledger) {
5577
- if (!ledger.trim() || isAbsolute2(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
5578
- const norm2 = posix.normalize(ledger.replace(/\\/g, "/"));
5733
+ if (!ledger.trim() || isAbsolute4(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
5734
+ const norm2 = posix2.normalize(ledger.replace(/\\/g, "/"));
5579
5735
  return norm2 !== ".." && !norm2.startsWith("../") && !norm2.startsWith("/");
5580
5736
  }
5581
5737
  function validate(r, where) {
@@ -5682,7 +5838,7 @@ function parseOrThrow(src, where) {
5682
5838
  }
5683
5839
  function loadPolicy(cwd = process.cwd()) {
5684
5840
  const path = join4(cwd, POLICY_FILE);
5685
- if (!existsSync2(path)) return defaultPolicy();
5841
+ if (!existsSync3(path)) return defaultPolicy();
5686
5842
  return parseOrThrow(readFileSync3(path, "utf8"), POLICY_FILE);
5687
5843
  }
5688
5844
  function loadPolicyAt(rev, cwd) {
@@ -5693,7 +5849,7 @@ function loadPolicyAt(rev, cwd) {
5693
5849
 
5694
5850
  // src/signoff.ts
5695
5851
  import { createHash } from "node:crypto";
5696
- import { appendFileSync, mkdirSync, readFileSync as readFileSync4, existsSync as existsSync3 } from "node:fs";
5852
+ import { appendFileSync, mkdirSync, readFileSync as readFileSync4, existsSync as existsSync4 } from "node:fs";
5697
5853
  import { dirname as dirname2, join as join5 } from "node:path";
5698
5854
  var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
5699
5855
  function fingerprint(rule, file, evidence) {
@@ -5709,7 +5865,7 @@ function ledgerPath(cwd, policy) {
5709
5865
  }
5710
5866
  function readLedger(cwd, policy) {
5711
5867
  const p = ledgerPath(cwd, policy);
5712
- if (!existsSync3(p)) return [];
5868
+ if (!existsSync4(p)) return [];
5713
5869
  const out2 = [];
5714
5870
  for (const line of readFileSync4(p, "utf8").split("\n")) {
5715
5871
  if (!line.trim()) continue;
@@ -6081,14 +6237,14 @@ function runCheck(opts) {
6081
6237
  }
6082
6238
 
6083
6239
  // src/cli/hook.ts
6084
- import { readFileSync as readFileSync9, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync6 } from "node:fs";
6240
+ import { readFileSync as readFileSync9, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7 } from "node:fs";
6085
6241
  import { createHash as createHash4 } from "node:crypto";
6086
6242
 
6087
6243
  // src/adapters/claude/changes.ts
6088
6244
  import { execFileSync as execFileSync3 } from "node:child_process";
6089
6245
  import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
6090
6246
  import { tmpdir } from "node:os";
6091
- import { isAbsolute as isAbsolute3, join as join6, relative as relative2, resolve } from "node:path";
6247
+ import { isAbsolute as isAbsolute5, join as join6, relative as relative2, resolve as resolve2 } from "node:path";
6092
6248
  function asStr(v) {
6093
6249
  return typeof v === "string" ? v : "";
6094
6250
  }
@@ -6096,11 +6252,11 @@ function readDisk(path) {
6096
6252
  return textOf(inspectResolved(path));
6097
6253
  }
6098
6254
  function abs(path, cwd) {
6099
- return resolve(cwd, path);
6255
+ return resolve2(cwd, path);
6100
6256
  }
6101
6257
  function relForDisplay(path, cwd) {
6102
6258
  const rel = relative2(cwd, path);
6103
- return rel && !rel.startsWith("..") && !isAbsolute3(rel) ? rel : path;
6259
+ return rel && !rel.startsWith("..") && !isAbsolute5(rel) ? rel : path;
6104
6260
  }
6105
6261
  function applyEdit(content, oldStr, newStr) {
6106
6262
  if (content === null) return newStr;
@@ -6202,7 +6358,7 @@ function formatDenial(blocks) {
6202
6358
  }
6203
6359
 
6204
6360
  // src/session.ts
6205
- import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "node:fs";
6361
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "node:fs";
6206
6362
  import { dirname as dirname3, join as join7 } from "node:path";
6207
6363
  var UNSAFE = /[^A-Za-z0-9_-]/g;
6208
6364
  var SHA = /^[0-9a-f]{7,40}$/;
@@ -6216,7 +6372,7 @@ function turnBaseline(cwd, sessionId) {
6216
6372
  try {
6217
6373
  const p = baselinePath(cwd, sessionId);
6218
6374
  if (!p) return null;
6219
- if (existsSync4(p)) {
6375
+ if (existsSync5(p)) {
6220
6376
  const v = readFileSync5(p, "utf8").trim();
6221
6377
  if (SHA.test(v)) return v;
6222
6378
  }
@@ -6243,8 +6399,8 @@ function advanceTurnBaseline(cwd, sessionId) {
6243
6399
 
6244
6400
  // src/effect.ts
6245
6401
  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";
6402
+ import { existsSync as existsSync6, lstatSync as lstatSync2, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
6403
+ import { isAbsolute as isAbsolute6, join as join8 } from "node:path";
6248
6404
  import { execFileSync as execFileSync4 } from "node:child_process";
6249
6405
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
6250
6406
  var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
@@ -6256,7 +6412,7 @@ function gitDir2(cwd) {
6256
6412
  stdio: ["ignore", "pipe", "pipe"]
6257
6413
  }).trim();
6258
6414
  if (!d) return null;
6259
- return isAbsolute4(d) ? d : join8(cwd, d);
6415
+ return isAbsolute6(d) ? d : join8(cwd, d);
6260
6416
  } catch {
6261
6417
  return null;
6262
6418
  }
@@ -6335,7 +6491,7 @@ function driftBetween(expected, current) {
6335
6491
  return { changed, deleted, added };
6336
6492
  }
6337
6493
  function loadTree(p) {
6338
- if (!p || !existsSync5(p)) return null;
6494
+ if (!p || !existsSync6(p)) return null;
6339
6495
  try {
6340
6496
  return JSON.parse(readFileSync6(p, "utf8"));
6341
6497
  } catch {
@@ -6793,9 +6949,9 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
6793
6949
  } };
6794
6950
  const log = defaultEventLog(cwd);
6795
6951
  const cp = cursorPath(cwd, sessionId);
6796
- if (!existsSync6(log)) return none;
6952
+ if (!existsSync7(log)) return none;
6797
6953
  let offset = 0;
6798
- if (cp && existsSync6(cp)) {
6954
+ if (cp && existsSync7(cp)) {
6799
6955
  const n = Number(readFileSync9(cp, "utf8"));
6800
6956
  offset = Number.isFinite(n) ? n : 0;
6801
6957
  }
@@ -6949,8 +7105,8 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
6949
7105
  // src/cli/init.ts
6950
7106
  import { execFileSync as execFileSync5 } from "node:child_process";
6951
7107
  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";
7108
+ import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync, statSync as statSync3 } from "node:fs";
7109
+ import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute7, join as join10, relative as relative3, resolve as resolve3 } from "node:path";
6954
7110
  var errText3 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
6955
7111
  var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6956
7112
  function gitConfig(cwd, key2) {
@@ -7103,7 +7259,7 @@ function readWorkflowMark(src) {
7103
7259
  function toolSet(matcher) {
7104
7260
  const m = String(matcher ?? "").trim();
7105
7261
  if (m === "" || m === "*") return null;
7106
- return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
7262
+ return new Set(m.split(/[|,]/).map((t) => t.trim()).filter(Boolean));
7107
7263
  }
7108
7264
  function missingTools(matcher) {
7109
7265
  const have = toolSet(matcher);
@@ -7127,7 +7283,7 @@ function hooksShapeError(hooks) {
7127
7283
  }
7128
7284
  function planPolicy(cwd) {
7129
7285
  const path = join10(cwd, POLICY_FILE);
7130
- if (existsSync7(path)) {
7286
+ if (existsSync8(path)) {
7131
7287
  try {
7132
7288
  loadPolicy(cwd);
7133
7289
  } catch (e) {
@@ -7147,7 +7303,7 @@ function planClaudeHooks(cwd) {
7147
7303
  const rel = ".claude/settings.json";
7148
7304
  const path = join10(cwd, rel);
7149
7305
  let settings = {};
7150
- if (existsSync7(path)) {
7306
+ if (existsSync8(path)) {
7151
7307
  let parsed;
7152
7308
  try {
7153
7309
  parsed = JSON.parse(readFileSync10(path, "utf8"));
@@ -7176,7 +7332,8 @@ function planClaudeHooks(cwd) {
7176
7332
  const needStop = stopEntries.length === 0;
7177
7333
  const stale = preEntries.filter((m) => missingTools(m.matcher).length > 0);
7178
7334
  const repin = [...preEntries, ...stopEntries].flatMap((m) => m.hooks ?? []).filter((h) => stalePin(String(h.command ?? "")));
7179
- if (!needPre && !needStop && stale.length === 0 && repin.length === 0) {
7335
+ const needDisableFalse = settings.disableAllHooks !== false;
7336
+ if (!needPre && !needStop && stale.length === 0 && repin.length === 0 && !needDisableFalse) {
7180
7337
  return { item: "agent", path: rel, status: "ok", detail: "PreToolUse + Stop hooks already wired" };
7181
7338
  }
7182
7339
  const repairing = stale.length > 0 ? [...new Set(stale.flatMap((m) => missingTools(m.matcher)))] : [];
@@ -7184,12 +7341,13 @@ function planClaudeHooks(cwd) {
7184
7341
  return {
7185
7342
  item: "agent",
7186
7343
  path: rel,
7187
- status: existsSync7(path) ? "update" : "create",
7344
+ status: existsSync8(path) ? "update" : "create",
7188
7345
  detail: [
7189
7346
  needPre && "wire PreToolUse deny",
7190
7347
  needStop && "wire Stop sweep",
7191
7348
  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(", ")})`
7349
+ repin.length > 0 && `re-pin the hook commands to tamperward@${TW_VERSION} (was ${pins.join(", ")})`,
7350
+ 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
7351
  ].filter(Boolean).join(" + "),
7194
7352
  apply: () => {
7195
7353
  for (const h of repin) {
@@ -7208,6 +7366,7 @@ function planClaudeHooks(cwd) {
7208
7366
  if (needStop) {
7209
7367
  (hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
7210
7368
  }
7369
+ if (needDisableFalse) settings.disableAllHooks = false;
7211
7370
  mkdirSync5(dirname4(path), { recursive: true });
7212
7371
  writeFileSync5(path, JSON.stringify(settings, null, 2) + "\n");
7213
7372
  }
@@ -7240,14 +7399,14 @@ var MANAGED_HOOKS = [
7240
7399
  function hooksDir(cwd) {
7241
7400
  try {
7242
7401
  const out2 = execFileSync5("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
7243
- if (out2) return resolve2(cwd, out2);
7402
+ if (out2) return resolve3(cwd, out2);
7244
7403
  } catch {
7245
7404
  }
7246
- return existsSync7(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
7405
+ return existsSync8(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
7247
7406
  }
7248
7407
  function display(cwd, path) {
7249
7408
  const rel = relative3(cwd, path);
7250
- return rel && !rel.startsWith("..") && !isAbsolute5(rel) ? rel : path;
7409
+ return rel && !rel.startsWith("..") && !isAbsolute7(rel) ? rel : path;
7251
7410
  }
7252
7411
  function unconditionalExitAt(lines) {
7253
7412
  let depth = 0;
@@ -7270,17 +7429,17 @@ function planPreCommit(cwd) {
7270
7429
  const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
7271
7430
  let path;
7272
7431
  let note;
7273
- if (existsSync7(join10(cwd, ".husky"))) {
7432
+ if (existsSync8(join10(cwd, ".husky"))) {
7274
7433
  path = join10(cwd, ".husky", "pre-commit");
7275
7434
  note = "husky";
7276
7435
  } else {
7277
- const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync7(join10(cwd, c))));
7436
+ const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join10(cwd, c))));
7278
7437
  if (managed) {
7279
7438
  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
7439
  }
7281
7440
  const dir = hooksDir(cwd);
7282
7441
  if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
7283
- if (basename(dir) === "_" && basename(dirname4(dir)) === ".husky") {
7442
+ if (basename2(dir) === "_" && basename2(dirname4(dir)) === ".husky") {
7284
7443
  path = join10(dirname4(dir), "pre-commit");
7285
7444
  note = "husky";
7286
7445
  } else {
@@ -7290,10 +7449,10 @@ function planPreCommit(cwd) {
7290
7449
  }
7291
7450
  }
7292
7451
  const rel = display(cwd, path);
7293
- if (existsSync7(path) && !statSync3(path).isFile()) {
7452
+ if (existsSync8(path) && !statSync3(path).isFile()) {
7294
7453
  return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
7295
7454
  }
7296
- const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
7455
+ const existing = existsSync8(path) ? readFileSync10(path, "utf8") : null;
7297
7456
  if (existing === null) {
7298
7457
  return {
7299
7458
  item: "pre-commit",
@@ -7382,7 +7541,7 @@ ${block.join("\n")}
7382
7541
  function planCodeowners(cwd) {
7383
7542
  const rel = ".github/CODEOWNERS";
7384
7543
  const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
7385
- (r) => existsSync7(join10(cwd, r))
7544
+ (r) => existsSync8(join10(cwd, r))
7386
7545
  );
7387
7546
  const path = join10(cwd, existingRel ?? rel);
7388
7547
  const existing = existingRel ? readFileSync10(path, "utf8") : null;
@@ -7423,7 +7582,7 @@ function planWorkflow(cwd, force) {
7423
7582
  mkdirSync5(dirname4(path), { recursive: true });
7424
7583
  writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
7425
7584
  };
7426
- if (!existsSync7(path)) {
7585
+ if (!existsSync8(path)) {
7427
7586
  return {
7428
7587
  item: "ci",
7429
7588
  path: rel,
@@ -7533,15 +7692,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
7533
7692
  // src/cli/verify.ts
7534
7693
  import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
7535
7694
  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";
7695
+ 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
7696
  import { tmpdir as tmpdir2 } from "node:os";
7538
- import { dirname as dirname5, join as join13, resolve as resolve3 } from "node:path";
7697
+ import { dirname as dirname5, join as join13, resolve as resolve4 } from "node:path";
7539
7698
 
7540
7699
  // src/git/trusted.ts
7541
7700
  import { execFileSync as execFileSync6 } from "node:child_process";
7542
7701
  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";
7702
+ import { existsSync as existsSync9, readFileSync as readFileSync11 } from "node:fs";
7703
+ import { isAbsolute as isAbsolute8, join as join11 } from "node:path";
7545
7704
  function trustedGitEnv() {
7546
7705
  return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
7547
7706
  }
@@ -7554,7 +7713,7 @@ function gitDirOf(cwd) {
7554
7713
  env: trustedGitEnv()
7555
7714
  }).trim();
7556
7715
  if (!d) return null;
7557
- return isAbsolute6(d) ? d : join11(cwd, d);
7716
+ return isAbsolute8(d) ? d : join11(cwd, d);
7558
7717
  } catch {
7559
7718
  return null;
7560
7719
  }
@@ -7581,7 +7740,7 @@ function objectRewriteState(cwd) {
7581
7740
  }
7582
7741
  const p = join11(dir, rel);
7583
7742
  try {
7584
- parts.push(`${rel}:` + (existsSync8(p) ? readFileSync11(p, "utf8").trim() : ""));
7743
+ parts.push(`${rel}:` + (existsSync9(p) ? readFileSync11(p, "utf8").trim() : ""));
7585
7744
  } catch {
7586
7745
  parts.push(`${rel}:<unreadable>`);
7587
7746
  }
@@ -7759,8 +7918,8 @@ function materialize(cwd, dest) {
7759
7918
  }
7760
7919
  chmodSync2(out2, st.mode);
7761
7920
  }
7762
- const nm = resolve3(cwd, "node_modules");
7763
- if (existsSync9(nm)) symlinkSync(nm, join13(dest, "node_modules"), "dir");
7921
+ const nm = resolve4(cwd, "node_modules");
7922
+ if (existsSync10(nm)) symlinkSync(nm, join13(dest, "node_modules"), "dir");
7764
7923
  }
7765
7924
  function dropSymlink(p) {
7766
7925
  try {
@@ -8095,8 +8254,8 @@ function parseVerify(args) {
8095
8254
 
8096
8255
  // src/cli/run.ts
8097
8256
  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";
8257
+ import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync as realpathSync3 } from "node:fs";
8258
+ import { resolve as resolve5 } from "node:path";
8100
8259
  var out = (s) => process.stdout.write(s + "\n");
8101
8260
  var err = (s) => process.stderr.write(s + "\n");
8102
8261
  function git3(args, cwd) {
@@ -8113,7 +8272,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
8113
8272
  const out2 = [];
8114
8273
  let real;
8115
8274
  try {
8116
- real = realpathSync2(cwd);
8275
+ real = realpathSync3(cwd);
8117
8276
  } catch {
8118
8277
  return out2;
8119
8278
  }
@@ -8128,7 +8287,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
8128
8287
  if (n === process.pid) continue;
8129
8288
  let held;
8130
8289
  try {
8131
- held = realpathSync2(`/proc/${pid}/cwd`);
8290
+ held = realpathSync3(`/proc/${pid}/cwd`);
8132
8291
  } catch {
8133
8292
  continue;
8134
8293
  }
@@ -8143,7 +8302,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
8143
8302
  return out2;
8144
8303
  }
8145
8304
  function runEnvelope(opts) {
8146
- const cwd = resolve4(opts.cwd ?? process.cwd());
8305
+ const cwd = resolve5(opts.cwd ?? process.cwd());
8147
8306
  if (!opts.argv.length) {
8148
8307
  err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
8149
8308
  return 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
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",