tamperward 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -283,7 +283,7 @@ commands ignore what they do not know.
283
283
 
284
284
  | command | 0 | 1 | 2 |
285
285
  | --- | --- | --- | --- |
286
- | `check` | no blocking finding | at least one blocking finding | cannot evaluate: policy parse error, malformed `--diff` range, or no view given |
286
+ | `check` | no blocking finding | at least one blocking finding | cannot evaluate: policy parse error, malformed `--diff` range, no view given, not a git repository, or an unresolvable revision — any failure the gate cannot recover from is one clean `tamperward: …` line on stderr at exit 2, never a stack trace at exit 1 |
287
287
  | `verify` | `VERIFIED` — visible and pristine both green; or a `MASKED_FAILURE` cleared by an out-of-band `verify@<head-sha>` approval | `MASKED_FAILURE` (visible green, pristine red) or `SUITE_RED` | cannot verify, failing closed: no suite command, unresolvable base, `--require-ancestor` refused, budget exceeded, or the working or dependency tree moved during the run |
288
288
  | `run` | enforcement clean and the agent exited 0 — a non-zero agent exit is passed through unchanged | any blocking finding or masked failure, whatever the agent returned | cannot adjudicate: dirty start, policy error, verify cannot run |
289
289
  | `hook claude` / `sweep claude` | always — a deny is JSON on stdout at exit 0, never exit 2 | — | only for an unsupported agent name |
package/dist/cli/index.js CHANGED
@@ -443,6 +443,16 @@ function applyVersionGates(rules, version, gates = BLOCK_SINCE) {
443
443
  }
444
444
  return out2;
445
445
  }
446
+ function normalizeGlob(glob) {
447
+ return glob.startsWith("/") ? glob.replace(/^\/+/, "") : glob;
448
+ }
449
+ function mergeRules(base, user) {
450
+ const out2 = { ...base };
451
+ for (const [name, cfg] of Object.entries(user ?? {})) {
452
+ out2[name] = { ...out2[name] ?? {}, ...cfg ?? {} };
453
+ }
454
+ return out2;
455
+ }
446
456
  var cache = /* @__PURE__ */ new Map();
447
457
  function matcher(glob) {
448
458
  let m = cache.get(glob);
@@ -452,12 +462,12 @@ function matcher(glob) {
452
462
  }
453
463
  return m;
454
464
  }
455
- function matchesAny(path, globs) {
456
- return !!globs && globs.some((g) => matcher(g)(path));
465
+ function matchesAny(path, globs2) {
466
+ return !!globs2 && globs2.some((g) => matcher(g)(path));
457
467
  }
458
468
  function protectedCategory(path, policy) {
459
- for (const [cat, globs] of Object.entries(policy.protected ?? {})) {
460
- if (matchesAny(path, globs)) return cat;
469
+ for (const [cat, globs2] of Object.entries(policy.protected ?? {})) {
470
+ if (matchesAny(path, globs2)) return cat;
461
471
  }
462
472
  return null;
463
473
  }
@@ -473,9 +483,9 @@ function isIgnored(path, policy) {
473
483
  }
474
484
  function mergeProtected(base, user) {
475
485
  const out2 = { ...base };
476
- for (const [cat, globs] of Object.entries(user ?? {})) {
486
+ for (const [cat, globs2] of Object.entries(user ?? {})) {
477
487
  const have = new Set(out2[cat] ?? []);
478
- out2[cat] = [...out2[cat] ?? [], ...(globs ?? []).filter((g) => !have.has(g))];
488
+ out2[cat] = [...out2[cat] ?? [], ...(globs2 ?? []).filter((g) => !have.has(g))];
479
489
  }
480
490
  return out2;
481
491
  }
@@ -1164,6 +1174,7 @@ var ciTampering = {
1164
1174
 
1165
1175
  // src/detectors/policy-diff.ts
1166
1176
  import { parse } from "yaml";
1177
+ var globs = (list) => Array.isArray(list) ? list.filter((g) => typeof g === "string").map(normalizeGlob) : [];
1167
1178
  function safeParse(src) {
1168
1179
  try {
1169
1180
  const v = parse(src);
@@ -1177,12 +1188,19 @@ function effective(raw) {
1177
1188
  const v = raw.version;
1178
1189
  const version = typeof v === "number" && Number.isInteger(v) && v >= 1 ? v : 1;
1179
1190
  const base = defaultPolicy(version);
1191
+ const rules = { ...base.rules };
1192
+ for (const [name, cfg] of Object.entries(raw.rules ?? {})) {
1193
+ const over = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg : {};
1194
+ rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude: globs(over.exclude) } : {} };
1195
+ }
1196
+ const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list]) => [cat, globs(list)])) : void 0;
1180
1197
  return {
1181
1198
  version,
1182
- rules: { ...base.rules, ...raw.rules ?? {} },
1183
- ignore: raw.ignore ?? base.ignore ?? [],
1184
- protected: mergeProtected(base.protected, raw.protected),
1185
- requiredFor: raw.signoff?.required_for ?? raw.signoff?.requiredFor ?? base.signoff.requiredFor
1199
+ rules,
1200
+ ignore: raw.ignore ? globs(raw.ignore) : base.ignore ?? [],
1201
+ protected: mergeProtected(base.protected, userProtected),
1202
+ requiredFor: raw.signoff?.required_for ?? raw.signoff?.requiredFor ?? base.signoff.requiredFor,
1203
+ ledger: typeof raw.signoff?.ledger === "string" ? raw.signoff.ledger : base.signoff.ledger
1186
1204
  };
1187
1205
  }
