tamperward 2.1.0 → 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
@@ -37,7 +37,8 @@ impossible.
37
37
  > and hardened. Use it as one layer of defence in depth alongside protected CI,
38
38
  > independent tests and human review. Findings, limitations and corrections are
39
39
  > published openly. The 2.0 major marks the Node 18 drop, not a declaration of
40
- > security maturity.
40
+ > security maturity; the distance to that is tracked, milestone by milestone, in
41
+ > [SPEC §9.1](./SPEC.md#91-maturity-milestones).
41
42
 
42
43
  ## What we have actually measured
43
44
 
@@ -63,7 +64,7 @@ Earlier controlled experiments → **[the research series](./docs/blog/index.md)
63
64
  > **Scope.** The rows above cover specific models, pressure prompts, treatment
64
65
  > versions, and finite JavaScript/TypeScript and Python repository samples —
65
66
  > evidence for those settings, not a universal claim. Round 2 tested
66
- > the v1.9.0 stack; the current **v2.0.0** adds post-study envelope hardening
67
+ > the v1.9.0 stack; the current **2.x** line adds post-study envelope hardening
67
68
  > (externally reviewed, with findings tracked individually as REPRO or AUDIT in
68
69
  > [SECURITY-ENVELOPE.md](./SECURITY-ENVELOPE.md) and closed with regression and
69
70
  > mutation checks — see [CHANGELOG](./CHANGELOG.md)). Rounds 3 and 3.1 are
@@ -282,7 +283,7 @@ commands ignore what they do not know.
282
283
 
283
284
  | command | 0 | 1 | 2 |
284
285
  | --- | --- | --- | --- |
285
- | `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 |
286
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 |
287
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 |
288
289
  | `hook claude` / `sweep claude` | always — a deny is JSON on stdout at exit 0, never exit 2 | — | only for an unsupported agent name |
@@ -352,7 +353,8 @@ and its totals.
352
353
 
353
354
  **[The research series](./docs/blog/index.md)** ·
354
355
  **[The harness](./harness/)** ·
355
- **[Errata](./docs/blog/errata.md)**
356
+ **[Errata](./docs/blog/errata.md)** ·
357
+ **[Maturity milestones](./SPEC.md#91-maturity-milestones)**
356
358
 
357
359
  ## The architecture was earned by failures
358
360
 
@@ -393,7 +395,7 @@ via npm trusted publishing with SLSA provenance. Full rule:
393
395
 
394
396
  ```bash
395
397
  npm install && npm run build # bundles the CLI to dist/cli/index.js
396
- npm test # 520+ tests at 2.0.0 — parser, detectors, engine, policy, renderers
398
+ npm test # 540+ tests — parser, detectors, engine, policy, renderers
397
399
  npm run typecheck
398
400
  ```
399
401
 
package/dist/cli/index.js CHANGED
@@ -33,6 +33,11 @@ function unquotePath(p) {
33
33
  }
34
34
  return Buffer.from(bytes).toString("utf8");
35
35
  }
36
+ function pathToken(raw) {
37
+ if (raw.startsWith('"')) return raw;
38
+ const tab = raw.indexOf(" ");
39
+ return tab >= 0 ? raw.slice(0, tab) : raw;
40
+ }
36
41
  function endOfQuoted(s) {
37
42
  for (let i = 1; i < s.length; i++) {
38
43
  if (s[i] === "\\") {
@@ -59,10 +64,17 @@ function headerPaths(line) {
59
64
  first = rest.slice(0, q);
60
65
  rest = rest.slice(q + 1);
61
66
  } else {
62
- const m = rest.match(/^(.*) (b\/.*)$/);
63
- if (!m) return [null, null];
64
- first = m[1];
65
- rest = m[2];
67
+ const mid = (rest.length - 1) / 2;
68
+ const sym = Number.isInteger(mid) && rest[mid] === " " && rest.startsWith("a/") && rest.slice(mid + 1).startsWith("b/") && rest.slice(2, mid) === rest.slice(mid + 3);
69
+ if (sym) {
70
+ first = rest.slice(0, mid);
71
+ rest = rest.slice(mid + 1);
72
+ } else {
73
+ const m = rest.match(/^(.*) (b\/.*)$/);
74
+ if (!m) return [null, null];
75
+ first = m[1];
76
+ rest = m[2];
77
+ }
66
78
  }
67
79
  }
68
80
  return [stripAB(first), stripAB(rest)];
@@ -126,8 +138,8 @@ function parseDiff(diff) {
126
138
  renameTo = unquotePath(l.slice(8));
127
139
  if (op === "modify") op = "rename";
128
140
  } else if (l.startsWith("Binary files ") || l.startsWith("GIT binary patch")) binary = true;
129
- else if (l.startsWith("--- ")) minusPath = l.slice(4);
130
- else if (l.startsWith("+++ ")) plusPath = l.slice(4);
141
+ else if (l.startsWith("--- ")) minusPath = pathToken(l.slice(4));
142
+ else if (l.startsWith("+++ ")) plusPath = pathToken(l.slice(4));
131
143
  i++;
132
144
  }
133
145
  let path;
@@ -196,7 +208,12 @@ function git(args, cwd) {
196
208
  return execFileSync("git", args, {
197
209
  cwd,
198
210
  encoding: "utf8",
199
- maxBuffer: 64 * 1024 * 1024,
211
+ // 256 MiB, matching the hook adapter. Every diff below runs with `--text`, so a
212
+ // changed binary asset now costs its full size in patch output instead of one
213
+ // "Binary files differ" line; a legitimate golden-image update must fit. Past
214
+ // the cap git() throws and the view fails CLOSED (a truncated patch would parse
215
+ // as a smaller change than the one landing), which is the documented stance.
216
+ maxBuffer: 256 * 1024 * 1024,
200
217
  stdio: ["ignore", "pipe", "pipe"]
201
218
  });
202
219
  } catch (e) {
@@ -220,7 +237,7 @@ function fromDisk(path, cwd) {
220
237
  }
221
238
  }
222
239
  function enrich(c, beforeReader, afterReader) {
223
- if (c.kind !== "file" || c.binary) return c;
240
+ if (c.kind !== "file") return c;
224
241
  const before = c.op !== "add" ? beforeReader(c.oldPath ?? c.path) : null;
225
242
  const after = c.op !== "delete" ? afterReader(c.path) : null;
226
243
  return { ...c, before, after };
@@ -261,8 +278,9 @@ function headSha(cwd) {
261
278
  return null;
262
279
  }
263
280
  }
281
+ var DIFF_ARGS = ["diff", "--no-color", "--text", "-M"];
264
282
  function diffSince(rev, opts = {}) {
265
- const raw = git(["diff", "--no-color", "-M", rev], opts.cwd);
283
+ const raw = git([...DIFF_ARGS, rev], opts.cwd);
266
284
  return parseDiff(raw).map(
267
285
  (c) => enrich(c, (p) => blobAt(rev, p, opts.cwd), (p) => fromDisk(p, opts.cwd))
268
286
  );
@@ -270,20 +288,20 @@ function diffSince(rev, opts = {}) {
270
288
  function diffRange(base, head, opts = {}) {
271
289
  assertRev(base);
272
290
  assertRev(head);
273
- const raw = git(["diff", "--no-color", "-M", `${base}...${head}`], opts.cwd);
291
+ const raw = git([...DIFF_ARGS, `${base}...${head}`], opts.cwd);
274
292
  const mergeBase = mergeBaseOf(base, head, opts);
275
293
  return parseDiff(raw).map(
276
294
  (c) => enrich(c, (p) => blobAt(mergeBase, p, opts.cwd), (p) => blobAt(head, p, opts.cwd))
277
295
  );
278
296
  }
279
297
  function diffStaged(opts = {}) {
280
- const raw = git(["diff", "--no-color", "-M", "--cached"], opts.cwd);
298
+ const raw = git([...DIFF_ARGS, "--cached"], opts.cwd);
281
299
  return parseDiff(raw).map(
282
300
  (c) => enrich(c, (p) => blobAt("HEAD", p, opts.cwd), (p) => blobAt("", p, opts.cwd))
283
301
  );
284
302
  }
285
303
  function diffWorktree(opts = {}) {
286
- const raw = git(["diff", "--no-color", "-M", "HEAD"], opts.cwd);
304
+ const raw = git([...DIFF_ARGS, "HEAD"], opts.cwd);
287
305
  return parseDiff(raw).map(
288
306
  (c) => enrich(c, (p) => blobAt("HEAD", p, opts.cwd), (p) => fromDisk(p, opts.cwd))
289
307
  );
@@ -425,6 +443,16 @@ function applyVersionGates(rules, version, gates = BLOCK_SINCE) {
425
443
  }
426
444
  return out2;
427
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
+ }
428
456
  var cache = /* @__PURE__ */ new Map();
429
457
  function matcher(glob) {
430
458
  let m = cache.get(glob);
@@ -434,12 +462,12 @@ function matcher(glob) {
434
462
  }
435
463
  return m;
436
464
  }
437
- function matchesAny(path, globs) {
438
- return !!globs && globs.some((g) => matcher(g)(path));
465
+ function matchesAny(path, globs2) {
466
+ return !!globs2 && globs2.some((g) => matcher(g)(path));
439
467
  }
440
468
  function protectedCategory(path, policy) {
441
- for (const [cat, globs] of Object.entries(policy.protected ?? {})) {
442
- if (matchesAny(path, globs)) return cat;
469
+ for (const [cat, globs2] of Object.entries(policy.protected ?? {})) {
470
+ if (matchesAny(path, globs2)) return cat;
443
471
  }
444
472
  return null;
445
473
  }
@@ -455,9 +483,9 @@ function isIgnored(path, policy) {
455
483
  }
456
484
  function mergeProtected(base, user) {
457
485
  const out2 = { ...base };
458
- for (const [cat, globs] of Object.entries(user ?? {})) {
486
+ for (const [cat, globs2] of Object.entries(user ?? {})) {
459
487
  const have = new Set(out2[cat] ?? []);
460
- out2[cat] = [...out2[cat] ?? [], ...(globs ?? []).filter((g) => !have.has(g))];
488
+ out2[cat] = [...out2[cat] ?? [], ...(globs2 ?? []).filter((g) => !have.has(g))];
461
489
  }
462
490
  return out2;
463
491
  }
@@ -1146,6 +1174,7 @@ var ciTampering = {
1146
1174
 
1147
1175
  // src/detectors/policy-diff.ts
1148
1176
  import { parse } from "yaml";
1177
+ var globs = (list) => Array.isArray(list) ? list.filter((g) => typeof g === "string").map(normalizeGlob) : [];
1149
1178
  function safeParse(src) {
1150
1179
  try {
1151
1180
  const v = parse(src);
@@ -1159,12 +1188,19 @@ function effective(raw) {
1159
1188
  const v = raw.version;
1160
1189
  const version = typeof v === "number" && Number.isInteger(v) && v >= 1 ? v : 1;
1161
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;
1162
1197
  return {
1163
1198
  version,
1164
- rules: { ...base.rules, ...raw.rules ?? {} },
1165
- ignore: raw.ignore ?? base.ignore ?? [],
1166
- protected: mergeProtected(base.protected, raw.protected),
1167
- 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
1168
1204
  };
1169
1205
  }
1170
1206
  function policyWeakening(before, after) {
@@ -1216,6 +1252,9 @@ function policyWeakening(before, after) {
1216
1252
  if (be.requiredFor.includes("block") && !new Set(ae.requiredFor).has("block")) {
1217
1253
  reasons.push("sign-off no longer required for blocking findings");
1218
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
+ }
1219
1258
  const bv = b.verify;
1220
1259
  const av = a.verify;
1221
1260
  if (bv?.command) {
@@ -1771,7 +1810,7 @@ function hasBlocking(findings) {
1771
1810
 
1772
1811
  // src/policy-load.ts
1773
1812
  import { readFileSync as readFileSync2, existsSync } from "node:fs";
1774
- import { join as join2 } from "node:path";
1813
+ import { isAbsolute, join as join2, posix } from "node:path";
1775
1814
  import { parse as parse2 } from "yaml";
1776
1815
  var PolicyError = class extends Error {
1777
1816
  };
@@ -1786,11 +1825,21 @@ var SEVERITIES = ["block", "warn"];
1786
1825
  var isSeverity = (v) => SEVERITIES.includes(v);
1787
1826
  var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "string");
1788
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
+ }
1789
1834
  function validate(r, where) {
1790
1835
  function bad(msg) {
1791
1836
  throw new PolicyError(`${where}: ${msg}`);
1792
1837
  }
1793
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
+ }
1794
1843
  if (r.rules !== void 0) {
1795
1844
  if (!isMapping(r.rules)) bad(`rules must be a mapping of rule name to { severity, enabled, exclude }, got ${show(r.rules)}`);
1796
1845
  for (const [name, cfg] of Object.entries(r.rules)) {
@@ -1809,8 +1858,8 @@ function validate(r, where) {
1809
1858
  if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
1810
1859
  if (r.protected !== void 0) {
1811
1860
  if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
1812
- for (const [cat, globs] of Object.entries(r.protected)) {
1813
- 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)}`);
1814
1863
  }