1188
1206
  function policyWeakening(before, after) {
@@ -1234,6 +1252,9 @@ function policyWeakening(before, after) {
1234
1252
  if (be.requiredFor.includes("block") && !new Set(ae.requiredFor).has("block")) {
1235
1253
  reasons.push("sign-off no longer required for blocking findings");
1236
1254
  }
1255
+ if (ae.ledger !== be.ledger) {
1256
+ reasons.push(`sign-off ledger moved (${be.ledger} \u2192 ${ae.ledger}) \u2014 the local layer would trust a different file`);
1257
+ }
1237
1258
  const bv = b.verify;
1238
1259
  const av = a.verify;
1239
1260
  if (bv?.command) {
@@ -1789,7 +1810,7 @@ function hasBlocking(findings) {
1789
1810
 
1790
1811
  // src/policy-load.ts
1791
1812
  import { readFileSync as readFileSync2, existsSync } from "node:fs";
1792
- import { join as join2 } from "node:path";
1813
+ import { isAbsolute, join as join2, posix } from "node:path";
1793
1814
  import { parse as parse2 } from "yaml";
1794
1815
  var PolicyError = class extends Error {
1795
1816
  };
@@ -1804,11 +1825,21 @@ var SEVERITIES = ["block", "warn"];
1804
1825
  var isSeverity = (v) => SEVERITIES.includes(v);
1805
1826
  var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "string");
1806
1827
  var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1828
+ var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "protected", "rules", "ignore", "signoff", "verify"]);
1829
+ function ledgerInsideRepo(ledger) {
1830
+ if (!ledger.trim() || isAbsolute(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
1831
+ const norm2 = posix.normalize(ledger.replace(/\\/g, "/"));
1832
+ return norm2 !== ".." && !norm2.startsWith("../") && !norm2.startsWith("/");
1833
+ }
1807
1834
  function validate(r, where) {
1808
1835
  function bad(msg) {
1809
1836
  throw new PolicyError(`${where}: ${msg}`);
1810
1837
  }
1811
1838
  const show = (v) => JSON.stringify(v) ?? String(v);
1839
+ const unknown = Object.keys(r).filter((k) => !TOP_LEVEL_KEYS.has(k));
1840
+ if (unknown.length) {
1841
+ bad(`unknown top-level key${unknown.length === 1 ? "" : "s"} ${unknown.map((k) => JSON.stringify(k)).join(", ")} (expected one of ${[...TOP_LEVEL_KEYS].join(", ")})`);
1842
+ }
1812
1843
  if (r.rules !== void 0) {
1813
1844
  if (!isMapping(r.rules)) bad(`rules must be a mapping of rule name to { severity, enabled, exclude }, got ${show(r.rules)}`);
1814
1845
  for (const [name, cfg] of Object.entries(r.rules)) {
@@ -1827,8 +1858,8 @@ function validate(r, where) {
1827
1858
  if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
1828
1859
  if (r.protected !== void 0) {
1829
1860
  if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
1830
- for (const [cat, globs] of Object.entries(r.protected)) {
1831
- if (!isStringList(globs)) bad(`protected.${cat} must be a list of globs, got ${show(globs)}`);
1861
+ for (const [cat, globs2] of Object.entries(r.protected)) {
1862
+ if (!isStringList(globs2)) bad(`protected.${cat} must be a list of globs, got ${show(globs2)}`);
1832
1863
  }
1833
1864
  }
1834
1865
  if (r.signoff !== void 0) {
@@ -1841,6 +1872,9 @@ function validate(r, where) {
1841
1872
  }
1842
1873
  const ledger = r.signoff.ledger;
1843
1874
  if (ledger !== void 0 && typeof ledger !== "string") bad(`signoff.ledger must be a path, got ${show(ledger)}`);
1875
+ if (typeof ledger === "string" && !ledgerInsideRepo(ledger)) {
1876
+ bad(`signoff.ledger must be a relative path inside the repository, got ${show(ledger)}`);
1877
+ }
1844
1878
  }
1845
1879
  if (r.verify !== void 0) {
1846
1880
  if (!isMapping(r.verify)) bad(`verify must be a mapping like { command: "npm test" }, got ${show(r.verify)}`);
@@ -1857,14 +1891,20 @@ function parsePolicy(raw, where = POLICY_FILE) {
1857
1891
  const version = normalizeVersion(r.version, where);
1858
1892
  validate(r, where);
1859
1893
  const base = defaultPolicy(version);
1894
+ const globs2 = (list) => list?.map(normalizeGlob);
1895
+ const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat, globs2(list) ?? []])) : void 0;
1896
+ const userRules = r.rules ? Object.fromEntries(
1897
+ Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs2(cfg.exclude) } : cfg])
1898
+ ) : void 0;
1860
1899
  return {
1861
1900
  version,
1862
1901
  // Merge with the baseline, never replace. For an integrity tool, a config that sets
1863
1902
  // one rule's severity must NOT silently drop the other nine — nor may naming one
1864
- // protected glob wipe out the rest of its category (see mergeProtected).
1865
- protected: mergeProtected(base.protected, r.protected),
1866
- rules: { ...base.rules, ...r.rules ?? {} },
1867
- ignore: r.ignore ?? base.ignore,
1903
+ // protected glob wipe out the rest of its category (see mergeProtected), nor may
1904
+ // an `exclude`-only override drop the rule's baseline severity (see mergeRules).
1905
+ protected: mergeProtected(base.protected, userProtected),
1906
+ rules: mergeRules(base.rules, userRules),
1907
+ ignore: globs2(r.ignore) ?? base.ignore,
1868
1908
  signoff: {
1869
1909
  requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
1870
1910
  ledger: r.signoff?.ledger ?? base.signoff.ledger
@@ -1914,7 +1954,11 @@ function fingerprint(rule, file, evidence) {
1914
1954
  }
1915
1955
  var fingerprintOf = (f) => fingerprint(f.rule, f.file, f.evidence);
1916
1956
  function ledgerPath(cwd, policy) {
1917
- return join3(cwd, policy.signoff?.ledger ?? ".tamperward/ledger.jsonl");
1957
+ const rel = policy.signoff?.ledger ?? ".tamperward/ledger.jsonl";
1958
+ if (!ledgerInsideRepo(rel)) {
1959
+ throw new PolicyError(`signoff.ledger must be a relative path inside the repository, got ${JSON.stringify(rel)}`);
1960
+ }
1961
+ return join3(cwd, rel);
1918
1962
  }
1919
1963
  function readLedger(cwd, policy) {
1920
1964
  const p = ledgerPath(cwd, policy);
@@ -2008,7 +2052,7 @@ function paint(s, code, on) {
2008
2052
  function wrap(text, width) {
2009
2053
  const out2 = [];
2010
2054
  let cur = "";
2011
- for (const w of text.split(/\s+/).filter(Boolean)) {
2055
+ for (const w of stripControl(text).split(/\s+/).filter(Boolean)) {
2012
2056
  if (cur === "") cur = w;
2013
2057
  else if (cur.length + 1 + w.length <= width) cur += " " + w;
2014
2058
  else {
@@ -2073,15 +2117,15 @@ function renderText(input, opts) {
2073
2117
  for (const f of findings) {
2074
2118
  const isBlock = f.severity === "block";
2075
2119
  const mark = paint(isBlock ? "BLOCK" : "warn ", isBlock ? BOLD + RED : YELLOW, c);
2076
- const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""}` : "(command)";
2077
- out2.push(` ${mark} ${paint(f.rule, BOLD, c)} ${paint(loc, DIM, c)}`);
2120
+ const loc = f.file ? `${stripControl(f.file)}${f.line ? `:${f.line}` : ""}` : "(command)";
2121
+ out2.push(` ${mark} ${paint(stripControl(f.rule), BOLD, c)} ${paint(loc, DIM, c)}`);
2078
2122
  for (const l of wrap(f.message, opts.width - 4)) out2.push(` ${l}`);
2079
2123
  const row = (key2, lines) => {
2080
2124
  lines.forEach((l, i) => out2.push(` ${paint(pad(i === 0 ? key2 : ""), DIM, c)} ${l}`));
2081
2125
  };
2082
2126
  row("evidence", [paint(clip(f.evidence, body), DIM, c)]);
2083
2127
  row("instead", wrap(f.remediation, body));
2084
- if (f.signoff.required) row("sign-off", [f.signoff.command]);
2128
+ if (f.signoff.required) row("sign-off", [stripControl(f.signoff.command)]);
2085
2129
  out2.push("");
2086
2130
  }
2087
2131
  if (blocks.length > 0) {
@@ -2095,7 +2139,7 @@ function renderText(input, opts) {
2095
2139
  // src/cli/render/github.ts
2096
2140
  import { appendFileSync as appendFileSync2 } from "node:fs";
2097
2141
  function escData(s) {
2098
- return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
2142
+ return stripControl(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
2099
2143
  }
2100
2144
  function escProp(s) {
2101
2145
  return escData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
@@ -2114,7 +2158,7 @@ function annotations(findings) {
2114
2158
  });
2115
2159
  }
2116
2160
  function cell(s) {
2117
- return s.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
2161
+ return stripControl(s).replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
2118
2162
  }
2119
2163
  function mdCode(s) {
2120
2164
  return s.replace(/`/g, "'").replace(/[\r\n]/g, " ");
@@ -2222,8 +2266,13 @@ function report(input) {
2222
2266
 
2223
2267
  // src/cli/check.ts
2224
2268
  function check(opts) {
2225
- let policy = opts.policyOverride ?? loadPolicy(opts.cwd);
2226
2269
  const cwd = opts.cwd ?? process.cwd();
2270
+ if (!isGitRepo(cwd)) {
2271
+ process.stderr.write(`tamperward: ${cwd} is not inside a git repository \u2014 nothing to check
2272
+ `);
2273
+ return 2;
2274
+ }
2275
+ let policy = opts.policyOverride ?? loadPolicy(opts.cwd);
2227
2276
  let changes;
2228
2277
  let layer;
2229
2278
  let view;
@@ -2236,13 +2285,20 @@ function check(opts) {
2236
2285
  layer = opts.ciLayer ? "ci" : "local";
2237
2286
  view = "worktree";
2238
2287
  } else if (opts.diff) {
2239
- const [base, head] = opts.diff.split(/\.{2,3}/);
2240
- if (!base || !head) {
2288
+ const m = opts.diff.match(/^(.+?)\.{2,3}(.+)$/);
2289
+ const [base, head] = m ? [m[1], m[2]] : ["", ""];
2290
+ if (!base || !head || base.endsWith(".") || head.startsWith(".")) {
2241
2291
  process.stderr.write(`tamperward: invalid --diff range "${opts.diff}" (expected <base>...<head>)
2242
2292
  `);
2243
2293
  return 2;
2244
2294
  }
2245
2295
  changes = diffRange(base, head, { cwd: opts.cwd });
2296
+ if (changes.length === 0) {
2297
+ process.stderr.write(
2298
+ `tamperward: note: the range ${base}...${head} contains no changes \u2014 if this is not an empty pull request, check that the range resolves to the intended commits
2299
+ `
2300
+ );
2301
+ }
2246
2302
  layer = "ci";
2247
2303
  view = "range";
2248
2304
  policy = opts.policyOverride ?? loadPolicyAt(mergeBaseOf(base, head, { cwd: opts.cwd }), opts.cwd) ?? defaultPolicy();
@@ -2283,7 +2339,7 @@ import { createHash as createHash4 } from "node:crypto";
2283
2339
  import { execFileSync as execFileSync2 } from "node:child_process";
2284
2340
  import { readFileSync as readFileSync4, existsSync as existsSync3, writeFileSync, mkdtempSync, rmSync } from "node:fs";
2285
2341
  import { tmpdir } from "node:os";
2286
- import { isAbsolute, join as join4, relative, resolve } from "node:path";
2342
+ import { isAbsolute as isAbsolute2, join as join4, relative, resolve } from "node:path";
2287
2343
  function asStr(v) {
2288
2344
  return typeof v === "string" ? v : "";
2289
2345
  }
@@ -2299,7 +2355,7 @@ function abs(path, cwd) {
2299
2355
  }
2300
2356
  function relForDisplay(path, cwd) {
2301
2357
  const rel = relative(cwd, path);
2302
- return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
2358
+ return rel && !rel.startsWith("..") && !isAbsolute2(rel) ? rel : path;
2303
2359
  }
2304
2360
  function applyEdit(content, oldStr, newStr) {
2305
2361
  if (content === null) return newStr;
@@ -2443,7 +2499,7 @@ function advanceTurnBaseline(cwd, sessionId) {
2443
2499
  // src/effect.ts
2444
2500
  import { createHash as createHash2 } from "node:crypto";
2445
2501
  import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync, writeFileSync as writeFileSync3 } from "node:fs";
2446
- import { isAbsolute as isAbsolute2, join as join6 } from "node:path";
2502
+ import { isAbsolute as isAbsolute3, join as join6 } from "node:path";
2447
2503
  import { execFileSync as execFileSync3 } from "node:child_process";
2448
2504
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
2449
2505
  var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
@@ -2455,7 +2511,7 @@ function gitDir2(cwd) {
2455
2511
  stdio: ["ignore", "pipe", "pipe"]
2456
2512
  }).trim();
2457
2513
  if (!d) return null;
2458
- return isAbsolute2(d) ? d : join6(cwd, d);
2514
+ return isAbsolute3(d) ? d : join6(cwd, d);
2459
2515
  } catch {
2460
2516
  return null;
2461
2517
  }
@@ -2972,9 +3028,18 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
2972
3028
  // src/cli/init.ts
2973
3029
  import { execFileSync as execFileSync4 } from "node:child_process";
2974
3030
  import { createHash as createHash5 } from "node:crypto";
2975
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
2976
- import { dirname as dirname3, join as join8 } from "node:path";
3031
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
3032
+ import { basename, dirname as dirname3, isAbsolute as isAbsolute4, join as join8, relative as relative2, resolve as resolve2 } from "node:path";
2977
3033
  import { fileURLToPath } from "node:url";
3034
+ var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
3035
+ var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
3036
+ function gitConfig(cwd, key2) {
3037
+ try {
3038
+ return execFileSync4("git", ["config", "--get", key2], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
3039
+ } catch {
3040
+ return null;
3041
+ }
3042
+ }
2978
3043
  var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
2979
3044
  var MARKER = "# tamperward: block agent shortcuts before they land";
2980
3045
  var HOOK_RE = /\btamperward(?:@\S+)?\s+hook\s+claude\b/;
@@ -2987,25 +3052,28 @@ function coveredBy(existing, critical) {
2987
3052
  for (const line of existing.split("\n")) {
2988
3053
  const t = line.trim();
2989
3054
  if (!t || t.startsWith("#")) continue;
2990
- const pattern = t.split(/\s+/)[0];
2991
- if (!t.slice(pattern.length).trim()) continue;
2992
- if (pattern === "*" || pattern === critical) return true;
2993
- if (pattern.endsWith("/") && critical.startsWith(pattern)) return true;
3055
+ const raw = t.split(/\s+/)[0];
3056
+ if (!t.slice(raw.length).trim()) continue;
3057
+ if (raw === "*" || raw === "**") return true;
3058
+ const pattern = raw.replace(/\/\*{1,2}$/, "/");
3059
+ if (pattern.includes("*")) continue;
3060
+ const anchored = pattern.startsWith("/") ? pattern : "/" + pattern;
3061
+ if (anchored === critical) return true;
3062
+ if (anchored.endsWith("/") && critical.startsWith(anchored)) return true;
3063
+ if (!pattern.startsWith("/")) {
3064
+ if (critical.endsWith("/" + pattern)) return true;
3065
+ if (pattern.endsWith("/") && critical.includes("/" + pattern)) return true;
3066
+ }
2994
3067
  }
2995
3068
  return false;
2996
3069
  }
2997
3070
  function inferOwner(cwd) {
2998
- try {
2999
- const url = execFileSync4("git", ["remote", "get-url", "origin"], {
3000
- cwd,
3001
- encoding: "utf8",
3002
- stdio: ["ignore", "pipe", "ignore"]
3003
- }).trim();
3004
- const m = url.match(/github\.com[:/]+([^/]+)\//);
3005
- return m ? `@${m[1]}` : null;
3006
- } catch {
3007
- return null;
3008
- }
3071
+ const url = gitConfig(cwd, "remote.origin.url");
3072
+ const m = url?.match(/github\.com[:/]+([^/]+)\//);
3073
+ if (!m) return null;
3074
+ const segment = m[1];
3075
+ const self = [gitConfig(cwd, "github.user"), gitConfig(cwd, "user.name")].filter((v) => v !== null);
3076
+ return { owner: `@${segment}`, confirmed: self.some((v) => v.toLowerCase() === segment.toLowerCase()) };
3009
3077
  }
3010
3078
  var POLICY_CONTENT = `# Tamperward policy. The BASELINE (all rules, standard protected globs) applies even
3011
3079
  # without this file \u2014 everything here is an override, so an empty file changes nothing.
@@ -3143,9 +3211,31 @@ function missingTools(matcher2) {
3143
3211
  if (have === null) return [];
3144
3212
  return PRE_MATCHER.split("|").filter((t) => !have.has(t));
3145
3213
  }
3214
+ function hooksShapeError(hooks) {
3215
+ if (hooks === void 0 || hooks === null) return null;
3216
+ if (!isPlainObject(hooks)) return "hooks is not an object";
3217
+ for (const [event, arr] of Object.entries(hooks)) {
3218
+ if (arr === void 0 || arr === null) continue;
3219
+ if (!Array.isArray(arr)) return `hooks.${event} is not a list`;
3220
+ for (const m of arr) {
3221
+ if (!isPlainObject(m)) return `hooks.${event} contains an entry that is not an object`;
3222
+ if (m.hooks !== void 0 && m.hooks !== null && !(Array.isArray(m.hooks) && m.hooks.every(isPlainObject))) {
3223
+ return `hooks.${event} has a "hooks" value that is not a list of { type, command }`;
3224
+ }
3225
+ }
3226
+ }
3227
+ return null;
3228
+ }
3146
3229
  function planPolicy(cwd) {
3147
3230
  const path = join8(cwd, POLICY_FILE);
3148
- if (existsSync7(path)) return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present \u2014 left untouched" };
3231
+ if (existsSync7(path)) {
3232
+ try {
3233
+ loadPolicy(cwd);
3234
+ } catch (e) {
3235
+ return { item: "policy", path: POLICY_FILE, status: "error", detail: `present but does not load \u2014 ${errText2(e)}; fix it, then re-run init (left untouched)` };
3236
+ }
3237
+ return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present and loads \u2014 left untouched" };
3238
+ }
3149
3239
  return {
3150
3240
  item: "policy",
3151
3241
  path: POLICY_FILE,
@@ -3175,6 +3265,10 @@ function planClaudeHooks(cwd) {
3175
3265
  }
3176
3266
  settings = parsed;
3177
3267
  }
3268
+ const shapeError = hooksShapeError(settings.hooks);
3269
+ if (shapeError) {
3270
+ return { item: "agent", path: rel, status: "error", detail: `${shapeError} \u2014 fix it, then re-run init (refusing to overwrite)` };
3271
+ }
3178
3272
  const hooks = settings.hooks ??= {};
3179
3273
  const entriesWith = (arr, needle) => (arr ?? []).filter((m) => (m.hooks ?? []).some((h) => needle.test(String(h.command ?? ""))));
3180
3274
  const preEntries = entriesWith(hooks.PreToolUse, HOOK_RE);
@@ -3220,47 +3314,170 @@ function planClaudeHooks(cwd) {
3220
3314
  }
3221
3315
  };
3222
3316
  }
3317
+ var MANAGED_HOOKS = [
3318
+ {
3319
+ name: "lefthook",
3320
+ configs: ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml", "lefthook-local.yml", "lefthook-local.yaml"],
3321
+ hookRe: /\blefthook\b/i,
3322
+ hint: `add this under \`pre-commit:\` \u2192 \`commands:\` in lefthook.yml:
3323
+ tamperward:
3324
+ run: ${PRECOMMIT_CMD}`
3325
+ },
3326
+ {
3327
+ name: "pre-commit",
3328
+ configs: [".pre-commit-config.yaml", ".pre-commit-config.yml"],
3329
+ hookRe: /pre-commit\.com|File generated by pre-commit/i,
3330
+ hint: `add this under \`repos:\` in .pre-commit-config.yaml:
3331
+ - repo: local
3332
+ hooks:
3333
+ - id: tamperward
3334
+ name: tamperward
3335
+ entry: ${PRECOMMIT_CMD}
3336
+ language: system
3337
+ pass_filenames: false
3338
+ always_run: true`
3339
+ }
3340
+ ];
3341
+ function hooksDir(cwd) {
3342
+ try {
3343
+ const out2 = execFileSync4("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
3344
+ if (out2) return resolve2(cwd, out2);
3345
+ } catch {
3346
+ }
3347
+ return existsSync7(join8(cwd, ".git")) ? join8(cwd, ".git", "hooks") : null;
3348
+ }
3349
+ function display(cwd, path) {
3350
+ const rel = relative2(cwd, path);
3351
+ return rel && !rel.startsWith("..") && !isAbsolute4(rel) ? rel : path;
3352
+ }
3353
+ function unconditionalExitAt(lines) {
3354
+ let depth = 0;
3355
+ for (let i = 0; i < lines.length; i++) {
3356
+ const t = lines[i].trim();
3357
+ if (!t || t.startsWith("#")) continue;
3358
+ if (depth === 0) {
3359
+ if (/^exit(\s|$|;)/.test(t)) return i;
3360
+ const exec = t.match(/^exec(?:\s+(.*))?$/);
3361
+ if (exec && exec[1] && !/^(\d*[<>]|&>)/.test(exec[1])) return i;
3362
+ }
3363
+ const opens = (t.match(/(?:^|[;(]\s*|&&\s*|\|\|\s*)(?:if|case|for|while|until)\s/g) ?? []).length + (/(?:^|\s)\{\s*$/.test(t) ? 1 : 0);
3364
+ const closes = (t.match(/(?:^|[;]\s*)(?:fi|esac|done)(?:\s|;|$)/g) ?? []).length + (/^\}/.test(t) ? 1 : 0);
3365
+ depth = Math.max(0, depth + opens - closes);
3366
+ }
3367
+ return -1;
3368
+ }
3223
3369
  function planPreCommit(cwd) {
3224
- const line = `${MARKER}
3225
- ${PRECOMMIT_CMD}
3226
- `;
3227
- const husky = join8(cwd, ".husky");
3228
- const gitDir3 = join8(cwd, ".git");
3229
- const target = existsSync7(husky) ? { rel: ".husky/pre-commit", note: "husky" } : existsSync7(gitDir3) ? { rel: ".git/hooks/pre-commit", note: "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" } : null;
3230
- if (!target) return { item: "pre-commit", path: "(none)", status: "skip", detail: "not a git repo and no .husky/ \u2014 nothing to wire" };
3231
- const path = join8(cwd, target.rel);
3370
+ const block = [MARKER, PRECOMMIT_CMD];
3371
+ const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
3372
+ let path;
3373
+ let note;
3374
+ if (existsSync7(join8(cwd, ".husky"))) {
3375
+ path = join8(cwd, ".husky", "pre-commit");
3376
+ note = "husky";
3377
+ } else {
3378
+ const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync7(join8(cwd, c))));
3379
+ if (managed) {
3380
+ 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}`);
3381
+ }
3382
+ const dir = hooksDir(cwd);
3383
+ if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
3384
+ if (basename(dir) === "_" && basename(dirname3(dir)) === ".husky") {
3385
+ path = join8(dirname3(dir), "pre-commit");
3386
+ note = "husky";
3387
+ } else {
3388
+ path = join8(dir, "pre-commit");
3389
+ const rel2 = display(cwd, dir);
3390
+ note = rel2 === join8(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
3391
+ }
3392
+ }
3393
+ const rel = display(cwd, path);
3394
+ if (existsSync7(path) && !statSync5(path).isFile()) {
3395
+ return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
3396
+ }
3232
3397
  const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
3233
- if (existing !== null && PRECOMMIT_RE.test(existing)) {
3234
- const lines = existing.split("\n");
3235
- const staleAt = lines.findIndex((l) => PRECOMMIT_RE.test(l) && stalePin(l));
3236
- if (staleAt === -1) {
3237
- return { item: "pre-commit", path: target.rel, status: "ok", detail: "already runs the staged check" };
3398
+ if (existing === null) {
3399
+ return {
3400
+ item: "pre-commit",
3401
+ path: rel,
3402
+ status: "create",
3403
+ detail: `create via ${note}`,
3404
+ apply: () => {
3405
+ mkdirSync5(dirname3(path), { recursive: true });
3406
+ writeFileSync5(path, `#!/bin/sh
3407
+ ${block.join("\n")}
3408
+ `);
3409
+ chmodSync(path, 493);
3410
+ }
3411
+ };
3412
+ }
3413
+ const owner = MANAGED_HOOKS.find((m) => m.hookRe.test(existing));
3414
+ if (owner) {
3415
+ return { item: "pre-commit", path: rel, status: "skip", detail: `generated by ${owner.name}, which overwrites it on install \u2014 ${owner.hint}` };
3416
+ }
3417
+ const lines = existing.split("\n");
3418
+ const dead = unconditionalExitAt(lines);
3419
+ const at = lines.findIndex((l) => PRECOMMIT_RE.test(l));
3420
+ const write = (out2) => {
3421
+ writeFileSync5(path, out2.join("\n"));
3422
+ chmodSync(path, 493);
3423
+ };
3424
+ if (at !== -1 && (dead === -1 || at < dead)) {
3425
+ if (!stalePin(lines[at])) {
3426
+ return { item: "pre-commit", path: rel, status: "ok", detail: "already runs the staged check" };
3238
3427
  }
3239
- const was = pinOf(lines[staleAt]) || "unpinned";
3428
+ const was = pinOf(lines[at]) || "unpinned";
3240
3429
  return {
3241
3430
  item: "pre-commit",
3242
- path: target.rel,
3431
+ path: rel,
3243
3432
  status: "update",
3244
3433
  detail: `re-pin the staged check to tamperward@${TW_VERSION} (was ${was})`,
3245
3434
  apply: () => {
3246
- lines[staleAt] = PRECOMMIT_CMD;
3247
- writeFileSync5(path, lines.join("\n"));
3248
- chmodSync(path, 493);
3435
+ lines[at] = PRECOMMIT_CMD;
3436
+ write(lines);
3437
+ }
3438
+ };
3439
+ }
3440
+ if (at !== -1) {
3441
+ const ours = pinOf(lines[at]) !== null && lines[at - 1]?.trim() === MARKER;
3442
+ if (!ours) {
3443
+ return {
3444
+ item: "pre-commit",
3445
+ path: rel,
3446
+ status: "error",
3447
+ detail: `the staged check on line ${at + 1} never runs: line ${dead + 1} (\`${lines[dead].trim()}\`) ends the script first \u2014 move the tamperward line above it`
3448
+ };
3449
+ }
3450
+ return {
3451
+ item: "pre-commit",
3452
+ path: rel,
3453
+ status: "update",
3454
+ detail: `move the staged check above line ${dead + 1} (\`${lines[dead].trim()}\`), which ended the script before it ran`,
3455
+ apply: () => {
3456
+ const out2 = lines.filter((_, i) => i !== at && i !== at - 1);
3457
+ out2.splice(dead, 0, ...block);
3458
+ write(out2);
3459
+ }
3460
+ };
3461
+ }
3462
+ if (dead !== -1) {
3463
+ return {
3464
+ item: "pre-commit",
3465
+ path: rel,
3466
+ status: "update",
3467
+ detail: `insert the staged check above line ${dead + 1} (\`${lines[dead].trim()}\`), which ends the script \u2014 ${note}`,
3468
+ apply: () => {
3469
+ const out2 = [...lines];
3470
+ out2.splice(dead, 0, ...block);
3471
+ write(out2);
3249
3472
  }
3250
3473
  };
3251
3474
  }
3252
3475
  return {
3253
3476
  item: "pre-commit",
3254
- path: target.rel,
3255
- status: existing === null ? "create" : "update",
3256
- detail: existing === null ? `create via ${target.note}` : `append the staged check (${target.note})`,
3257
- apply: () => {
3258
- mkdirSync5(dirname3(path), { recursive: true });
3259
- const content = existing === null ? `#!/bin/sh
3260
- ${line}` : existing.replace(/\n?$/, "\n") + line;
3261
- writeFileSync5(path, content);
3262
- chmodSync(path, 493);
3263
- }
3477
+ path: rel,
3478
+ status: "update",
3479
+ detail: `append the staged check (${note})`,
3480
+ apply: () => write([...existing.replace(/\n?$/, "\n").split("\n").slice(0, -1), ...block, ""])
3264
3481
  };
3265
3482
  }
3266
3483
  function planCodeowners(cwd) {
@@ -3274,18 +3491,26 @@ function planCodeowners(cwd) {
3274
3491
  if (missing.length === 0) {
3275
3492
  return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
3276
3493
  }
3277
- const owner = inferOwner(cwd);
3494
+ const inferred = inferOwner(cwd);
3495
+ const owner = inferred?.owner ?? null;
3496
+ const unconfirmed = inferred !== null && !inferred.confirmed;
3278
3497
  const block = `
3279
3498
  ${CODEOWNERS_MARK}
3280
3499
  # A pull request that rewrites the workflow can keep the job name, replace the
3281
3500
  # gate with \`true\`, and still report a green required check. Only a human
3282
3501
  # requirement on these paths prevents that.
3283
- ` + (owner === null ? "# REPLACE @OWNER BELOW with a real user or team \u2014 an unresolvable owner protects nothing.\n" : "") + missing.map((p) => `${p.padEnd(24)} ${owner ?? "@OWNER"}`).join("\n") + "\n";
3502
+ ` + (owner === null ? "# REPLACE @OWNER BELOW with a real user or team \u2014 an unresolvable owner protects nothing.\n" : unconfirmed ? `# TODO: replace ${owner} with ${owner}/<team> if "${owner.slice(1)}" is an organisation. GitHub
3503
+ # rejects a bare organisation as an owner, and a rule with an invalid owner protects
3504
+ # nothing. A user login is fine as written.
3505
+ ` : "") + missing.map((p) => `${p.padEnd(24)} ${owner ?? "@OWNER"}`).join("\n") + "\n";
3284
3506
  return {
3285
3507
  item: "codeowners",
3286
3508
  path: existingRel ?? rel,
3287
3509
  status: existing === null ? "create" : "update",
3288
- detail: (existing === null ? "require a code owner on " : "add missing code-owner rules for ") + missing.join(", ") + (owner === null ? " \u2014 OWNER UNKNOWN, edit @OWNER before this does anything" : ` (${owner})`),
3510
+ detail: (existing === null ? "require a code owner on " : "add missing code-owner rules for ") + missing.join(", ") + (owner === null ? " \u2014 OWNER UNKNOWN, edit @OWNER before this does anything" : ` (${owner}${unconfirmed ? ", unconfirmed \u2014 see warning below" : ""})`),
3511
+ ...inferred && unconfirmed ? {
3512
+ warning: `${inferred.owner} was inferred from the origin remote and is not confirmed to be a user. If "${inferred.owner.slice(1)}" is an organisation, replace it with ${inferred.owner}/<team> in CODEOWNERS: GitHub rejects a bare organisation as an owner, and a rule with an invalid owner protects nothing.`
3513
+ } : {},
3289
3514
  apply: () => {
3290
3515
  mkdirSync5(dirname3(path), { recursive: true });
3291
3516
  writeFileSync5(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
@@ -3344,21 +3569,49 @@ function planWorkflow(cwd, force) {
3344
3569
  detail: (mark ? `edited since tamperward v${mark.version} generated it` : "present but not generated by tamperward") + ` \u2014 left untouched. Compare it against the v${TW_VERSION} template (an older one may lack the pristine-verify step) and re-run with --force-workflow to replace it.`
3345
3570
  };
3346
3571
  }
3572
+ function planned(item, path, plan) {
3573
+ try {
3574
+ return plan();
3575
+ } catch (e) {
3576
+ return { item, path, status: "error", detail: `cannot plan this item \u2014 ${errText2(e)}` };
3577
+ }
3578
+ }
3347
3579
  function planInit(cwd, opts = {}) {
3348
- return [planPolicy(cwd), planClaudeHooks(cwd), planPreCommit(cwd), planWorkflow(cwd, opts.forceWorkflow ?? false), planCodeowners(cwd)];
3580
+ return [
3581
+ planned("policy", POLICY_FILE, () => planPolicy(cwd)),
3582
+ planned("agent", ".claude/settings.json", () => planClaudeHooks(cwd)),
3583
+ planned("pre-commit", "(hooks)", () => planPreCommit(cwd)),
3584
+ planned("ci", ".github/workflows/tamperward.yml", () => planWorkflow(cwd, opts.forceWorkflow ?? false)),
3585
+ planned("codeowners", ".github/CODEOWNERS", () => planCodeowners(cwd))
3586
+ ];
3349
3587
  }
3350
3588
  function runInit(opts) {
3351
3589
  const cwd = opts.cwd ?? process.cwd();
3352
3590
  const plan = planInit(cwd, { forceWorkflow: opts.forceWorkflow });
3353
3591
  const w = process.stdout;
3592
+ let applied = 0;
3354
3593
  for (const a of plan) {
3594
+ if (!opts.dryRun && a.apply) {
3595
+ try {
3596
+ a.apply();
3597
+ applied++;
3598
+ } catch (e) {
3599
+ a.status = "error";
3600
+ a.detail = `could not be written \u2014 ${errText2(e)}`;
3601
+ a.apply = void 0;
3602
+ }
3603
+ }
3355
3604
  const verb = opts.dryRun && (a.status === "create" || a.status === "update") ? `would ${a.status}` : a.status;
3356
3605
  w.write(` ${a.item.padEnd(10)} ${verb.padEnd(12)} ${a.path} \u2014 ${a.detail}
3357
3606
  `);
3358
- if (!opts.dryRun && a.apply) a.apply();
3607
+ }
3608
+ for (const a of plan) {
3609
+ if (a.warning) w.write(`
3610
+ WARNING (${a.item}): ${a.warning}
3611
+ `);
3359
3612
  }
3360
3613
  const errors = plan.filter((a) => a.status === "error");
3361
- const changed = plan.filter((a) => a.apply).length;
3614
+ const changed = opts.dryRun ? plan.filter((a) => a.apply).length : applied;
3362
3615
  if (errors.length) {
3363
3616
  w.write(`
3364
3617
  tamperward init: ${errors.length} item(s) need your attention above; the rest ${opts.dryRun ? "are planned" : "were applied"}.
@@ -3381,15 +3634,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
3381
3634
  // src/cli/verify.ts
3382
3635
  import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
3383
3636
  import { createHash as createHash8 } from "node:crypto";
3384
- import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as statSync6, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "node:fs";
3637
+ import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "node:fs";
3385
3638
  import { tmpdir as tmpdir2 } from "node:os";
3386
- import { dirname as dirname4, join as join11, resolve as resolve2 } from "node:path";
3639
+ import { dirname as dirname4, join as join11, resolve as resolve3 } from "node:path";
3387
3640
 
3388
3641
  // src/git/trusted.ts
3389
3642
  import { execFileSync as execFileSync5 } from "node:child_process";
3390
3643
  import { createHash as createHash6 } from "node:crypto";
3391
3644
  import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
3392
- import { isAbsolute as isAbsolute3, join as join9 } from "node:path";
3645
+ import { isAbsolute as isAbsolute5, join as join9 } from "node:path";
3393
3646
  function trustedGitEnv() {
3394
3647
  return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
3395
3648
  }
@@ -3402,7 +3655,7 @@ function gitDirOf(cwd) {
3402
3655
  env: trustedGitEnv()
3403
3656
  }).trim();
3404
3657
  if (!d) return null;
3405
- return isAbsolute3(d) ? d : join9(cwd, d);
3658
+ return isAbsolute5(d) ? d : join9(cwd, d);
3406
3659
  } catch {
3407
3660
  return null;
3408
3661
  }
@@ -3440,7 +3693,7 @@ function objectRewriteState(cwd) {
3440
3693
  // src/fingerprint.ts
3441
3694
  import { execFileSync as execFileSync6 } from "node:child_process";
3442
3695
  import { createHash as createHash7 } from "node:crypto";
3443
- import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as statSync5 } from "node:fs";
3696
+ import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as statSync6 } from "node:fs";
3444
3697
  import { join as join10 } from "node:path";
3445
3698
  function treeFingerprint(cwd) {
3446
3699
  const files = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
@@ -3479,7 +3732,7 @@ function depsFingerprint(cwd) {
3479
3732
  h.update(p);
3480
3733
  try {
3481
3734
  h.update(readFileSync12(p));
3482
- h.update(String(statSync5(p).mode));
3735
+ h.update(String(statSync6(p).mode));
3483
3736
  } catch {
3484
3737
  h.update("<unreadable>");
3485
3738
  }
@@ -3494,7 +3747,7 @@ function depsFingerprint(cwd) {
3494
3747
  }
3495
3748
  };
3496
3749
  try {
3497
- if (!statSync5(root).isDirectory()) return "none";
3750
+ if (!statSync6(root).isDirectory()) return "none";
3498
3751
  } catch {
3499
3752
  return "none";
3500
3753
  }
@@ -3552,7 +3805,7 @@ function materialize(cwd, dest) {
3552
3805
  const src = join11(cwd, rel);
3553
3806
  let st;
3554
3807
  try {
3555
- st = statSync6(src);
3808
+ st = statSync7(src);
3556
3809
  } catch {
3557
3810
  continue;
3558
3811
  }
@@ -3567,7 +3820,7 @@ function materialize(cwd, dest) {
3567
3820
  }
3568
3821
  chmodSync2(out2, st.mode);
3569
3822
  }
3570
- const nm = resolve2(cwd, "node_modules");
3823
+ const nm = resolve3(cwd, "node_modules");
3571
3824
  if (existsSync9(nm)) symlinkSync(nm, join11(dest, "node_modules"), "dir");
3572
3825
  }
3573
3826
  function dropSymlink(p) {
@@ -3583,9 +3836,9 @@ function verifierInputs(cmd, atBase, policy) {
3583
3836
  const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
3584
3837
  if (tok && baseFiles.has(tok)) picked.add(tok);
3585
3838
  }
3586
- const globs = policy.verify?.inputs;
3587
- if (globs && globs.length) {
3588
- for (const rel of atBase) if (matchesAny(rel, globs)) picked.add(rel);
3839
+ const globs2 = policy.verify?.inputs;
3840
+ if (globs2 && globs2.length) {
3841
+ for (const rel of atBase) if (matchesAny(rel, globs2)) picked.add(rel);
3589
3842
  }
3590
3843
  return picked;
3591
3844
  }
@@ -3857,7 +4110,7 @@ function parseVerify(args) {
3857
4110
  // src/cli/run.ts
3858
4111
  import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
3859
4112
  import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync } from "node:fs";
3860
- import { resolve as resolve3 } from "node:path";
4113
+ import { resolve as resolve4 } from "node:path";
3861
4114
  var out = (s) => process.stdout.write(s + "\n");
3862
4115
  var err = (s) => process.stderr.write(s + "\n");
3863
4116
  function git3(args, cwd) {
@@ -3904,7 +4157,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
3904
4157
  return out2;
3905
4158
  }
3906
4159
  function runEnvelope(opts) {
3907
- const cwd = resolve3(opts.cwd ?? process.cwd());
4160
+ const cwd = resolve4(opts.cwd ?? process.cwd());
3908
4161
  if (!opts.argv.length) {
3909
4162
  err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
3910
4163
  return 2;
@@ -4041,7 +4294,7 @@ function parseRun(args) {
4041
4294
  return o;
4042
4295
  }
4043
4296
 
4044
- // src/cli/index.ts
4297
+ // src/cli/main.ts
4045
4298
  function parseAllow(args) {
4046
4299
  const o = {};
4047
4300
  for (let i = 0; i < args.length; i++) {
@@ -4149,9 +4402,11 @@ Formats:
4149
4402
  Exit codes: 0 clean \xB7 1 a blocking finding (check), MASKED_FAILURE or SUITE_RED
4150
4403
  (verify), any blocking finding or masked failure (run) \xB7 2 cannot
4151
4404
  evaluate, failing closed: policy error, bad --diff range, no view,
4152
- unresolvable base, --require-ancestor refused, budget exceeded.
4153
- hook/sweep \u2192 0 with a deny emitted as JSON on stdout (exit 2 would make
4154
- Claude Code ignore the JSON); 2 only for an unsupported agent name.
4405
+ not a git repository, unresolvable base or revision, --require-ancestor
4406
+ refused, budget exceeded \u2014 always one line on stderr, never a stack
4407
+ trace. hook/sweep \u2192 0 with a deny emitted as JSON on stdout (exit 2
4408
+ would make Claude Code ignore the JSON); 2 only for an unsupported
4409
+ agent name.
4155
4410
  `);
4156
4411
  }
4157
4412
  function main(argv2) {
@@ -4183,9 +4438,21 @@ function main(argv2) {
4183
4438
  return 2;
4184
4439
  }
4185
4440
  }
4441
+ function guardedMain(argv2, stderr = process.stderr) {
4442
+ try {
4443
+ return main(argv2);
4444
+ } catch (e) {
4445
+ const first = (e instanceof Error ? e.message : String(e)).split("\n").map((l) => l.trim()).find(Boolean) ?? "";
4446
+ stderr.write(`tamperward: ${first || "unexpected failure"}
4447
+ `);
4448
+ return 2;
4449
+ }
4450
+ }
4451
+
4452
+ // src/cli/index.ts
4186
4453
  var argv = process.argv.slice(2);
4187
4454
  if (argv[0] === "watch") {
4188
4455
  runWatch(argv.slice(1));
4189
4456
  } else {
4190
- process.exit(main(argv));
4457
+ process.exit(guardedMain(argv));
4191
4458
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "2.1.1",
3
+ "version": "2.2.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",