1815
1864
  }
1816
1865
  if (r.signoff !== void 0) {
@@ -1823,6 +1872,9 @@ function validate(r, where) {
1823
1872
  }
1824
1873
  const ledger = r.signoff.ledger;
1825
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
+ }
1826
1878
  }
1827
1879
  if (r.verify !== void 0) {
1828
1880
  if (!isMapping(r.verify)) bad(`verify must be a mapping like { command: "npm test" }, got ${show(r.verify)}`);
@@ -1839,14 +1891,20 @@ function parsePolicy(raw, where = POLICY_FILE) {
1839
1891
  const version = normalizeVersion(r.version, where);
1840
1892
  validate(r, where);
1841
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;
1842
1899
  return {
1843
1900
  version,
1844
1901
  // Merge with the baseline, never replace. For an integrity tool, a config that sets
1845
1902
  // one rule's severity must NOT silently drop the other nine — nor may naming one
1846
- // protected glob wipe out the rest of its category (see mergeProtected).
1847
- protected: mergeProtected(base.protected, r.protected),
1848
- rules: { ...base.rules, ...r.rules ?? {} },
1849
- 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,
1850
1908
  signoff: {
1851
1909
  requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
1852
1910
  ledger: r.signoff?.ledger ?? base.signoff.ledger
@@ -1896,7 +1954,11 @@ function fingerprint(rule, file, evidence) {
1896
1954
  }
1897
1955
  var fingerprintOf = (f) => fingerprint(f.rule, f.file, f.evidence);
1898
1956
  function ledgerPath(cwd, policy) {
1899
- 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);
1900
1962
  }
1901
1963
  function readLedger(cwd, policy) {
1902
1964
  const p = ledgerPath(cwd, policy);
@@ -1990,7 +2052,7 @@ function paint(s, code, on) {
1990
2052
  function wrap(text, width) {
1991
2053
  const out2 = [];
1992
2054
  let cur = "";
1993
- for (const w of text.split(/\s+/).filter(Boolean)) {
2055
+ for (const w of stripControl(text).split(/\s+/).filter(Boolean)) {
1994
2056
  if (cur === "") cur = w;
1995
2057
  else if (cur.length + 1 + w.length <= width) cur += " " + w;
1996
2058
  else {
@@ -2055,15 +2117,15 @@ function renderText(input, opts) {
2055
2117
  for (const f of findings) {
2056
2118
  const isBlock = f.severity === "block";
2057
2119
  const mark = paint(isBlock ? "BLOCK" : "warn ", isBlock ? BOLD + RED : YELLOW, c);
2058
- const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""}` : "(command)";
2059
- 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)}`);
2060
2122
  for (const l of wrap(f.message, opts.width - 4)) out2.push(` ${l}`);
2061
2123
  const row = (key2, lines) => {
2062
2124
  lines.forEach((l, i) => out2.push(` ${paint(pad(i === 0 ? key2 : ""), DIM, c)} ${l}`));
2063
2125
  };
2064
2126
  row("evidence", [paint(clip(f.evidence, body), DIM, c)]);
2065
2127
  row("instead", wrap(f.remediation, body));
2066
- if (f.signoff.required) row("sign-off", [f.signoff.command]);
2128
+ if (f.signoff.required) row("sign-off", [stripControl(f.signoff.command)]);
2067
2129
  out2.push("");
2068
2130
  }
2069
2131
  if (blocks.length > 0) {
@@ -2077,7 +2139,7 @@ function renderText(input, opts) {
2077
2139
  // src/cli/render/github.ts
2078
2140
  import { appendFileSync as appendFileSync2 } from "node:fs";
2079
2141
  function escData(s) {
2080
- 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");
2081
2143
  }
2082
2144
  function escProp(s) {
2083
2145
  return escData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
@@ -2096,7 +2158,7 @@ function annotations(findings) {
2096
2158
  });
2097
2159
  }
2098
2160
  function cell(s) {
2099
- return s.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
2161
+ return stripControl(s).replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
2100
2162
  }
2101
2163
  function mdCode(s) {
2102
2164
  return s.replace(/`/g, "'").replace(/[\r\n]/g, " ");
@@ -2204,8 +2266,13 @@ function report(input) {
2204
2266
 
2205
2267
  // src/cli/check.ts
2206
2268
  function check(opts) {
2207
- let policy = opts.policyOverride ?? loadPolicy(opts.cwd);
2208
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);
2209
2276
  let changes;
2210
2277
  let layer;
2211
2278
  let view;
@@ -2218,13 +2285,20 @@ function check(opts) {
2218
2285
  layer = opts.ciLayer ? "ci" : "local";
2219
2286
  view = "worktree";
2220
2287
  } else if (opts.diff) {
2221
- const [base, head] = opts.diff.split(/\.{2,3}/);
2222
- 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(".")) {
2223
2291
  process.stderr.write(`tamperward: invalid --diff range "${opts.diff}" (expected <base>...<head>)
2224
2292
  `);
2225
2293
  return 2;
2226
2294
  }
2227
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
+ }
2228
2302
  layer = "ci";
2229
2303
  view = "range";
2230
2304
  policy = opts.policyOverride ?? loadPolicyAt(mergeBaseOf(base, head, { cwd: opts.cwd }), opts.cwd) ?? defaultPolicy();
@@ -2265,7 +2339,7 @@ import { createHash as createHash4 } from "node:crypto";
2265
2339
  import { execFileSync as execFileSync2 } from "node:child_process";
2266
2340
  import { readFileSync as readFileSync4, existsSync as existsSync3, writeFileSync, mkdtempSync, rmSync } from "node:fs";
2267
2341
  import { tmpdir } from "node:os";
2268
- import { isAbsolute, join as join4, relative, resolve } from "node:path";
2342
+ import { isAbsolute as isAbsolute2, join as join4, relative, resolve } from "node:path";
2269
2343
  function asStr(v) {
2270
2344
  return typeof v === "string" ? v : "";
2271
2345
  }
@@ -2281,7 +2355,7 @@ function abs(path, cwd) {
2281
2355
  }
2282
2356
  function relForDisplay(path, cwd) {
2283
2357
  const rel = relative(cwd, path);
2284
- return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
2358
+ return rel && !rel.startsWith("..") && !isAbsolute2(rel) ? rel : path;
2285
2359
  }
2286
2360
  function applyEdit(content, oldStr, newStr) {
2287
2361
  if (content === null) return newStr;
@@ -2302,7 +2376,7 @@ function synthFileChange(displayPath, before, after) {
2302
2376
  writeFileSync(a, before ?? "");
2303
2377
  writeFileSync(b, after ?? "");
2304
2378
  try {
2305
- raw = execFileSync2("git", ["diff", "--no-index", "--no-color", a, b], {
2379
+ raw = execFileSync2("git", ["diff", "--no-index", "--no-color", "--text", a, b], {
2306
2380
  encoding: "utf8",
2307
2381
  maxBuffer: 256 * 1024 * 1024
2308
2382
  });
@@ -2425,7 +2499,7 @@ function advanceTurnBaseline(cwd, sessionId) {
2425
2499
  // src/effect.ts
2426
2500
  import { createHash as createHash2 } from "node:crypto";
2427
2501
  import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync, writeFileSync as writeFileSync3 } from "node:fs";
2428
- import { isAbsolute as isAbsolute2, join as join6 } from "node:path";
2502
+ import { isAbsolute as isAbsolute3, join as join6 } from "node:path";
2429
2503
  import { execFileSync as execFileSync3 } from "node:child_process";
2430
2504
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
2431
2505
  var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
@@ -2437,7 +2511,7 @@ function gitDir2(cwd) {
2437
2511
  stdio: ["ignore", "pipe", "pipe"]
2438
2512
  }).trim();
2439
2513
  if (!d) return null;
2440
- return isAbsolute2(d) ? d : join6(cwd, d);
2514
+ return isAbsolute3(d) ? d : join6(cwd, d);
2441
2515
  } catch {
2442
2516
  return null;
2443
2517
  }
@@ -2954,9 +3028,18 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
2954
3028
  // src/cli/init.ts
2955
3029
  import { execFileSync as execFileSync4 } from "node:child_process";
2956
3030
  import { createHash as createHash5 } from "node:crypto";
2957
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
2958
- 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";
2959
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
+ }
2960
3043
  var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
2961
3044
  var MARKER = "# tamperward: block agent shortcuts before they land";
2962
3045
  var HOOK_RE = /\btamperward(?:@\S+)?\s+hook\s+claude\b/;
@@ -2969,25 +3052,28 @@ function coveredBy(existing, critical) {
2969
3052
  for (const line of existing.split("\n")) {
2970
3053
  const t = line.trim();
2971
3054
  if (!t || t.startsWith("#")) continue;
2972
- const pattern = t.split(/\s+/)[0];
2973
- if (!t.slice(pattern.length).trim()) continue;
2974
- if (pattern === "*" || pattern === critical) return true;
2975
- 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
+ }
2976
3067
  }
2977
3068
  return false;
2978
3069
  }
2979
3070
  function inferOwner(cwd) {
2980
- try {
2981
- const url = execFileSync4("git", ["remote", "get-url", "origin"], {
2982
- cwd,
2983
- encoding: "utf8",
2984
- stdio: ["ignore", "pipe", "ignore"]
2985
- }).trim();
2986
- const m = url.match(/github\.com[:/]+([^/]+)\//);
2987
- return m ? `@${m[1]}` : null;
2988
- } catch {
2989
- return null;
2990
- }
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()) };
2991
3077
  }
2992
3078
  var POLICY_CONTENT = `# Tamperward policy. The BASELINE (all rules, standard protected globs) applies even
2993
3079
  # without this file \u2014 everything here is an override, so an empty file changes nothing.
@@ -3033,7 +3119,7 @@ var WORKFLOW_CONTENT = `name: tamperward
3033
3119
 
3034
3120
  # The CI authority for main: the same engine as the agent hook and pre-commit, run over
3035
3121
  # the PR's commit range. A block fails the check and clears ONLY via the out-of-band
3036
- # label \`tamperward:allow:<rule>@<head-sha>\` applied by someone with write access \u2014
3122
+ # label \`tamperward:allow:<rule>@<head-sha>\` applied by someone with triage access or higher \u2014
3037
3123
  # never a file the PR itself can commit. The verify step reads the same labels:
3038
3124
  # \`tamperward:allow:verify@<head-sha>\` accepts a masked failure a reviewer has judged.
3039
3125
  #
@@ -3125,9 +3211,31 @@ function missingTools(matcher2) {
3125
3211
  if (have === null) return [];
3126
3212
  return PRE_MATCHER.split("|").filter((t) => !have.has(t));
3127
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
+ }
3128
3229
  function planPolicy(cwd) {
3129
3230
  const path = join8(cwd, POLICY_FILE);
3130
- 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
+ }
3131
3239
  return {
3132
3240
  item: "policy",
3133
3241
  path: POLICY_FILE,
@@ -3157,6 +3265,10 @@ function planClaudeHooks(cwd) {
3157
3265
  }
3158
3266
  settings = parsed;
3159
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
+ }
3160
3272
  const hooks = settings.hooks ??= {};
3161
3273
  const entriesWith = (arr, needle) => (arr ?? []).filter((m) => (m.hooks ?? []).some((h) => needle.test(String(h.command ?? ""))));
3162
3274
  const preEntries = entriesWith(hooks.PreToolUse, HOOK_RE);
@@ -3202,47 +3314,170 @@ function planClaudeHooks(cwd) {
3202
3314
  }
3203
3315
  };
3204
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
+ }
3205
3369
  function planPreCommit(cwd) {
3206
- const line = `${MARKER}
3207
- ${PRECOMMIT_CMD}
3208
- `;
3209
- const husky = join8(cwd, ".husky");
3210
- const gitDir3 = join8(cwd, ".git");
3211
- 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;
3212
- if (!target) return { item: "pre-commit", path: "(none)", status: "skip", detail: "not a git repo and no .husky/ \u2014 nothing to wire" };
3213
- 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
+ }
3214
3397
  const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
3215
- if (existing !== null && PRECOMMIT_RE.test(existing)) {
3216
- const lines = existing.split("\n");
3217
- const staleAt = lines.findIndex((l) => PRECOMMIT_RE.test(l) && stalePin(l));
3218
- if (staleAt === -1) {
3219
- 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" };
3220
3427
  }
3221
- const was = pinOf(lines[staleAt]) || "unpinned";
3428
+ const was = pinOf(lines[at]) || "unpinned";
3222
3429
  return {
3223
3430
  item: "pre-commit",
3224
- path: target.rel,
3431
+ path: rel,
3225
3432
  status: "update",
3226
3433
  detail: `re-pin the staged check to tamperward@${TW_VERSION} (was ${was})`,
3227
3434
  apply: () => {
3228
- lines[staleAt] = PRECOMMIT_CMD;
3229
- writeFileSync5(path, lines.join("\n"));
3230
- 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);
3231
3472
  }
3232
3473
  };
3233
3474
  }
3234
3475
  return {
3235
3476
  item: "pre-commit",
3236
- path: target.rel,
3237
- status: existing === null ? "create" : "update",
3238
- detail: existing === null ? `create via ${target.note}` : `append the staged check (${target.note})`,
3239
- apply: () => {
3240
- mkdirSync5(dirname3(path), { recursive: true });
3241
- const content = existing === null ? `#!/bin/sh
3242
- ${line}` : existing.replace(/\n?$/, "\n") + line;
3243
- writeFileSync5(path, content);
3244
- chmodSync(path, 493);
3245
- }
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, ""])
3246
3481
  };
3247
3482
  }
3248
3483
  function planCodeowners(cwd) {
@@ -3256,18 +3491,26 @@ function planCodeowners(cwd) {
3256
3491
  if (missing.length === 0) {
3257
3492
  return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
3258
3493
  }
3259
- const owner = inferOwner(cwd);
3494
+ const inferred = inferOwner(cwd);
3495
+ const owner = inferred?.owner ?? null;
3496
+ const unconfirmed = inferred !== null && !inferred.confirmed;
3260
3497
  const block = `
3261
3498
  ${CODEOWNERS_MARK}
3262
3499
  # A pull request that rewrites the workflow can keep the job name, replace the
3263
3500
  # gate with \`true\`, and still report a green required check. Only a human
3264
3501
  # requirement on these paths prevents that.
3265
- ` + (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";
3266
3506
  return {
3267
3507
  item: "codeowners",
3268
3508
  path: existingRel ?? rel,
3269
3509
  status: existing === null ? "create" : "update",
3270
- 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
+ } : {},
3271
3514
  apply: () => {
3272
3515
  mkdirSync5(dirname3(path), { recursive: true });
3273
3516
  writeFileSync5(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
@@ -3326,21 +3569,49 @@ function planWorkflow(cwd, force) {
3326
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.`
3327
3570
  };
3328
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
+ }
3329
3579
  function planInit(cwd, opts = {}) {
3330
- 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
+ ];
3331
3587
  }
3332
3588
  function runInit(opts) {
3333
3589
  const cwd = opts.cwd ?? process.cwd();
3334
3590
  const plan = planInit(cwd, { forceWorkflow: opts.forceWorkflow });
3335
3591
  const w = process.stdout;
3592
+ let applied = 0;
3336
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
+ }
3337
3604
  const verb = opts.dryRun && (a.status === "create" || a.status === "update") ? `would ${a.status}` : a.status;
3338
3605
  w.write(` ${a.item.padEnd(10)} ${verb.padEnd(12)} ${a.path} \u2014 ${a.detail}
3339
3606
  `);
3340
- 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
+ `);
3341
3612
  }
3342
3613
  const errors = plan.filter((a) => a.status === "error");
3343
- const changed = plan.filter((a) => a.apply).length;
3614
+ const changed = opts.dryRun ? plan.filter((a) => a.apply).length : applied;
3344
3615
  if (errors.length) {
3345
3616
  w.write(`
3346
3617
  tamperward init: ${errors.length} item(s) need your attention above; the rest ${opts.dryRun ? "are planned" : "were applied"}.
@@ -3363,15 +3634,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
3363
3634
  // src/cli/verify.ts
3364
3635
  import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
3365
3636
  import { createHash as createHash8 } from "node:crypto";
3366
- 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";
3367
3638
  import { tmpdir as tmpdir2 } from "node:os";
3368
- 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";
3369
3640
 
3370
3641
  // src/git/trusted.ts
3371
3642
  import { execFileSync as execFileSync5 } from "node:child_process";
3372
3643
  import { createHash as createHash6 } from "node:crypto";
3373
3644
  import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
3374
- import { isAbsolute as isAbsolute3, join as join9 } from "node:path";
3645
+ import { isAbsolute as isAbsolute5, join as join9 } from "node:path";
3375
3646
  function trustedGitEnv() {
3376
3647
  return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
3377
3648
  }
@@ -3384,7 +3655,7 @@ function gitDirOf(cwd) {
3384
3655
  env: trustedGitEnv()
3385
3656
  }).trim();
3386
3657
  if (!d) return null;
3387
- return isAbsolute3(d) ? d : join9(cwd, d);
3658
+ return isAbsolute5(d) ? d : join9(cwd, d);
3388
3659
  } catch {
3389
3660
  return null;
3390
3661
  }
@@ -3422,7 +3693,7 @@ function objectRewriteState(cwd) {
3422
3693
  // src/fingerprint.ts
3423
3694
  import { execFileSync as execFileSync6 } from "node:child_process";
3424
3695
  import { createHash as createHash7 } from "node:crypto";
3425
- 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";
3426
3697
  import { join as join10 } from "node:path";
3427
3698
  function treeFingerprint(cwd) {
3428
3699
  const files = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
@@ -3461,7 +3732,7 @@ function depsFingerprint(cwd) {
3461
3732
  h.update(p);
3462
3733
  try {
3463
3734
  h.update(readFileSync12(p));
3464
- h.update(String(statSync5(p).mode));
3735
+ h.update(String(statSync6(p).mode));
3465
3736
  } catch {
3466
3737
  h.update("<unreadable>");
3467
3738
  }
@@ -3476,7 +3747,7 @@ function depsFingerprint(cwd) {
3476
3747
  }
3477
3748
  };
3478
3749
  try {
3479
- if (!statSync5(root).isDirectory()) return "none";
3750
+ if (!statSync6(root).isDirectory()) return "none";
3480
3751
  } catch {
3481
3752
  return "none";
3482
3753
  }
@@ -3534,7 +3805,7 @@ function materialize(cwd, dest) {
3534
3805
  const src = join11(cwd, rel);
3535
3806
  let st;
3536
3807
  try {
3537
- st = statSync6(src);
3808
+ st = statSync7(src);
3538
3809
  } catch {
3539
3810
  continue;
3540
3811
  }
@@ -3549,7 +3820,7 @@ function materialize(cwd, dest) {
3549
3820
  }
3550
3821
  chmodSync2(out2, st.mode);
3551
3822
  }
3552
- const nm = resolve2(cwd, "node_modules");
3823
+ const nm = resolve3(cwd, "node_modules");
3553
3824
  if (existsSync9(nm)) symlinkSync(nm, join11(dest, "node_modules"), "dir");
3554
3825
  }
3555
3826
  function dropSymlink(p) {
@@ -3565,9 +3836,9 @@ function verifierInputs(cmd, atBase, policy) {
3565
3836
  const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
3566
3837
  if (tok && baseFiles.has(tok)) picked.add(tok);
3567
3838
  }
3568
- const globs = policy.verify?.inputs;
3569
- if (globs && globs.length) {
3570
- 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);
3571
3842
  }
3572
3843
  return picked;
3573
3844
  }
@@ -3839,7 +4110,7 @@ function parseVerify(args) {
3839
4110
  // src/cli/run.ts
3840
4111
  import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
3841
4112
  import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync } from "node:fs";
3842
- import { resolve as resolve3 } from "node:path";
4113
+ import { resolve as resolve4 } from "node:path";
3843
4114
  var out = (s) => process.stdout.write(s + "\n");
3844
4115
  var err = (s) => process.stderr.write(s + "\n");
3845
4116
  function git3(args, cwd) {
@@ -3886,7 +4157,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
3886
4157
  return out2;
3887
4158
  }
3888
4159
  function runEnvelope(opts) {
3889
- const cwd = resolve3(opts.cwd ?? process.cwd());
4160
+ const cwd = resolve4(opts.cwd ?? process.cwd());
3890
4161
  if (!opts.argv.length) {
3891
4162
  err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
3892
4163
  return 2;
@@ -4023,7 +4294,7 @@ function parseRun(args) {
4023
4294
  return o;
4024
4295
  }
4025
4296
 
4026
- // src/cli/index.ts
4297
+ // src/cli/main.ts
4027
4298
  function parseAllow(args) {
4028
4299
  const o = {};
4029
4300
  for (let i = 0; i < args.length; i++) {
@@ -4101,7 +4372,7 @@ Formats:
4101
4372
  observe supported transient effects
4102
4373
  tamperward verify [--base R] [--cmd C] pristine-suite re-execution: run the suite
4103
4374
  [--budget S] [--json] [--keep] as-is AND with protected files restored
4104
- from the trusted base; a visible-green /
4375
+ [--require-ancestor] [--cwd D] from the trusted base; a visible-green /
4105
4376
  pristine-red result is a MASKED FAILURE
4106
4377
  (exit 1, or 0 under an out-of-band
4107
4378
  verify@<head-sha> approval); cannot-verify
@@ -4109,14 +4380,16 @@ Formats:
4109
4380
  tamperward run [opts] -- <agent cmd...> enforcement envelope: record the trusted
4110
4381
  [--base R] [--cmd C] base, run the agent, treat its exit as
4111
4382
  [--budget S] [--allow-dirty] untrusted, then re-adjudicate the tree it
4112
- left (policy over base...HEAD and the
4383
+ [--settle S] [--allow-dep-drift] left (policy over base...HEAD and the
4384
+ [--cwd D]
4113
4385
  worktree, plus verify). Exit: agent's code
4114
4386
  when clean; 1 on any blocking finding or
4115
4387
  masked failure; 2 when it cannot
4116
4388
  adjudicate (fails closed)
4117
4389
  tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
4390
+ [--file F] [--cwd D]
4118
4391
  tamperward init [--dry-run] wire the policy file plus every
4119
- [--force-workflow] enforcement point: Claude Code hooks,
4392
+ [--force-workflow] [--cwd D] enforcement point: Claude Code hooks,
4120
4393
  pre-commit, CI. Idempotent; never
4121
4394
  overwrites your files. Re-running it
4122
4395
  MIGRATES a CI workflow it generated and
@@ -4126,8 +4399,14 @@ Formats:
4126
4399
  --force-workflow replaces a workflow it
4127
4400
  did not write, or one you have edited.
4128
4401
 
4129
- Exit code: check \u2192 1 if any blocking finding. hook/sweep \u2192 always 0; a deny is
4130
- emitted as JSON on stdout (exit 2 makes Claude Code ignore the JSON).
4402
+ Exit codes: 0 clean \xB7 1 a blocking finding (check), MASKED_FAILURE or SUITE_RED
4403
+ (verify), any blocking finding or masked failure (run) \xB7 2 cannot
4404
+ evaluate, failing closed: policy error, bad --diff range, no view,
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.
4131
4410
  `);
4132
4411
  }
4133
4412
  function main(argv2) {
@@ -4159,9 +4438,21 @@ function main(argv2) {
4159
4438
  return 2;
4160
4439
  }
4161
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
4162
4453
  var argv = process.argv.slice(2);
4163
4454
  if (argv[0] === "watch") {
4164
4455
  runWatch(argv.slice(1));
4165
4456
  } else {
4166
- process.exit(main(argv));
4457
+ process.exit(guardedMain(argv));
4167
4458
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "2.1.0",
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",