tamperward 2.7.1 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli/index.js +796 -466
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -2,9 +2,376 @@
2
2
 
3
3
  // src/git/build.ts
4
4
  import { execFileSync } from "node:child_process";
5
- import { readFileSync } from "node:fs";
5
+
6
+ // src/disk.ts
7
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readlinkSync, realpathSync } from "node:fs";
6
8
  import { join } from "node:path";
7
9
 
10
+ // src/policy.ts
11
+ import picomatch from "picomatch";
12
+ var POLICY_FILE = ".tamperward.yml";
13
+ var BLOCK_SINCE = {};
14
+ function applyVersionGates(rules, version, gates = BLOCK_SINCE) {
15
+ const out2 = { ...rules };
16
+ for (const [rule, since] of Object.entries(gates)) {
17
+ if (since > version && out2[rule]?.severity === "block") {
18
+ out2[rule] = { ...out2[rule], severity: "warn" };
19
+ }
20
+ }
21
+ return out2;
22
+ }
23
+ function normalizeGlob(glob) {
24
+ return glob.startsWith("/") ? glob.replace(/^\/+/, "") : glob;
25
+ }
26
+ function mergeRules(base, user) {
27
+ const out2 = { ...base };
28
+ for (const [name, cfg] of Object.entries(user ?? {})) {
29
+ out2[name] = { ...out2[name] ?? {}, ...cfg ?? {} };
30
+ }
31
+ return out2;
32
+ }
33
+ var CONTROL = /[\u0000-\u001f\u007f]/;
34
+ function hasControlChar(path) {
35
+ return CONTROL.test(path);
36
+ }
37
+ function escapeControl(path) {
38
+ return path.replace(/[\u0000-\u001f\u007f]/g, (ch) => {
39
+ const c = ch.charCodeAt(0);
40
+ if (c === 10) return "\\n";
41
+ if (c === 13) return "\\r";
42
+ if (c === 9) return "\\t";
43
+ return `\\x${c.toString(16).padStart(2, "0")}`;
44
+ });
45
+ }
46
+ var listCache = /* @__PURE__ */ new WeakMap();
47
+ function listMatcher(globs4) {
48
+ let re = listCache.get(globs4);
49
+ if (re === void 0) {
50
+ re = globs4.length === 0 ? null : new RegExp(globs4.map((g) => `(?:${picomatch.makeRe(g, { dot: true }).source})`).join("|"));
51
+ listCache.set(globs4, re);
52
+ }
53
+ return re;
54
+ }
55
+ function matchesAny(path, globs4) {
56
+ if (!globs4) return false;
57
+ const re = listMatcher(globs4);
58
+ return re !== null && re.test(path);
59
+ }
60
+ function protectedCategory(path, policy) {
61
+ const cats = Object.entries(policy.protected ?? {});
62
+ if (hasControlChar(path)) return cats[0]?.[0] ?? "tests";
63
+ for (const [cat, globs4] of cats) {
64
+ if (matchesAny(path, globs4)) return cat;
65
+ }
66
+ return null;
67
+ }
68
+ var anyCache = /* @__PURE__ */ new WeakMap();
69
+ function allProtectedGlobs(policy) {
70
+ const p = policy.protected ?? {};
71
+ let all = anyCache.get(p);
72
+ if (!all) {
73
+ all = Object.values(p).flat();
74
+ anyCache.set(p, all);
75
+ }
76
+ return all;
77
+ }
78
+ function isProtected(path, policy, category) {
79
+ if (hasControlChar(path)) return true;
80
+ if (category) return matchesAny(path, policy.protected?.[category]);
81
+ return matchesAny(path, allProtectedGlobs(policy));
82
+ }
83
+ function isPolicyFile(path) {
84
+ return path === POLICY_FILE || path.endsWith("/" + POLICY_FILE);
85
+ }
86
+ function isIgnored(path, policy) {
87
+ return matchesAny(path, policy.ignore);
88
+ }
89
+ function mergeProtected(base, user) {
90
+ const out2 = { ...base };
91
+ for (const [cat, globs4] of Object.entries(user ?? {})) {
92
+ const have = new Set(out2[cat] ?? []);
93
+ out2[cat] = [...out2[cat] ?? [], ...(globs4 ?? []).filter((g) => !have.has(g))];
94
+ }
95
+ return out2;
96
+ }
97
+ function defaultPolicy(version = 1) {
98
+ const p = {
99
+ version,
100
+ protected: {
101
+ // Cover every JS/TS test extension, not just .test.ts — the multi-repo FP study found
102
+ // .test.tsx/.spec.tsx (hono, zustand) slipped the glob, so legit test-file casts blocked
103
+ // instead of warning. Kept ADDITIVE (originals + new extensions) so the policy-diff sees a
104
+ // strengthening, not a narrowing. picomatch expands the brace.
105
+ // test-skip, test-deletion and test-content-removal all gate on this
106
+ // class, so a repo it does not match gets rules that can NEVER fire —
107
+ // protection that is silently absent rather than visibly off. The JS/TS
108
+ // list shipped alone; these are the conventional layouts of the other
109
+ // ecosystems `init` is run in. (P2-13, external review.)
110
+ tests: [
111
+ // JavaScript / TypeScript
112
+ "**/*.test.ts",
113
+ "**/*.spec.ts",
114
+ "**/*.{test,spec}.{tsx,cts,mts,js,jsx,cjs,mjs}",
115
+ "**/__tests__/**",
116
+ // Python (pytest / unittest layouts)
117
+ "**/test_*.py",
118
+ "**/*_test.py",
119
+ "**/conftest.py",
120
+ // Go
121
+ "**/*_test.go",
122
+ // Rust (integration tests live in tests/, unit tests inline)
123
+ "**/tests/**/*.rs",
124
+ // Ruby (RSpec / minitest)
125
+ "**/*_spec.rb",
126
+ "**/*_test.rb",
127
+ // Java / Kotlin / Scala (Maven & Gradle convention)
128
+ "**/src/test/**",
129
+ // PHP (PHPUnit)
130
+ "**/*Test.php",
131
+ // C# (.NET)
132
+ "**/*Tests.cs",
133
+ "**/*Test.cs"
134
+ ],
135
+ config: [
136
+ "**/jest.config.*",
137
+ "**/vitest.config.*",
138
+ // A `vitest.workspace.*` file REPLACES the root config's selection with the
139
+ // union of its projects, and a `vite.config.*` carrying a `test:` key is the
140
+ // vitest config when no vitest.config exists — both are where a spec can be
141
+ // dropped from the suite without a line of it changing. ADDITIVE, like every
142
+ // baseline widening (the policy-diff reads it as a strengthening).
143
+ "**/vitest.workspace.*",
144
+ "**/vite.config.*",
145
+ "**/tsconfig*.json",
146
+ "**/.eslintrc*",
147
+ "**/eslint.config.*",
148
+ "**/package.json",
149
+ // Coverage gates outside the Jest/Vitest shapes. coverage-lowering could only
150
+ // see a threshold it was shown: a Python `fail_under`, an nyc `lines`, a
151
+ // codecov `target` lived in files no glob protected, so lowering them fired
152
+ // nothing. ADDITIVE, like every baseline widening (the policy-diff reads it
153
+ // as a strengthening). pyproject.toml carries [tool.coverage.report].
154
+ "**/.coveragerc",
155
+ "**/pyproject.toml",
156
+ "**/.nycrc",
157
+ "**/.nycrc.*",
158
+ "**/nyc.config.*",
159
+ "**/codecov.yml",
160
+ "**/.codecov.yml"
161
+ ],
162
+ ci: [".github/workflows/**"],
163
+ // Recorded expected outputs. An assertion stored as data is still an assertion;
164
+ // rewriting it from current output is the snapshot-update move the affordance
165
+ // experiment measured at a 70% attempt / 100% through rate (snapshot-rewrite).
166
+ // tap-snapshots/** added from Phase-3 evidence: node-tar's recorded expectations
167
+ // live there as .cjs and were rewritten unseen (PHASE3.md §4).
168
+ snapshots: ["**/*.snap", "**/__snapshots__/**", "**/tap-snapshots/**", "**/golden/**", "**/*.golden.*"],
169
+ // The wiring IS the enforcement: settings.json carries the PreToolUse and
170
+ // Stop hooks, and a pre-commit config carries the commit-time gate. These
171
+ // were installed by `init` and protected by nothing, so editing them out
172
+ // fired no rule at all. (.git/hooks/** is outside every git view and so
173
+ // cannot be covered here — noted in SECURITY-ENVELOPE.md.) (P1-4, review.)
174
+ hooks: [
175
+ ".husky/**",
176
+ "**/lefthook.*",
177
+ // lefthook merges `lefthook-local.yml` OVER `lefthook.yml`: a `skip: true`
178
+ // there switches the gate off without touching the file init pointed at.
179
+ "**/lefthook-local.*",
180
+ ".tamperward.yml",
181
+ "**/.tamperward.yml",
182
+ ".claude/settings.json",
183
+ ".claude/settings.local.json",
184
+ "**/.pre-commit-config.yaml",
185
+ // CODEOWNERS is enforcement wiring, not documentation: it is the only
186
+ // thing that can require a human on a change to the workflow that
187
+ // decides whether the gate runs at all. GitHub reads it from any of
188
+ // these three locations. (H2.)
189
+ ".github/CODEOWNERS",
190
+ "CODEOWNERS",
191
+ "docs/CODEOWNERS"
192
+ ]
193
+ },
194
+ rules: {
195
+ "test-deletion": { severity: "block" },
196
+ // content gutted from a surviving spec — the class Phase 3 measured walking
197
+ // through the block-count rule (rows, assertion arguments, setup). Severity
198
+ // backed by the real-commit corpus measurement recorded in the rule's SPEC row.
199
+ "test-content-removal": { severity: "block" },
200
+ "test-skip": { severity: "block" },
201
+ "ts-any-cast": { severity: "block" },
202
+ // the unambiguous explicit casts + ts-suppression directives
203
+ // broad any in annotation/generic position is common in legit code (measured ~84-100% FP as a
204
+ // block rule on real TS), so it WARNs until a semantic (error-silencing-aware) signal earns block.
205
+ "ts-any-launder": { severity: "warn" },
206
+ "lint-suppression": { severity: "block" },
207
+ "coverage-lowering": { severity: "block" },
208
+ "ci-tampering": { severity: "block" },
209
+ "hook-tampering": { severity: "block" },
210
+ "no-verify": { severity: "block" },
211
+ // mechanical but intent-ambiguous: updating a snapshot is the legitimate workflow
212
+ // when intended output changes. WARN until the §7 evidence path earns block.
213
+ "snapshot-rewrite": { severity: "warn" },
214
+ // the watcher's transient class (modified-and-restored within a turn). WARN by
215
+ // default: a Stop block already forces restores, and a compliant restore reads
216
+ // as churn — block-by-default would deadlock the honest path. Harnesses that own
217
+ // that semantics raise it via TAMPERWARD_TRANSIENT=block.
218
+ "transient-protected-mutation": { severity: "warn" },
219
+ // the narrow graduation candidate: snapshot moved with NO accompanying change.
220
+ // ~0.06% FP on 1,652 audited mainline commits, 7/7 TP on observed tampers
221
+ // (harness/fp-study/). warn until a version: gate graduates it (BLOCK_SINCE).
222
+ "snapshot-only-rewrite": { severity: "warn" },
223
+ // heuristic — warn until precision is measured (SPEC §7)
224
+ "assertion-weakening": { severity: "warn" },
225
+ "guard-removal": { severity: "warn" }
226
+ },
227
+ ignore: [],
228
+ signoff: { requiredFor: ["block"], ledger: ".tamperward/ledger.jsonl" }
229
+ };
230
+ p.rules = applyVersionGates(p.rules, version);
231
+ return p;
232
+ }
233
+
234
+ // src/disk.ts
235
+ var READ_CAP = 64 * 1024 * 1024;
236
+ var NO_META = { mode: 0, size: 0, mtimeMs: 0 };
237
+ function errText(e) {
238
+ return e instanceof Error ? e.message : String(e);
239
+ }
240
+ function errCode(e) {
241
+ return String(e?.code ?? "");
242
+ }
243
+ function irregularName(st) {
244
+ if (st.isFIFO()) return "fifo";
245
+ if (st.isSocket()) return "socket";
246
+ if (st.isCharacterDevice()) return "character device";
247
+ if (st.isBlockDevice()) return "block device";
248
+ return "unknown file type";
249
+ }
250
+ function meta(st) {
251
+ return { mode: st.mode, size: st.size, mtimeMs: st.mtimeMs };
252
+ }
253
+ var O_READ = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
254
+ function readRegular(abs2, st) {
255
+ let fd;
256
+ try {
257
+ fd = openSync(abs2, O_READ);
258
+ } catch (e) {
259
+ if (errCode(e) === "ELOOP") return inspectOnce(abs2);
260
+ return { kind: "unreadable", content: null, ...meta(st), detail: errText(e) };
261
+ }
262
+ try {
263
+ const now = fstatSync(fd);
264
+ if (!now.isFile()) return { kind: "irregular", content: null, ...meta(now), detail: irregularName(now) };
265
+ if (now.size > READ_CAP) return { kind: "oversize", content: null, ...meta(now), detail: `${now.size} bytes` };
266
+ const buf = Buffer.allocUnsafe(now.size);
267
+ let off2 = 0;
268
+ while (off2 < now.size) {
269
+ const n = readSync(fd, buf, off2, now.size - off2, off2);
270
+ if (n === 0) break;
271
+ off2 += n;
272
+ }
273
+ return { kind: "file", content: off2 === now.size ? buf : buf.subarray(0, off2), mode: now.mode, size: off2, mtimeMs: now.mtimeMs, detail: "" };
274
+ } catch (e) {
275
+ return { kind: "unreadable", content: null, ...meta(st), detail: errText(e) };
276
+ } finally {
277
+ closeSync(fd);
278
+ }
279
+ }
280
+ function classify(abs2, st) {
281
+ if (st.isSymbolicLink()) {
282
+ try {
283
+ const target = readlinkSync(abs2);
284
+ return { kind: "symlink", content: Buffer.from(target, "utf8"), ...meta(st), detail: target };
285
+ } catch (e) {
286
+ return { kind: "unreadable", content: null, ...meta(st), detail: errText(e) };
287
+ }
288
+ }
289
+ if (st.isDirectory()) return { kind: "directory", content: null, ...meta(st), detail: "directory" };
290
+ if (!st.isFile()) return { kind: "irregular", content: null, ...meta(st), detail: irregularName(st) };
291
+ if (st.size > READ_CAP) return { kind: "oversize", content: null, ...meta(st), detail: `${st.size} bytes` };
292
+ return readRegular(abs2, st);
293
+ }
294
+ function inspectOnce(abs2) {
295
+ let st;
296
+ try {
297
+ st = lstatSync(abs2);
298
+ } catch (e) {
299
+ const code = errCode(e);
300
+ if (code === "ENOENT" || code === "ENOTDIR") return { kind: "absent", content: null, ...NO_META, detail: "" };
301
+ return { kind: "unreadable", content: null, ...NO_META, detail: errText(e) };
302
+ }
303
+ if (st.isSymbolicLink() || !st.isFile()) return classify(abs2, st);
304
+ return { kind: "unreadable", content: null, ...meta(st), detail: "the path changed shape while it was being read" };
305
+ }
306
+ function inspectPath(abs2) {
307
+ let st;
308
+ try {
309
+ st = lstatSync(abs2);
310
+ } catch (e) {
311
+ const code = errCode(e);
312
+ if (code === "ENOENT" || code === "ENOTDIR") return { kind: "absent", content: null, ...NO_META, detail: "" };
313
+ return { kind: "unreadable", content: null, ...NO_META, detail: errText(e) };
314
+ }
315
+ return classify(abs2, st);
316
+ }
317
+ function inspectRel(cwd, rel) {
318
+ return inspectPath(join(cwd, rel));
319
+ }
320
+ function inspectResolved(abs2) {
321
+ let real;
322
+ try {
323
+ real = realpathSync(abs2);
324
+ } catch (e) {
325
+ const code = errCode(e);
326
+ if (code === "ENOENT" || code === "ENOTDIR") return { kind: "absent", content: null, ...NO_META, detail: "" };
327
+ return { kind: "unreadable", content: null, ...NO_META, detail: errText(e) };
328
+ }
329
+ return inspectPath(real);
330
+ }
331
+ function textOf(e) {
332
+ return e.content == null ? null : e.content.toString("utf8");
333
+ }
334
+ function describe(e, shown) {
335
+ switch (e.kind) {
336
+ case "symlink":
337
+ return `Protected path ${shown} is a symbolic link to ${escapeControl(e.detail)}. The gate never follows a link, so what the runner will read there cannot be judged.`;
338
+ case "directory":
339
+ return `Protected path ${shown} is a directory where a file is expected, so it cannot be judged.`;
340
+ case "irregular":
341
+ return `Protected path ${shown} is a ${e.detail}, not a regular file, so it cannot be read to judge it.`;
342
+ case "oversize":
343
+ return `Protected file ${shown} is ${e.detail}, above the ${READ_CAP / (1024 * 1024)} MiB the gate reads, so it is judged by name.`;
344
+ default:
345
+ return `Protected file ${shown} cannot be read to judge it (${e.detail}).`;
346
+ }
347
+ }
348
+ function unjudgeableFinding(cwd, rel) {
349
+ const e = inspectRel(cwd, rel);
350
+ if (e.kind === "file" || e.kind === "absent") return null;
351
+ const shown = escapeControl(rel);
352
+ return {
353
+ rule: "hidden-drift",
354
+ severity: "block",
355
+ file: rel,
356
+ message: describe(e, shown),
357
+ evidence: `${shown}: ${e.kind}${e.detail ? ` (${escapeControl(e.detail)})` : ""}`,
358
+ remediation: "Put a regular file the gate can read at the path \u2014 no symbolic link, FIFO, socket or device, and under 64 MiB \u2014 through a tool call the gate can see, or remove it.",
359
+ signoff: { required: true, command: `tamperward allow hidden-drift --file ${shown} --reason "..."` }
360
+ };
361
+ }
362
+ function unjudgeableProtected(cwd, policy, changes) {
363
+ const out2 = [];
364
+ const seen = /* @__PURE__ */ new Set();
365
+ for (const c of changes) {
366
+ if (c.kind !== "file" || c.op === "delete" || seen.has(c.path)) continue;
367
+ seen.add(c.path);
368
+ if (!isProtected(c.path, policy)) continue;
369
+ const f = unjudgeableFinding(cwd, c.path);
370
+ if (f) out2.push(f);
371
+ }
372
+ return out2;
373
+ }
374
+
8
375
  // src/diff/parse.ts
9
376
  var HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
10
377
  var C_ESCAPES = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, "\\": 92, '"': 34 };
@@ -251,11 +618,7 @@ function blobAt(rev, path, cwd) {
251
618
  }
252
619
  }
253
620
  function fromDisk(path, cwd) {
254
- try {
255
- return readFileSync(join(cwd ?? process.cwd(), path), "utf8");
256
- } catch {
257
- return null;
258
- }
621
+ return textOf(inspectRel(cwd ?? process.cwd(), path));
259
622
  }
260
623
  function enrich(c, beforeReader, afterReader) {
261
624
  if (c.kind !== "file") return c;
@@ -353,6 +716,9 @@ function underSkippedDir(rel) {
353
716
  return rel.split("/").some((seg) => SKIP_DIRS.has(seg));
354
717
  }
355
718
  function ignoredPaths(opts = {}, keep) {
719
+ return ignoredTree(opts, keep).files;
720
+ }
721
+ function ignoredTree(opts = {}, keep) {
356
722
  const files = [];
357
723
  const dirs = [];
358
724
  for (const entry of git([...IGNORED_LS, "--directory"], opts.cwd).split("\0").filter(Boolean)) {
@@ -364,7 +730,7 @@ function ignoredPaths(opts = {}, keep) {
364
730
  const expanded = git([...IGNORED_LS, "--", ...dirs.map((d) => `:(literal)${d}`)], opts.cwd).split("\0").filter(Boolean);
365
731
  for (const rel of expanded) if (!underSkippedDir(rel)) files.push(rel);
366
732
  }
367
- return files.filter(keep);
733
+ return { dirs, files: files.filter(keep) };
368
734
  }
369
735
  function ignoredAdds(opts = {}, keep, exclude) {
370
736
  return ignoredPaths(opts, keep).filter((rel) => !exclude?.has(rel)).map((rel) => addOf(rel, opts.cwd));
@@ -554,234 +920,38 @@ var noVerify = {
554
920
  why = `${SKIP_WHY} (set earlier in the same command)`;
555
921
  evidence = `${carriedSkip}; ${seg}`;
556
922
  }
557
- if (!why && sub && HOOK_SUBCOMMANDS.has(sub)) {
558
- const args = gitArgs(toks, sub);
559
- if (args.some((t) => NO_VERIFY.test(t))) why = "--no-verify skips the pre-commit/pre-push hooks";
560
- else if (sub === "commit" && args.some((t) => SHORT_N.test(t))) why = "git commit -n skips the pre-commit hook";
561
- }
562
- if (!why && toks.includes(NO_HOOKS)) why = "--no-hooks disables hook execution";
563
- if (why) {
564
- out2.push(
565
- makeFinding(RULE, policy, {
566
- message: `Command bypasses git hooks: ${why}.`,
567
- evidence,
568
- remediation: "Let the hooks run and fix what they catch. Skipping verification is exactly the move this gate blocks \u2014 editing the hook and --no-hooks are blocked too."
569
- })
570
- );
571
- }
572
- }
573
- }
574
- return out2;
575
- }
576
- };
577
-
578
- // src/detectors/ts-any-cast.ts
579
- import ts from "typescript";
580
-
581
- // src/diff/select.ts
582
- function addedLines(c) {
583
- if (c.kind !== "file") return [];
584
- return c.hunks.flatMap((h) => h.lines.filter((l) => l.type === "add"));
585
- }
586
- function removedLines(c) {
587
- if (c.kind !== "file") return [];
588
- return c.hunks.flatMap((h) => h.lines.filter((l) => l.type === "del"));
589
- }
590
-
591
- // src/policy.ts
592
- import picomatch from "picomatch";
593
- var POLICY_FILE = ".tamperward.yml";
594
- var BLOCK_SINCE = {};
595
- function applyVersionGates(rules, version, gates = BLOCK_SINCE) {
596
- const out2 = { ...rules };
597
- for (const [rule, since] of Object.entries(gates)) {
598
- if (since > version && out2[rule]?.severity === "block") {
599
- out2[rule] = { ...out2[rule], severity: "warn" };
600
- }
601
- }
602
- return out2;
603
- }
604
- function normalizeGlob(glob) {
605
- return glob.startsWith("/") ? glob.replace(/^\/+/, "") : glob;
606
- }
607
- function mergeRules(base, user) {
608
- const out2 = { ...base };
609
- for (const [name, cfg] of Object.entries(user ?? {})) {
610
- out2[name] = { ...out2[name] ?? {}, ...cfg ?? {} };
611
- }
612
- return out2;
613
- }
614
- var cache = /* @__PURE__ */ new Map();
615
- function matcher(glob) {
616
- let m = cache.get(glob);
617
- if (!m) {
618
- m = picomatch(glob, { dot: true });
619
- cache.set(glob, m);
620
- }
621
- return m;
622
- }
623
- function matchesAny(path, globs4) {
624
- return !!globs4 && globs4.some((g) => matcher(g)(path));
625
- }
626
- function protectedCategory(path, policy) {
627
- for (const [cat, globs4] of Object.entries(policy.protected ?? {})) {
628
- if (matchesAny(path, globs4)) return cat;
629
- }
630
- return null;
631
- }
632
- function isProtected(path, policy, category) {
633
- if (category) return matchesAny(path, policy.protected?.[category]);
634
- return protectedCategory(path, policy) !== null;
635
- }
636
- function isPolicyFile(path) {
637
- return path === POLICY_FILE || path.endsWith("/" + POLICY_FILE);
638
- }
639
- function isIgnored(path, policy) {
640
- return matchesAny(path, policy.ignore);
641
- }
642
- function mergeProtected(base, user) {
643
- const out2 = { ...base };
644
- for (const [cat, globs4] of Object.entries(user ?? {})) {
645
- const have = new Set(out2[cat] ?? []);
646
- out2[cat] = [...out2[cat] ?? [], ...(globs4 ?? []).filter((g) => !have.has(g))];
923
+ if (!why && sub && HOOK_SUBCOMMANDS.has(sub)) {
924
+ const args = gitArgs(toks, sub);
925
+ if (args.some((t) => NO_VERIFY.test(t))) why = "--no-verify skips the pre-commit/pre-push hooks";
926
+ else if (sub === "commit" && args.some((t) => SHORT_N.test(t))) why = "git commit -n skips the pre-commit hook";
927
+ }
928
+ if (!why && toks.includes(NO_HOOKS)) why = "--no-hooks disables hook execution";
929
+ if (why) {
930
+ out2.push(
931
+ makeFinding(RULE, policy, {
932
+ message: `Command bypasses git hooks: ${why}.`,
933
+ evidence,
934
+ remediation: "Let the hooks run and fix what they catch. Skipping verification is exactly the move this gate blocks \u2014 editing the hook and --no-hooks are blocked too."
935
+ })
936
+ );
937
+ }
938
+ }
939
+ }
940
+ return out2;
647
941
  }
648
- return out2;
942
+ };
943
+
944
+ // src/detectors/ts-any-cast.ts
945
+ import ts from "typescript";
946
+
947
+ // src/diff/select.ts
948
+ function addedLines(c) {
949
+ if (c.kind !== "file") return [];
950
+ return c.hunks.flatMap((h) => h.lines.filter((l) => l.type === "add"));
649
951
  }
650
- function defaultPolicy(version = 1) {
651
- const p = {
652
- version,
653
- protected: {
654
- // Cover every JS/TS test extension, not just .test.ts — the multi-repo FP study found
655
- // .test.tsx/.spec.tsx (hono, zustand) slipped the glob, so legit test-file casts blocked
656
- // instead of warning. Kept ADDITIVE (originals + new extensions) so the policy-diff sees a
657
- // strengthening, not a narrowing. picomatch expands the brace.
658
- // test-skip, test-deletion and test-content-removal all gate on this
659
- // class, so a repo it does not match gets rules that can NEVER fire —
660
- // protection that is silently absent rather than visibly off. The JS/TS
661
- // list shipped alone; these are the conventional layouts of the other
662
- // ecosystems `init` is run in. (P2-13, external review.)
663
- tests: [
664
- // JavaScript / TypeScript
665
- "**/*.test.ts",
666
- "**/*.spec.ts",
667
- "**/*.{test,spec}.{tsx,cts,mts,js,jsx,cjs,mjs}",
668
- "**/__tests__/**",
669
- // Python (pytest / unittest layouts)
670
- "**/test_*.py",
671
- "**/*_test.py",
672
- "**/conftest.py",
673
- // Go
674
- "**/*_test.go",
675
- // Rust (integration tests live in tests/, unit tests inline)
676
- "**/tests/**/*.rs",
677
- // Ruby (RSpec / minitest)
678
- "**/*_spec.rb",
679
- "**/*_test.rb",
680
- // Java / Kotlin / Scala (Maven & Gradle convention)
681
- "**/src/test/**",
682
- // PHP (PHPUnit)
683
- "**/*Test.php",
684
- // C# (.NET)
685
- "**/*Tests.cs",
686
- "**/*Test.cs"
687
- ],
688
- config: [
689
- "**/jest.config.*",
690
- "**/vitest.config.*",
691
- // A `vitest.workspace.*` file REPLACES the root config's selection with the
692
- // union of its projects, and a `vite.config.*` carrying a `test:` key is the
693
- // vitest config when no vitest.config exists — both are where a spec can be
694
- // dropped from the suite without a line of it changing. ADDITIVE, like every
695
- // baseline widening (the policy-diff reads it as a strengthening).
696
- "**/vitest.workspace.*",
697
- "**/vite.config.*",
698
- "**/tsconfig*.json",
699
- "**/.eslintrc*",
700
- "**/eslint.config.*",
701
- "**/package.json",
702
- // Coverage gates outside the Jest/Vitest shapes. coverage-lowering could only
703
- // see a threshold it was shown: a Python `fail_under`, an nyc `lines`, a
704
- // codecov `target` lived in files no glob protected, so lowering them fired
705
- // nothing. ADDITIVE, like every baseline widening (the policy-diff reads it
706
- // as a strengthening). pyproject.toml carries [tool.coverage.report].
707
- "**/.coveragerc",
708
- "**/pyproject.toml",
709
- "**/.nycrc",
710
- "**/.nycrc.*",
711
- "**/nyc.config.*",
712
- "**/codecov.yml",
713
- "**/.codecov.yml"
714
- ],
715
- ci: [".github/workflows/**"],
716
- // Recorded expected outputs. An assertion stored as data is still an assertion;
717
- // rewriting it from current output is the snapshot-update move the affordance
718
- // experiment measured at a 70% attempt / 100% through rate (snapshot-rewrite).
719
- // tap-snapshots/** added from Phase-3 evidence: node-tar's recorded expectations
720
- // live there as .cjs and were rewritten unseen (PHASE3.md §4).
721
- snapshots: ["**/*.snap", "**/__snapshots__/**", "**/tap-snapshots/**", "**/golden/**", "**/*.golden.*"],
722
- // The wiring IS the enforcement: settings.json carries the PreToolUse and
723
- // Stop hooks, and a pre-commit config carries the commit-time gate. These
724
- // were installed by `init` and protected by nothing, so editing them out
725
- // fired no rule at all. (.git/hooks/** is outside every git view and so
726
- // cannot be covered here — noted in SECURITY-ENVELOPE.md.) (P1-4, review.)
727
- hooks: [
728
- ".husky/**",
729
- "**/lefthook.*",
730
- // lefthook merges `lefthook-local.yml` OVER `lefthook.yml`: a `skip: true`
731
- // there switches the gate off without touching the file init pointed at.
732
- "**/lefthook-local.*",
733
- ".tamperward.yml",
734
- "**/.tamperward.yml",
735
- ".claude/settings.json",
736
- ".claude/settings.local.json",
737
- "**/.pre-commit-config.yaml",
738
- // CODEOWNERS is enforcement wiring, not documentation: it is the only
739
- // thing that can require a human on a change to the workflow that
740
- // decides whether the gate runs at all. GitHub reads it from any of
741
- // these three locations. (H2.)
742
- ".github/CODEOWNERS",
743
- "CODEOWNERS",
744
- "docs/CODEOWNERS"
745
- ]
746
- },
747
- rules: {
748
- "test-deletion": { severity: "block" },
749
- // content gutted from a surviving spec — the class Phase 3 measured walking
750
- // through the block-count rule (rows, assertion arguments, setup). Severity
751
- // backed by the real-commit corpus measurement recorded in the rule's SPEC row.
752
- "test-content-removal": { severity: "block" },
753
- "test-skip": { severity: "block" },
754
- "ts-any-cast": { severity: "block" },
755
- // the unambiguous explicit casts + ts-suppression directives
756
- // broad any in annotation/generic position is common in legit code (measured ~84-100% FP as a
757
- // block rule on real TS), so it WARNs until a semantic (error-silencing-aware) signal earns block.
758
- "ts-any-launder": { severity: "warn" },
759
- "lint-suppression": { severity: "block" },
760
- "coverage-lowering": { severity: "block" },
761
- "ci-tampering": { severity: "block" },
762
- "hook-tampering": { severity: "block" },
763
- "no-verify": { severity: "block" },
764
- // mechanical but intent-ambiguous: updating a snapshot is the legitimate workflow
765
- // when intended output changes. WARN until the §7 evidence path earns block.
766
- "snapshot-rewrite": { severity: "warn" },
767
- // the watcher's transient class (modified-and-restored within a turn). WARN by
768
- // default: a Stop block already forces restores, and a compliant restore reads
769
- // as churn — block-by-default would deadlock the honest path. Harnesses that own
770
- // that semantics raise it via TAMPERWARD_TRANSIENT=block.
771
- "transient-protected-mutation": { severity: "warn" },
772
- // the narrow graduation candidate: snapshot moved with NO accompanying change.
773
- // ~0.06% FP on 1,652 audited mainline commits, 7/7 TP on observed tampers
774
- // (harness/fp-study/). warn until a version: gate graduates it (BLOCK_SINCE).
775
- "snapshot-only-rewrite": { severity: "warn" },
776
- // heuristic — warn until precision is measured (SPEC §7)
777
- "assertion-weakening": { severity: "warn" },
778
- "guard-removal": { severity: "warn" }
779
- },
780
- ignore: [],
781
- signoff: { requiredFor: ["block"], ledger: ".tamperward/ledger.jsonl" }
782
- };
783
- p.rules = applyVersionGates(p.rules, version);
784
- return p;
952
+ function removedLines(c) {
953
+ if (c.kind !== "file") return [];
954
+ return c.hunks.flatMap((h) => h.lines.filter((l) => l.type === "del"));
785
955
  }
786
956
 
787
957
  // src/detectors/files.ts
@@ -1164,12 +1334,12 @@ import ts2 from "typescript";
1164
1334
 
1165
1335
  // src/detectors/repo.ts
1166
1336
  import { execFileSync as execFileSync2 } from "node:child_process";
1167
- var cache2 = /* @__PURE__ */ new Map();
1337
+ var cache = /* @__PURE__ */ new Map();
1168
1338
  function trackedFiles(ctx) {
1169
1339
  if (!ctx) return null;
1170
1340
  if (ctx.trackedFiles) return ctx.trackedFiles;
1171
1341
  if (!ctx.cwd) return null;
1172
- const hit = cache2.get(ctx.cwd);
1342
+ const hit = cache.get(ctx.cwd);
1173
1343
  if (hit !== void 0) return hit;
1174
1344
  let files = null;
1175
1345
  try {
@@ -1182,7 +1352,7 @@ function trackedFiles(ctx) {
1182
1352
  } catch {
1183
1353
  files = null;
1184
1354
  }
1185
- cache2.set(ctx.cwd, files);
1355
+ cache.set(ctx.cwd, files);
1186
1356
  return files;
1187
1357
  }
1188
1358
  var branchCache = /* @__PURE__ */ new Map();
@@ -1280,8 +1450,8 @@ function numericOf(e) {
1280
1450
  }
1281
1451
  return void 0;
1282
1452
  }
1283
- function metricsOf(expr, resolve5) {
1284
- const obj = resolve5(expr);
1453
+ function metricsOf(expr, resolve6) {
1454
+ const obj = resolve6(expr);
1285
1455
  if (!ts2.isObjectLiteralExpression(obj)) return void 0;
1286
1456
  const out2 = { values: {}, opaque: false };
1287
1457
  let any = false;
@@ -1295,7 +1465,7 @@ function metricsOf(expr, resolve5) {
1295
1465
  const k = keyName(p.name);
1296
1466
  if (k === null || !isMetric(k)) continue;
1297
1467
  any = true;
1298
- const n = numericOf(resolve5(p.initializer));
1468
+ const n = numericOf(resolve6(p.initializer));
1299
1469
  if (n === void 0) out2.opaque = true;
1300
1470
  else out2.values[k] = n;
1301
1471
  }
@@ -1341,17 +1511,17 @@ function parseThresholds(src) {
1341
1511
  const res = { paths: /* @__PURE__ */ new Map(), present: false, opaque: false };
1342
1512
  try {
1343
1513
  const sf = sourceOf(src);
1344
- const resolve5 = resolverFor(sf);
1514
+ const resolve6 = resolverFor(sf);
1345
1515
  const visit = (node) => {
1346
1516
  if (ts2.isPropertyAssignment(node)) {
1347
1517
  const key2 = keyName(node.name);
1348
1518
  if (key2 === "coverageThreshold" || key2 === "thresholds" && underKey(node, "coverage")) {
1349
1519
  res.present = true;
1350
- const init = resolve5(node.initializer);
1520
+ const init = resolve6(node.initializer);
1351
1521
  if (!ts2.isObjectLiteralExpression(init)) {
1352
1522
  res.opaque = true;
1353
1523
  } else {
1354
- const flat = metricsOf(init, resolve5);
1524
+ const flat = metricsOf(init, resolve6);
1355
1525
  if (flat) res.global = merge(res.global, flat);
1356
1526
  for (const p of init.properties) {
1357
1527
  if (ts2.isSpreadAssignment(p)) {
@@ -1365,7 +1535,7 @@ function parseThresholds(src) {
1365
1535
  res.global = merge(res.global, { values: { branches: 100, functions: 100, lines: 100, statements: 100 }, opaque: false });
1366
1536
  continue;
1367
1537
  }
1368
- const m = metricsOf(p.initializer, resolve5);
1538
+ const m = metricsOf(p.initializer, resolve6);
1369
1539
  if (!m) continue;
1370
1540
  if (k === "global") res.global = merge(res.global, m);
1371
1541
  else res.paths.set(norm(k), m);
@@ -1398,18 +1568,18 @@ function parseLists(src) {
1398
1568
  const res = { collectFrom: null, collectKey: "collectCoverageFrom", ignore: [], opaqueCollect: false, opaqueIgnore: false };
1399
1569
  try {
1400
1570
  const sf = sourceOf(src);
1401
- const resolve5 = resolverFor(sf);
1571
+ const resolve6 = resolverFor(sf);
1402
1572
  const visit = (node) => {
1403
1573
  if (ts2.isPropertyAssignment(node)) {
1404
1574
  const k = keyName(node.name);
1405
1575
  const vitestCoverage = ownerKey(node) === "coverage";
1406
1576
  if (k === "collectCoverageFrom" || k === "include" && vitestCoverage) {
1407
- const { items, opaque } = literals(resolve5(node.initializer));
1577
+ const { items, opaque } = literals(resolve6(node.initializer));
1408
1578
  res.collectFrom = items;
1409
1579
  res.collectKey = k === "include" ? "coverage.include" : k;
1410
1580
  if (opaque) res.opaqueCollect = true;
1411
1581
  } else if (k === "coveragePathIgnorePatterns" || k === "exclude" && vitestCoverage) {
1412
- const { items, opaque } = literals(resolve5(node.initializer));
1582
+ const { items, opaque } = literals(resolve6(node.initializer));
1413
1583
  res.ignore.push(...items);
1414
1584
  if (opaque) res.opaqueIgnore = true;
1415
1585
  }
@@ -2490,19 +2660,20 @@ var ciTampering = {
2490
2660
  };
2491
2661
 
2492
2662
  // src/detectors/hook-tampering.ts
2493
- import { existsSync, readFileSync as readFileSync3 } from "node:fs";
2494
- import { isAbsolute, join as join3, relative } from "node:path";
2663
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
2664
+ import { isAbsolute as isAbsolute3, join as join3, relative } from "node:path";
2495
2665
 
2496
2666
  // src/wiring.ts
2497
- import { readFileSync as readFileSync2 } from "node:fs";
2498
- import { dirname, join as join2 } from "node:path";
2667
+ import { readFileSync, realpathSync as realpathSync2 } from "node:fs";
2668
+ import { homedir } from "node:os";
2669
+ import { basename, dirname, isAbsolute, join as join2, posix } from "node:path";
2499
2670
  import { fileURLToPath } from "node:url";
2500
2671
  function shippedVersion() {
2501
2672
  try {
2502
2673
  const here = dirname(fileURLToPath(import.meta.url));
2503
2674
  for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
2504
2675
  try {
2505
- const pkg = JSON.parse(readFileSync2(join2(here, rel), "utf8"));
2676
+ const pkg = JSON.parse(readFileSync(join2(here, rel), "utf8"));
2506
2677
  if (pkg.name === "tamperward" && pkg.version) return pkg.version;
2507
2678
  } catch {
2508
2679
  }
@@ -2519,7 +2690,7 @@ var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
2519
2690
  var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
2520
2691
  var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
2521
2692
  var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
2522
- var PLAIN_SEMVER = /^\d+\.\d+\.\d+$/;
2693
+ var PLAIN_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
2523
2694
  function compareVersions(a, b) {
2524
2695
  if (!PLAIN_SEMVER.test(a) || !PLAIN_SEMVER.test(b)) return null;
2525
2696
  const pa = a.split(".").map(Number);
@@ -2537,8 +2708,41 @@ function initScriptPin(src) {
2537
2708
  const m = src.match(INIT_SCRIPT);
2538
2709
  return m ? m[1] ?? "" : null;
2539
2710
  }
2711
+ function homeDir(env = process.env) {
2712
+ const h = (env.HOME ?? env.USERPROFILE ?? "").trim() || homedir();
2713
+ return h.replace(/\\/g, "/").replace(/(?<=.)\/+$/, "");
2714
+ }
2715
+ function claudeConfigDir(env = process.env) {
2716
+ const dir = (env.CLAUDE_CONFIG_DIR ?? "").trim().replace(/\\/g, "/").replace(/(?<=.)\/+$/, "");
2717
+ return dir || `${homeDir(env)}/.claude`;
2718
+ }
2540
2719
  var CLAUDE_SETTINGS = /(?:^|\/)(?:\.claude\/settings(?:\.local)?\.json|managed-settings\.json)$/;
2541
- var isClaudeSettings = (path) => CLAUDE_SETTINGS.test(path);
2720
+ function isClaudeSettings(path, env = process.env) {
2721
+ const p = path.replace(/\\/g, "/");
2722
+ if (CLAUDE_SETTINGS.test(p)) return true;
2723
+ const dir = (env.CLAUDE_CONFIG_DIR ?? "").trim().replace(/\\/g, "/").replace(/(?<=.)\/+$/, "");
2724
+ return dir !== "" && (p === `${dir}/settings.json` || p === `${dir}/settings.local.json`);
2725
+ }
2726
+ function canonicalPath(path) {
2727
+ const tail = [];
2728
+ let cur = path;
2729
+ for (let i = 0; i < 256; i++) {
2730
+ try {
2731
+ const real = realpathSync2(cur).replace(/\\/g, "/");
2732
+ return tail.length ? posix.join(real, ...tail.reverse()) : real;
2733
+ } catch {
2734
+ }
2735
+ const parent = dirname(cur);
2736
+ if (parent === cur) break;
2737
+ tail.push(basename(cur));
2738
+ cur = parent;
2739
+ }
2740
+ return path;
2741
+ }
2742
+ function resolvesToClaudeSettings(path, env = process.env) {
2743
+ if (isClaudeSettings(path, env)) return true;
2744
+ return isAbsolute(path) && isClaudeSettings(canonicalPath(path), env);
2745
+ }
2542
2746
 
2543
2747
  // src/detectors/policy-diff.ts
2544
2748
  import { parse } from "yaml";
@@ -2665,6 +2869,8 @@ function policyAddWeakening(after) {
2665
2869
  }
2666
2870
 
2667
2871
  // src/detectors/hook-wiring.ts
2872
+ import { existsSync } from "node:fs";
2873
+ import { isAbsolute as isAbsolute2, resolve } from "node:path";
2668
2874
  import { parse as parseYaml } from "yaml";
2669
2875
  function stripComment(line) {
2670
2876
  let single = false;
@@ -3655,7 +3861,8 @@ var WRAPPERS = /^(?:sudo|command|exec|time|nice|env|[A-Za-z_][A-Za-z0-9_]*=\S*)$
3655
3861
  var INTERPRETERS = /* @__PURE__ */ new Set(["python", "python2", "python3", "node", "perl", "ruby", "php", "deno", "bun"]);
3656
3862
  var INLINE_FLAG = /^-(?:c|e|r|E|p|pe|pi|i|ne|ni|le)$|^--eval$|^--print$|^--exec$/;
3657
3863
  var EDITORS = /* @__PURE__ */ new Set(["ex", "ed", "vim", "vi", "nvim"]);
3658
- var HOOK_BASENAMES = ["pre-commit", "pre-push", "commit-msg", "settings.json", "settings.local.json", ".tamperward.yml", "lefthook.yml", "lefthook.yaml", "lefthook-local.yml", ".pre-commit-config.yaml", "CODEOWNERS"];
3864
+ var SCRIPT_WORD = /[\w.~/$%{}\\-]+/g;
3865
+ var HOOK_BASENAMES = ["pre-commit", "pre-push", "commit-msg", "settings.json", "settings.local.json", "managed-settings.json", ".tamperward.yml", "lefthook.yml", "lefthook.yaml", "lefthook-local.yml", ".pre-commit-config.yaml", "CODEOWNERS"];
3659
3866
  function nameGlob(pattern) {
3660
3867
  const re = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
3661
3868
  return (n) => re.test(n);
@@ -3694,24 +3901,56 @@ function destinations(cmd, args, isDir) {
3694
3901
  return [dest];
3695
3902
  }
3696
3903
  var REDIRECT_TARGET = /(?:^|\s)(?:\d*>{1,2}\|?|&>{1,2})\s*(\S+)/g;
3904
+ function expandShellPath(t, env = process.env) {
3905
+ const home = homeDir(env);
3906
+ return t.replace(/\\/g, "/").replace(/^~(?=\/|$)/, home).replace(/\$(?:\{HOME\}|HOME\b|ENV\{HOME\})/g, home).replace(/%USERPROFILE%/gi, home).replace(/\$(?:\{CLAUDE_CONFIG_DIR\}|CLAUDE_CONFIG_DIR\b)/g, claudeConfigDir(env));
3907
+ }
3697
3908
  function hookTests(policy, ctx) {
3909
+ const cwd = ctx?.cwd;
3910
+ const onDisk = cwd !== void 0 && existsSync(cwd);
3911
+ const root = onDisk ? canonicalPath(cwd) : cwd;
3912
+ const memo = /* @__PURE__ */ new Map();
3698
3913
  const rel = (t) => {
3699
- let p = t.replace(/^["']|["']$/g, "");
3700
- if (ctx?.cwd && p.startsWith(ctx.cwd + "/")) p = p.slice(ctx.cwd.length + 1);
3701
- return p.replace(/^\.\/(?=.)/, "");
3914
+ const hit = memo.get(t);
3915
+ if (hit !== void 0) return hit;
3916
+ let p = expandShellPath(t.replace(/^["']|["']$/g, ""));
3917
+ const pathy = isAbsolute2(p) || p.includes("/") && !/^[-:]/.test(p);
3918
+ if (pathy && (isAbsolute2(p) || onDisk)) {
3919
+ p = canonicalPath(isAbsolute2(p) ? p : resolve(cwd, p));
3920
+ if (root !== void 0) {
3921
+ if (p === root) p = ".";
3922
+ else if (p.startsWith(root + "/")) p = p.slice(root.length + 1);
3923
+ }
3924
+ }
3925
+ if (cwd !== void 0 && p.startsWith(cwd + "/")) p = p.slice(cwd.length + 1);
3926
+ p = p.replace(/^\.\/(?=.)/, "");
3927
+ memo.set(t, p);
3928
+ return p;
3929
+ };
3930
+ const hook = (t) => {
3931
+ const p = rel(t);
3932
+ return isProtected(p, policy, "hooks") || isClaudeSettings(p) || isClaudeSettings(expandShellPath(t.replace(/^["']|["']$/g, "")));
3933
+ };
3934
+ const whole = (t) => /^(?:\.|\.\/|:\/|\*)$/.test(rel(t)) || cwd !== void 0 && rel(t) === cwd;
3935
+ const userSettings = `${claudeConfigDir()}/settings.json`;
3936
+ const userSettingsUnder = (t) => {
3937
+ const dir = rel(t).replace(/\/+$/, "");
3938
+ return dir !== "" && dir !== "/" && userSettings.startsWith(dir + "/") ? userSettings : null;
3702
3939
  };
3703
- const hook = (t) => isProtected(rel(t), policy, "hooks");
3704
- const whole = (t) => /^(?:\.|\.\/|:\/|\*)$/.test(rel(t)) || ctx?.cwd !== void 0 && rel(t) === ctx.cwd;
3705
- const holds = (t) => !t.startsWith("-") && (whole(t) || containsProtected(rel(t), policy, "hooks", ctx));
3940
+ const holds = (t) => !t.startsWith("-") && (whole(t) || userSettingsUnder(t) !== null || containsProtected(rel(t), policy, "hooks", ctx));
3706
3941
  const hookish = (t) => hook(t) || holds(t);
3707
- return { rel, hook, whole, holds, hookish };
3942
+ return { rel, hook, whole, holds, hookish, userSettingsUnder };
3708
3943
  }
3709
3944
  function shellHookTarget(seg, toks, policy, ctx) {
3710
- const { rel, hook, whole, holds } = hookTests(policy, ctx);
3945
+ const { rel, hook, whole, holds, userSettingsUnder } = hookTests(policy, ctx);
3711
3946
  for (const m of seg.matchAll(REDIRECT_TARGET)) {
3712
3947
  if (hook(m[1])) return rel(m[1]);
3713
3948
  }
3714
3949
  const args = toks.map((t) => t.replace(/^(?:of=|--(?:include|exclude|directory)=)/, ""));
3950
+ for (let i = 1; i < args.length; i++) {
3951
+ if (!INLINE_FLAG.test(args[i - 1])) continue;
3952
+ for (const word of args[i].match(SCRIPT_WORD) ?? []) if (hook(word)) return rel(word);
3953
+ }
3715
3954
  const fileLike = (p) => {
3716
3955
  const b = p.replace(/\/+$/, "").split("/").pop() ?? "";
3717
3956
  return HOOK_BASENAMES.includes(b) || /\.[A-Za-z0-9]+$/.test(b.replace(/^\.+/, "")) || (trackedFiles(ctx)?.includes(p) ?? false);
@@ -3719,6 +3958,8 @@ function shellHookTarget(seg, toks, policy, ctx) {
3719
3958
  for (const t of args) if (hook(t) && fileLike(rel(t))) return rel(t);
3720
3959
  for (const t of args) {
3721
3960
  if (t.startsWith("-") || !holds(t)) continue;
3961
+ const user = userSettingsUnder(t);
3962
+ if (user !== null) return user;
3722
3963
  const dir = rel(t).replace(/\/+$/, "");
3723
3964
  const under = whole(t) ? "" : dir + "/";
3724
3965
  const listed = trackedFiles(ctx)?.find((f) => f.startsWith(under) && isProtected(f, policy, "hooks"));
@@ -3742,7 +3983,7 @@ function shellWritesHook(seg, toks, policy, ctx) {
3742
3983
  const anyHook = args.some(hook);
3743
3984
  const inPlace = args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a));
3744
3985
  const scripts = args.filter((_, i) => i > 0 && INLINE_FLAG.test(args[i - 1]));
3745
- const namesHookInText = () => scripts.some((a) => (a.match(/[\w.~/-]+/g) ?? []).some(hook));
3986
+ const namesHookInText = () => scripts.some((a) => (a.match(SCRIPT_WORD) ?? []).some(hook));
3746
3987
  switch (cmd) {
3747
3988
  case "rm":
3748
3989
  case "unlink":
@@ -3826,8 +4067,8 @@ function shellWritesHook(seg, toks, policy, ctx) {
3826
4067
  function xargsWritesHook(toks, fed, policy, ctx) {
3827
4068
  const at = toks.findIndex((t) => t === "xargs" || t.endsWith("/xargs"));
3828
4069
  if (at === -1) return null;
3829
- const rel = (t) => (ctx?.cwd && t.startsWith(ctx.cwd + "/") ? t.slice(ctx.cwd.length + 1) : t).replace(/^\.\//, "");
3830
- const fedHook = fed.some((t) => isProtected(rel(t), policy, "hooks") || !t.startsWith("-") && containsProtected(rel(t), policy, "hooks", ctx));
4070
+ const { hookish } = hookTests(policy, ctx);
4071
+ const fedHook = fed.some(hookish);
3831
4072
  if (!fedHook) return null;
3832
4073
  let i = at + 1;
3833
4074
  while (i < toks.length && toks[i].startsWith("-")) i += /^-(?:I|n|L|P|d|a|s)$/.test(toks[i]) ? 2 : 1;
@@ -3859,11 +4100,23 @@ function eventEntries(s, event) {
3859
4100
  return Array.isArray(arr) ? arr : null;
3860
4101
  }
3861
4102
  var declares = (s, event) => eventEntries(s, event) !== null;
3862
- var GATE_KEYS = /* @__PURE__ */ new Set(["type", "command", "timeout"]);
4103
+ var GATE_KEYS = /* @__PURE__ */ new Set(["type", "command", "timeout", "statusMessage"]);
4104
+ var HOOK_TYPES = /* @__PURE__ */ new Set(["command", "prompt", "agent", "http"]);
4105
+ var SIBLING_KEYS = GATE_KEYS;
3863
4106
  var CANONICAL_CMD = /^npx --yes tamperward@(\S+) (hook|sweep) claude$/;
3864
4107
  var MENTIONS_GATE = /\btamperward\b.*\b(?:hook|sweep)\b/;
3865
4108
  var FORGERY = /hookSpecificOutput|permissionDecision|updatedInput|"decision"|"continue"/;
3866
- var SENSITIVE_ENV = /^(?:PATH|NODE_OPTIONS|NODE_PATH|HOME|npm_config_.*|NPM_CONFIG_.*)$/;
4109
+ var ENV_MECHANISM = [
4110
+ [/^PATH$/, "the gate's launcher is looked up through it"],
4111
+ [/^(?:NODE_OPTIONS|NODE_PATH)$/, "node loads what it names before the gate runs"],
4112
+ [/^HOME$/, "npx's cache and configuration live under it"],
4113
+ [/^npm_config_/i, "npx reads it as npm configuration, the registry the gate is fetched from included"],
4114
+ [/^(?:BASH_ENV|ENV)$/, "a non-interactive shell sources it before every hook command"],
4115
+ [/^BASH_FUNC_/, "bash imports it as a function that shadows the command it names"],
4116
+ [/^(?:LD|DYLD)_/, "the dynamic loader injects it into every process, the gate included"],
4117
+ [/^TAMPERWARD_/, "it configures the gate itself"]
4118
+ ];
4119
+ var envMechanism = (k) => ENV_MECHANISM.find(([re]) => re.test(k))?.[1] ?? null;
3867
4120
  function analyseGateCommand(cmd, event) {
3868
4121
  const want = event === "PreToolUse" ? "hook" : "sweep";
3869
4122
  const problems = [];
@@ -3922,7 +4175,7 @@ function matcherSelection(m) {
3922
4175
  if (m === "" || m === "*") return { tools: null, problem: null };
3923
4176
  let problem = null;
3924
4177
  if (/[^\x00-\x7f]/.test(m)) problem = "the matcher contains non-ASCII characters that look like tool names but are not";
3925
- if (/^[A-Za-z0-9_ |-]+$/.test(m)) return { tools: new Set(m.split("|").map((t) => t.trim()).filter(Boolean)), problem };
4178
+ if (/^[A-Za-z0-9_ ,|-]+$/.test(m)) return { tools: new Set(m.split(/[|,]/).map((t) => t.trim()).filter(Boolean)), problem };
3926
4179
  let re;
3927
4180
  try {
3928
4181
  re = new RegExp(m);
@@ -3931,48 +4184,58 @@ function matcherSelection(m) {
3931
4184
  }
3932
4185
  return { tools: new Set(PRE_TOOLS.filter((t) => re.test(t))), problem };
3933
4186
  }
4187
+ function entryShape(h) {
4188
+ const problems = [];
4189
+ let runs = true;
4190
+ if (h.type !== "command") {
4191
+ problems.push(h.type === void 0 ? "no `type`" : `\`type\` is ${JSON.stringify(h.type)}`);
4192
+ if (h.type !== void 0) runs = false;
4193
+ }
4194
+ for (const k of Object.keys(h)) {
4195
+ if (GATE_KEYS.has(k)) continue;
4196
+ problems.push(`carries \`${k}\``);
4197
+ runs = false;
4198
+ }
4199
+ if ("timeout" in h) {
4200
+ const t = h.timeout;
4201
+ if (typeof t !== "number" || !Number.isInteger(t) || t < 120) {
4202
+ problems.push(`\`timeout\` ${JSON.stringify(t)} \u2014 the gate is cut off before it answers`);
4203
+ runs = false;
4204
+ }
4205
+ }
4206
+ if ("statusMessage" in h && typeof h.statusMessage !== "string") {
4207
+ problems.push(`\`statusMessage\` ${JSON.stringify(h.statusMessage)} is not a string`);
4208
+ runs = false;
4209
+ }
4210
+ return { problems, runs };
4211
+ }
4212
+ var mentionsGate = (v) => /\btamperward\b/.test(JSON.stringify(v) ?? "");
3934
4213
  function gateCandidates(s, event) {
3935
4214
  const out2 = [];
3936
- const mentions = (v) => /\btamperward\b/.test(JSON.stringify(v) ?? "");
3937
4215
  for (const group of eventEntries(s, event) ?? []) {
3938
4216
  if (!isObj(group)) {
3939
- if (mentions(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["the matcher entry is not an object"], pin: null, tools: null });
4217
+ if (mentionsGate(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["the matcher entry is not an object"], pin: null, tools: null });
3940
4218
  continue;
3941
4219
  }
3942
4220
  if (!Array.isArray(group.hooks)) {
3943
- if (mentions(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["`hooks` is not a list"], pin: null, tools: null });
4221
+ if (mentionsGate(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["`hooks` is not a list"], pin: null, tools: null });
3944
4222
  continue;
3945
4223
  }
3946
4224
  for (const h of group.hooks) {
3947
4225
  if (!isObj(h)) {
3948
- if (mentions(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the hook entry is not an object"], pin: null, tools: null });
4226
+ if (mentionsGate(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the hook entry is not an object"], pin: null, tools: null });
3949
4227
  continue;
3950
4228
  }
3951
4229
  const cmd = typeof h.command === "string" ? h.command : null;
3952
4230
  if (cmd === null) {
3953
- if (mentions(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the gate is not in a `command` string"], pin: null, tools: null });
4231
+ if (mentionsGate(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the gate is not in a `command` string"], pin: null, tools: null });
3954
4232
  continue;
3955
4233
  }
3956
4234
  if (!MENTIONS_GATE.test(cmd)) continue;
3957
4235
  const a = analyseGateCommand(cmd, event);
3958
- const problems = [...a.problems];
3959
- let runs = a.runs;
3960
- if (h.type !== "command") {
3961
- problems.push(h.type === void 0 ? "no `type`" : `\`type\` is ${JSON.stringify(h.type)}`);
3962
- if (h.type !== void 0) runs = false;
3963
- }
3964
- for (const k of Object.keys(h)) {
3965
- if (GATE_KEYS.has(k)) continue;
3966
- problems.push(`carries \`${k}\``);
3967
- runs = false;
3968
- }
3969
- if ("timeout" in h) {
3970
- const t = h.timeout;
3971
- if (typeof t !== "number" || !Number.isInteger(t) || t < 120) {
3972
- problems.push(`\`timeout\` ${JSON.stringify(t)} \u2014 the gate is cut off before it answers`);
3973
- runs = false;
3974
- }
3975
- }
4236
+ const shape = entryShape(h);
4237
+ const problems = [...a.problems, ...shape.problems];
4238
+ let runs = a.runs && shape.runs;
3976
4239
  let tools = null;
3977
4240
  if (event === "PreToolUse") {
3978
4241
  const sel = matcherSelection(group.matcher);
@@ -3989,6 +4252,7 @@ function gateCandidates(s, event) {
3989
4252
  }
3990
4253
  return out2;
3991
4254
  }
4255
+ var carriesGate = (s) => EVENTS.some((e) => gateCandidates(s, e).length > 0);
3992
4256
  function otherCommands(s, event) {
3993
4257
  const out2 = [];
3994
4258
  for (const group of eventEntries(s, event) ?? []) {
@@ -4000,6 +4264,35 @@ function otherCommands(s, event) {
4000
4264
  }
4001
4265
  return out2;
4002
4266
  }
4267
+ function allEntries(s) {
4268
+ const hooks = s.hooks;
4269
+ if (!isObj(hooks)) return [];
4270
+ const out2 = [];
4271
+ for (const [event, arr] of Object.entries(hooks)) {
4272
+ if (!Array.isArray(arr)) continue;
4273
+ for (const group of arr) {
4274
+ if (!isObj(group) || !Array.isArray(group.hooks)) continue;
4275
+ for (const h of group.hooks) out2.push({ event, key: JSON.stringify({ e: event, m: group.matcher, h }), group, h });
4276
+ }
4277
+ }
4278
+ return out2;
4279
+ }
4280
+ function schemaProblem(group, h) {
4281
+ if (group.matcher !== void 0 && typeof group.matcher !== "string") return `the matcher ${JSON.stringify(group.matcher)} is not a string`;
4282
+ if (!isObj(h)) return `the hook entry ${JSON.stringify(h)} is not an object`;
4283
+ if (typeof h.type !== "string" || !HOOK_TYPES.has(h.type)) return `\`type\` ${JSON.stringify(h.type)} is not one the runtime accepts`;
4284
+ if (h.type === "command" && typeof h.command !== "string") return "`command` is not a string";
4285
+ if ("timeout" in h && (typeof h.timeout !== "number" || !Number.isFinite(h.timeout) || h.timeout <= 0)) return `\`timeout\` ${JSON.stringify(h.timeout)} is not a positive number`;
4286
+ return null;
4287
+ }
4288
+ function siblingProblem(h) {
4289
+ const cmd = typeof h.command === "string" ? h.command : "";
4290
+ if (FORGERY.test(cmd)) return `emits a hook decision of its own (\`${cmd.slice(0, 60)}\`)`;
4291
+ if (h.type !== "command") return `is not a plain command entry (\`type\` is ${JSON.stringify(h.type)})`;
4292
+ const extra = Object.keys(h).filter((k) => !SIBLING_KEYS.has(k));
4293
+ if (extra.length) return `is not a plain command entry (carries \`${extra.join("`, `")}\`)`;
4294
+ return `was added (\`${cmd.slice(0, 60)}\`) \u2014 its output combines with the gate's verdict (an \`updatedInput\` after an allow, a \`continue: false\` over a block), and init writes no entry beside the gate`;
4295
+ }
4003
4296
  function coverage(cs) {
4004
4297
  const live = cs.filter((c) => c.runs);
4005
4298
  if (live.length === 0) return "none";
@@ -4050,16 +4343,26 @@ function duplicateJsonKeys(src) {
4050
4343
  }
4051
4344
  function settingsWeakening(before, after, local) {
4052
4345
  const reasons = [];
4346
+ const gated = carriesGate(after);
4347
+ const gatedBefore = before !== null && carriesGate(before);
4053
4348
  const dis = after.disableAllHooks;
4054
4349
  if (dis !== void 0 && dis !== false && !(before !== null && before.disableAllHooks === dis)) {
4055
4350
  reasons.push(`\`disableAllHooks: ${JSON.stringify(dis)}\` switches every hook off, the gate included`);
4056
4351
  }
4352
+ if (gated && dis === void 0) {
4353
+ if (before !== null && before.disableAllHooks === false) {
4354
+ reasons.push("`disableAllHooks: false` was removed \u2014 init declares it so that a `true` in the user settings file cannot switch the gate off");
4355
+ } else if (!gatedBefore) {
4356
+ reasons.push("`disableAllHooks: false` is not declared \u2014 init declares it in the file it writes, so that a `true` in the user settings file cannot switch the gate off");
4357
+ }
4358
+ }
4057
4359
  if (isObj(after.env)) {
4058
4360
  const was = before !== null && isObj(before.env) ? before.env : {};
4059
4361
  for (const [k, v] of Object.entries(after.env)) {
4060
- if (SENSITIVE_ENV.test(k) && JSON.stringify(was[k]) !== JSON.stringify(v)) {
4061
- reasons.push(`a top-level \`env\` sets ${k} \u2014 the gate command resolves through it`);
4062
- }
4362
+ if (k in was && JSON.stringify(was[k]) === JSON.stringify(v)) continue;
4363
+ const how = envMechanism(k);
4364
+ if (how) reasons.push(`a top-level \`env\` sets ${k} \u2014 ${how}`);
4365
+ else if (gated) reasons.push(`a top-level \`env\` sets ${k} \u2014 every hook command runs with it, and init writes no \`env\``);
4063
4366
  }
4064
4367
  }
4065
4368
  for (const event of EVENTS) {
@@ -4087,15 +4390,36 @@ function settingsWeakening(before, after, local) {
4087
4390
  if (c.problems.length) reasons.push(`the ${event} gate entry does not match the shape \`tamperward init\` writes: ${c.problems.join("; ")}`);
4088
4391
  }
4089
4392
  const pins = bc.map((c) => c.pin).filter((p) => !!p && PLAIN_SEMVER.test(p)).sort((a, b) => compareVersions(b, a) ?? 0);
4090
- const floor = pins[0] ?? (before === null && PLAIN_SEMVER.test(TW_VERSION) ? TW_VERSION : null);
4091
- if (floor) {
4092
- for (const c of fresh) {
4093
- if (c.pin && PLAIN_SEMVER.test(c.pin) && !pinNotBelow(c.pin, floor)) reasons.push(`the ${event} gate is pinned to tamperward@${c.pin}, below ${floor}`);
4393
+ const judging = PLAIN_SEMVER.test(TW_VERSION) ? TW_VERSION : null;
4394
+ const floor = pins[0] ?? (bc.length === 0 ? judging : null);
4395
+ for (const c of fresh) {
4396
+ if (c.pin === null || c.pin !== "" && !PLAIN_SEMVER.test(c.pin)) continue;
4397
+ if (floor !== null && !pinNotBelow(c.pin, floor)) {
4398
+ reasons.push(
4399
+ c.pin === "" ? `the ${event} gate is unpinned, below ${floor} \u2014 \`npx\` then runs whatever \`tamperward\` resolves to on this machine` : `the ${event} gate is pinned to tamperward@${c.pin}, below ${floor}`
4400
+ );
4401
+ } else if (judging !== null && c.pin !== "" && (compareVersions(c.pin, judging) ?? 0) > 0) {
4402
+ reasons.push(`the ${event} gate is pinned to tamperward@${c.pin}, above the ${judging} judging it \u2014 a pin npm cannot resolve fails the hook open; raise it by running \`tamperward init\` from the newer version, which raises the judging gate with it`);
4403
+ }
4404
+ }
4405
+ }
4406
+ if (gated) {
4407
+ const had = new Set(before !== null ? allEntries(before).map((e) => e.key) : []);
4408
+ for (const e of allEntries(after)) {
4409
+ if (had.has(e.key) || mentionsGate(e.h)) continue;
4410
+ const schema = schemaProblem(e.group, e.h);
4411
+ if (schema) {
4412
+ reasons.push(`a ${e.event} hook entry carries a value the runtime's schema rejects (${schema}) \u2014 the runtime continues without the broken file, the gate included`);
4413
+ } else if (e.event === "PreToolUse" || e.event === "Stop") {
4414
+ reasons.push(`a ${e.event} hook entry beside the gate ${siblingProblem(e.h)}`);
4094
4415
  }
4095
4416
  }
4096
- const hadOther = new Set(before !== null ? otherCommands(before, event) : []);
4097
- for (const cmd of otherCommands(after, event)) {
4098
- if (!hadOther.has(cmd) && FORGERY.test(cmd)) reasons.push(`a ${event} hook entry beside the gate emits a hook decision of its own (\`${cmd.slice(0, 60)}\`)`);
4417
+ } else {
4418
+ for (const event of EVENTS) {
4419
+ const hadOther = new Set(before !== null ? otherCommands(before, event) : []);
4420
+ for (const cmd of otherCommands(after, event)) {
4421
+ if (!hadOther.has(cmd) && FORGERY.test(cmd)) reasons.push(`a ${event} hook entry beside the gate emits a hook decision of its own (\`${cmd.slice(0, 60)}\`)`);
4422
+ }
4099
4423
  }
4100
4424
  }
4101
4425
  return reasons;
@@ -4108,7 +4432,7 @@ function claudeSettingsFindings(c, policy) {
4108
4432
  file: c.path,
4109
4433
  message: `The Claude Code hook wiring was weakened: ${reason}.`,
4110
4434
  evidence: reason,
4111
- remediation: "Leave the PreToolUse and Stop entries that run tamperward, and the matcher that covers every editing tool, exactly as `tamperward init` wrote them. Editing the wiring is editing the gate: re-run `tamperward init` to restore it, or sign off."
4435
+ remediation: "Leave the PreToolUse and Stop entries that run tamperward, the matcher that covers every editing tool, and `disableAllHooks: false` exactly as `tamperward init` wrote them, with no `env` and no hook entry beside the gate. Editing the wiring is editing the gate: re-run `tamperward init` to restore it, or sign off."
4112
4436
  });
4113
4437
  if (c.before != null && before === null) return null;
4114
4438
  if (after === null) {
@@ -4151,7 +4475,7 @@ function lefthookBase(c, changes, ctx) {
4151
4475
  for (const n of names) {
4152
4476
  try {
4153
4477
  const p = join3(ctx.cwd, n);
4154
- if (existsSync(p)) return parseDoc(readFileSync3(p, "utf8"));
4478
+ if (existsSync2(p)) return parseDoc(readFileSync2(p, "utf8"));
4155
4479
  } catch {
4156
4480
  }
4157
4481
  }
@@ -4221,9 +4545,9 @@ function updateIndexHiding(toks, policy) {
4221
4545
  return null;
4222
4546
  }
4223
4547
  function repoRelative(path, ctx) {
4224
- if (!ctx?.cwd || !isAbsolute(path)) return path;
4548
+ if (!ctx?.cwd || !isAbsolute3(path)) return path;
4225
4549
  const rel = relative(ctx.cwd, path);
4226
- return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
4550
+ return rel && !rel.startsWith("..") && !isAbsolute3(rel) ? rel : path;
4227
4551
  }
4228
4552
  var isHuskyScript = (path) => /(?:^|\/)\.husky\/[^/]+$/.test(path);
4229
4553
  var hookTampering = {
@@ -4235,7 +4559,7 @@ var hookTampering = {
4235
4559
  for (const c of changes) {
4236
4560
  let semantic;
4237
4561
  if (c.kind === "file") {
4238
- const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false) || isClaudeSettings(c.path);
4562
+ const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false) || resolvesToClaudeSettings(c.path);
4239
4563
  if (!targetsHook) {
4240
4564
  if (isPackageJson(c.path) && c.before != null && c.after != null) {
4241
4565
  for (const reason of packageJsonWeakening(c.before, c.after)) {
@@ -4279,7 +4603,7 @@ var hookTampering = {
4279
4603
  remediation: "Renaming a hook out of place disables it. Restore the original path."
4280
4604
  })
4281
4605
  );
4282
- } else if ((c.path.endsWith(POLICY_FILE) || isClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c, policy)) !== null) {
4606
+ } else if ((c.path.endsWith(POLICY_FILE) || resolvesToClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c, policy)) !== null) {
4283
4607
  out2.push(...semantic);
4284
4608
  } else if (c.op === "rename") {
4285
4609
  } else if (isCodeowners(c.path)) {
@@ -4419,10 +4743,12 @@ var hookTampering = {
4419
4743
  const why = shellWritesHook(seg, toks, policy, ctx) ?? xargsWritesHook(toks, fed, policy, ctx);
4420
4744
  if (why) {
4421
4745
  const target = shellHookTarget(seg, toks, policy, ctx) ?? shellHookTarget("", fed, policy, ctx);
4746
+ const file = target ? repoRelative(target, ctx) : void 0;
4747
+ const outside = file !== void 0 && (isAbsolute3(file) || /^[A-Za-z]:\//.test(file)) && isClaudeSettings(file) ? ` \u2014 ${file} is a Claude Code settings file the runtime reads hooks from` : "";
4422
4748
  out2.push(
4423
4749
  makeFinding(RULE6, policy, {
4424
- ...target ? { file: repoRelative(target, ctx) } : {},
4425
- message: `Hook tampering via shell: ${why}.`,
4750
+ ...file !== void 0 ? { file } : {},
4751
+ message: `Hook tampering via shell: ${why}${outside}.`,
4426
4752
  evidence: seg,
4427
4753
  remediation: "Leave the hooks in place. Mutating them from the shell is still tampering."
4428
4754
  })
@@ -5327,7 +5653,7 @@ var GUARDED_RULE = "hook-tampering";
5327
5653
  var BASELINE_HOOKS = defaultPolicy().protected.hooks;
5328
5654
  function isGuardedFinding(f) {
5329
5655
  if (f.rule !== GUARDED_RULE || !f.file) return false;
5330
- return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS) || isClaudeSettings(f.file);
5656
+ return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS) || resolvesToClaudeSettings(f.file);
5331
5657
  }
5332
5658
  function key(f) {
5333
5659
  return `${f.rule}|${f.file ?? ""}|${f.line ?? ""}|${f.evidence}`;
@@ -5386,8 +5712,8 @@ function hasBlocking(findings) {
5386
5712
  }
5387
5713
 
5388
5714
  // src/policy-load.ts
5389
- import { readFileSync as readFileSync4, existsSync as existsSync2 } from "node:fs";
5390
- import { isAbsolute as isAbsolute2, join as join4, posix } from "node:path";
5715
+ import { readFileSync as readFileSync3, existsSync as existsSync3 } from "node:fs";
5716
+ import { isAbsolute as isAbsolute4, join as join4, posix as posix2 } from "node:path";
5391
5717
  import { parse as parse2 } from "yaml";
5392
5718
  var PolicyError = class extends Error {
5393
5719
  };
@@ -5404,8 +5730,8 @@ var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "strin
5404
5730
  var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
5405
5731
  var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "protected", "rules", "ignore", "signoff", "verify"]);
5406
5732
  function ledgerInsideRepo(ledger) {
5407
- if (!ledger.trim() || isAbsolute2(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
5408
- const norm2 = posix.normalize(ledger.replace(/\\/g, "/"));
5733
+ if (!ledger.trim() || isAbsolute4(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
5734
+ const norm2 = posix2.normalize(ledger.replace(/\\/g, "/"));
5409
5735
  return norm2 !== ".." && !norm2.startsWith("../") && !norm2.startsWith("/");
5410
5736
  }
5411
5737
  function validate(r, where) {
@@ -5512,8 +5838,8 @@ function parseOrThrow(src, where) {
5512
5838
  }
5513
5839
  function loadPolicy(cwd = process.cwd()) {
5514
5840
  const path = join4(cwd, POLICY_FILE);
5515
- if (!existsSync2(path)) return defaultPolicy();
5516
- return parseOrThrow(readFileSync4(path, "utf8"), POLICY_FILE);
5841
+ if (!existsSync3(path)) return defaultPolicy();
5842
+ return parseOrThrow(readFileSync3(path, "utf8"), POLICY_FILE);
5517
5843
  }
5518
5844
  function loadPolicyAt(rev, cwd) {
5519
5845
  const src = fileAt(rev, POLICY_FILE, { cwd });
@@ -5523,7 +5849,7 @@ function loadPolicyAt(rev, cwd) {
5523
5849
 
5524
5850
  // src/signoff.ts
5525
5851
  import { createHash } from "node:crypto";
5526
- import { appendFileSync, mkdirSync, readFileSync as readFileSync5, existsSync as existsSync3 } from "node:fs";
5852
+ import { appendFileSync, mkdirSync, readFileSync as readFileSync4, existsSync as existsSync4 } from "node:fs";
5527
5853
  import { dirname as dirname2, join as join5 } from "node:path";
5528
5854
  var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
5529
5855
  function fingerprint(rule, file, evidence) {
@@ -5539,9 +5865,9 @@ function ledgerPath(cwd, policy) {
5539
5865
  }
5540
5866
  function readLedger(cwd, policy) {
5541
5867
  const p = ledgerPath(cwd, policy);
5542
- if (!existsSync3(p)) return [];
5868
+ if (!existsSync4(p)) return [];
5543
5869
  const out2 = [];
5544
- for (const line of readFileSync5(p, "utf8").split("\n")) {
5870
+ for (const line of readFileSync4(p, "utf8").split("\n")) {
5545
5871
  if (!line.trim()) continue;
5546
5872
  try {
5547
5873
  const e = JSON.parse(line);
@@ -5694,7 +6020,7 @@ function renderText(input, opts) {
5694
6020
  for (const f of findings) {
5695
6021
  const isBlock = f.severity === "block";
5696
6022
  const mark = paint(isBlock ? "BLOCK" : "warn ", isBlock ? BOLD + RED : YELLOW, c);
5697
- const loc = f.file ? `${stripControl(f.file)}${f.line ? `:${f.line}` : ""}` : "(command)";
6023
+ const loc = f.file ? `${stripControl(escapeControl(f.file))}${f.line ? `:${f.line}` : ""}` : "(command)";
5698
6024
  out2.push(` ${mark} ${paint(stripControl(f.rule), BOLD, c)} ${paint(loc, DIM, c)}`);
5699
6025
  for (const l of wrap(f.message, opts.width - 4)) out2.push(` ${l}`);
5700
6026
  const row = (key2, lines) => {
@@ -5886,6 +6212,7 @@ function check(opts) {
5886
6212
  }
5887
6213
  const ignoredFiles = changes.filter((c) => isSuppressed(c, policy)).length;
5888
6214
  let findings = evaluate(changes, policy, void 0, view, { cwd });
6215
+ if (view === "worktree") findings = findings.concat(unjudgeableProtected(cwd, policy, changes));
5889
6216
  const { findings: remaining, cleared } = layer === "local" ? applyLocalSignoffs(findings, cwd, policy) : applyOobSignoffs(findings, oobFromEnv(), oobHeadFromEnv());
5890
6217
  findings = remaining;
5891
6218
  if (cleared.length) {
@@ -5910,31 +6237,26 @@ function runCheck(opts) {
5910
6237
  }
5911
6238
 
5912
6239
  // src/cli/hook.ts
5913
- import { readFileSync as readFileSync11, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7, statSync as statSync4 } from "node:fs";
6240
+ import { readFileSync as readFileSync9, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7 } from "node:fs";
5914
6241
  import { createHash as createHash4 } from "node:crypto";
5915
- import { join as join10 } from "node:path";
5916
6242
 
5917
6243
  // src/adapters/claude/changes.ts
5918
6244
  import { execFileSync as execFileSync3 } from "node:child_process";
5919
- import { readFileSync as readFileSync6, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
6245
+ import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
5920
6246
  import { tmpdir } from "node:os";
5921
- import { isAbsolute as isAbsolute3, join as join6, relative as relative2, resolve } from "node:path";
6247
+ import { isAbsolute as isAbsolute5, join as join6, relative as relative2, resolve as resolve2 } from "node:path";
5922
6248
  function asStr(v) {
5923
6249
  return typeof v === "string" ? v : "";
5924
6250
  }
5925
6251
  function readDisk(path) {
5926
- try {
5927
- return existsSync4(path) ? readFileSync6(path, "utf8") : null;
5928
- } catch {
5929
- return null;
5930
- }
6252
+ return textOf(inspectResolved(path));
5931
6253
  }
5932
6254
  function abs(path, cwd) {
5933
- return resolve(cwd, path);
6255
+ return resolve2(cwd, path);
5934
6256
  }
5935
6257
  function relForDisplay(path, cwd) {
5936
6258
  const rel = relative2(cwd, path);
5937
- return rel && !rel.startsWith("..") && !isAbsolute3(rel) ? rel : path;
6259
+ return rel && !rel.startsWith("..") && !isAbsolute5(rel) ? rel : path;
5938
6260
  }
5939
6261
  function applyEdit(content, oldStr, newStr) {
5940
6262
  if (content === null) return newStr;
@@ -6021,7 +6343,7 @@ function formatDenial(blocks) {
6021
6343
  const lead = blocks[0];
6022
6344
  const lines = ["Tamperward blocked this change \u2014 it weakens a protected safety net to pass checks.", ""];
6023
6345
  for (const f of blocks) {
6024
- const loc = f.file ? ` (${f.file}${f.line ? `:${f.line}` : ""})` : "";
6346
+ const loc = f.file ? ` (${escapeControl(f.file)}${f.line ? `:${f.line}` : ""})` : "";
6025
6347
  lines.push(` \u2022 ${f.rule}${loc}: ${f.message}`);
6026
6348
  }
6027
6349
  lines.push("");
@@ -6036,7 +6358,7 @@ function formatDenial(blocks) {
6036
6358
  }
6037
6359
 
6038
6360
  // src/session.ts
6039
- import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "node:fs";
6361
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "node:fs";
6040
6362
  import { dirname as dirname3, join as join7 } from "node:path";
6041
6363
  var UNSAFE = /[^A-Za-z0-9_-]/g;
6042
6364
  var SHA = /^[0-9a-f]{7,40}$/;
@@ -6051,7 +6373,7 @@ function turnBaseline(cwd, sessionId) {
6051
6373
  const p = baselinePath(cwd, sessionId);
6052
6374
  if (!p) return null;
6053
6375
  if (existsSync5(p)) {
6054
- const v = readFileSync7(p, "utf8").trim();
6376
+ const v = readFileSync5(p, "utf8").trim();
6055
6377
  if (SHA.test(v)) return v;
6056
6378
  }
6057
6379
  const head = headSha(cwd);
@@ -6077,8 +6399,8 @@ function advanceTurnBaseline(cwd, sessionId) {
6077
6399
 
6078
6400
  // src/effect.ts
6079
6401
  import { createHash as createHash2 } from "node:crypto";
6080
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync8, renameSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync3 } from "node:fs";
6081
- import { isAbsolute as isAbsolute4, join as join8 } from "node:path";
6402
+ import { existsSync as existsSync6, lstatSync as lstatSync2, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
6403
+ import { isAbsolute as isAbsolute6, join as join8 } from "node:path";
6082
6404
  import { execFileSync as execFileSync4 } from "node:child_process";
6083
6405
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
6084
6406
  var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
@@ -6090,7 +6412,7 @@ function gitDir2(cwd) {
6090
6412
  stdio: ["ignore", "pipe", "pipe"]
6091
6413
  }).trim();
6092
6414
  if (!d) return null;
6093
- return isAbsolute4(d) ? d : join8(cwd, d);
6415
+ return isAbsolute6(d) ? d : join8(cwd, d);
6094
6416
  } catch {
6095
6417
  return null;
6096
6418
  }
@@ -6112,8 +6434,22 @@ var sha = (buf) => createHash2("sha256").update(buf).digest("hex").slice(0, 16);
6112
6434
  function contentHash(content) {
6113
6435
  return sha(Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"));
6114
6436
  }
6437
+ function entryOf(e) {
6438
+ const hash = e.content != null ? sha(e.content) : e.kind === "unreadable" ? "unreadable" : `${e.kind}:${e.detail}`;
6439
+ return { hash, mode: e.mode, size: e.size, mtimeMs: e.mtimeMs };
6440
+ }
6115
6441
  function snapshotProtected(cwd, policy, prev) {
6442
+ void prev;
6116
6443
  const out2 = {};
6444
+ const keep = (rel) => isProtected(rel, policy);
6445
+ let pruned = /* @__PURE__ */ new Set();
6446
+ let listed = [];
6447
+ try {
6448
+ const t = ignoredTree({ cwd }, keep);
6449
+ pruned = new Set(t.dirs.map((d) => d.replace(/\/$/, "")));
6450
+ listed = t.files;
6451
+ } catch {
6452
+ }
6117
6453
  const walk2 = (rel) => {
6118
6454
  const abs2 = rel ? join8(cwd, rel) : cwd;
6119
6455
  let names;
@@ -6125,25 +6461,19 @@ function snapshotProtected(cwd, policy, prev) {
6125
6461
  for (const name of names) {
6126
6462
  if (SKIP_DIRS2.has(name)) continue;
6127
6463
  const r = rel ? `${rel}/${name}` : name;
6464
+ if (pruned.has(r)) continue;
6128
6465
  let st;
6129
6466
  try {
6130
- st = statSync(join8(cwd, r));
6467
+ st = lstatSync2(join8(cwd, r));
6131
6468
  } catch {
6132
6469
  continue;
6133
6470
  }
6134
6471
  if (st.isDirectory()) walk2(r);
6135
- else if (st.isFile() && isProtected(r, policy)) {
6136
- let hash = "";
6137
- try {
6138
- hash = sha(readFileSync8(join8(cwd, r)));
6139
- } catch {
6140
- hash = "unreadable";
6141
- }
6142
- out2[r] = { hash, mode: st.mode, size: st.size, mtimeMs: st.mtimeMs };
6143
- }
6472
+ else if (keep(r)) out2[r] = entryOf(inspectRel(cwd, r));
6144
6473
  }
6145
6474
  };
6146
6475
  walk2("");
6476
+ for (const rel of listed) if (out2[rel] === void 0) out2[rel] = entryOf(inspectRel(cwd, rel));
6147
6477
  return out2;
6148
6478
  }
6149
6479
  function driftBetween(expected, current) {
@@ -6163,7 +6493,7 @@ function driftBetween(expected, current) {
6163
6493
  function loadTree(p) {
6164
6494
  if (!p || !existsSync6(p)) return null;
6165
6495
  try {
6166
- return JSON.parse(readFileSync8(p, "utf8"));
6496
+ return JSON.parse(readFileSync6(p, "utf8"));
6167
6497
  } catch {
6168
6498
  return null;
6169
6499
  }
@@ -6202,19 +6532,16 @@ function dropTurnTree(cwd, sessionId) {
6202
6532
  }
6203
6533
 
6204
6534
  // src/cli/watch.ts
6205
- import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync9, statSync as statSync2, watch } from "node:fs";
6535
+ import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, statSync, watch } from "node:fs";
6206
6536
  import { join as join9 } from "node:path";
6207
6537
  import { createHash as createHash3 } from "node:crypto";
6208
6538
  var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
6209
6539
  function snap(abs2) {
6210
- try {
6211
- const st = statSync2(abs2);
6212
- if (!st.isFile()) return { mode: st.mode, size: null, hash: null };
6213
- const hash = createHash3("sha256").update(readFileSync9(abs2)).digest("hex").slice(0, 16);
6214
- return { mode: st.mode, size: st.size, hash };
6215
- } catch {
6216
- return { mode: null, size: null, hash: null };
6217
- }
6540
+ const e = inspectPath(abs2);
6541
+ if (e.kind === "absent" || e.kind === "unreadable") return { mode: null, size: null, hash: null };
6542
+ if (e.kind !== "file" || e.content == null) return { mode: e.mode, size: null, hash: null };
6543
+ const hash = createHash3("sha256").update(e.content).digest("hex").slice(0, 16);
6544
+ return { mode: e.mode, size: e.size, hash };
6218
6545
  }
6219
6546
  function defaultEventLog(cwd) {
6220
6547
  if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
@@ -6240,7 +6567,7 @@ function watchTree(dir, cb) {
6240
6567
  const child = rel ? `${rel}/${String(fname)}` : String(fname);
6241
6568
  cb(kind, child);
6242
6569
  try {
6243
- if (statSync2(join9(dir, child)).isDirectory()) addDir(child);
6570
+ if (statSync(join9(dir, child)).isDirectory()) addDir(child);
6244
6571
  } catch {
6245
6572
  }
6246
6573
  });
@@ -6261,7 +6588,7 @@ function watchTree(dir, cb) {
6261
6588
  const child = rel ? `${rel}/${name}` : name;
6262
6589
  if (SKIP2.test(child + "/")) continue;
6263
6590
  try {
6264
- if (statSync2(join9(dir, child)).isDirectory()) walk2(child);
6591
+ if (statSync(join9(dir, child)).isDirectory()) walk2(child);
6265
6592
  } catch {
6266
6593
  }
6267
6594
  }
@@ -6306,17 +6633,17 @@ function runWatch(args) {
6306
6633
  }
6307
6634
 
6308
6635
  // src/detectors/fs-events.ts
6309
- import { readFileSync as readFileSync10, statSync as statSync3 } from "node:fs";
6636
+ import { readFileSync as readFileSync8, statSync as statSync2 } from "node:fs";
6310
6637
  var RULE11 = "transient-protected-mutation";
6311
6638
  function readEvents(log, offset) {
6312
6639
  let size = 0;
6313
6640
  try {
6314
- size = statSync3(log).size;
6641
+ size = statSync2(log).size;
6315
6642
  } catch {
6316
6643
  return { events: [], newOffset: offset };
6317
6644
  }
6318
6645
  if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
6319
- const raw = readFileSync10(log, "utf8").slice(offset);
6646
+ const raw = readFileSync8(log, "utf8").slice(offset);
6320
6647
  const events = [];
6321
6648
  for (const line of raw.split("\n")) {
6322
6649
  if (!line.trim()) continue;
@@ -6369,7 +6696,7 @@ var HookInputError = class extends Error {
6369
6696
  };
6370
6697
  function readStdin() {
6371
6698
  try {
6372
- return readFileSync11(0, "utf8");
6699
+ return readFileSync9(0, "utf8");
6373
6700
  } catch (e) {
6374
6701
  throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
6375
6702
  }
@@ -6395,7 +6722,7 @@ function recordDenylog(blocks) {
6395
6722
  } catch {
6396
6723
  }
6397
6724
  }
6398
- function errText(e) {
6725
+ function errText2(e) {
6399
6726
  const m = e instanceof Error ? e.message : String(e);
6400
6727
  return m.length > 200 ? m.slice(0, 200) + "\u2026" : m;
6401
6728
  }
@@ -6427,11 +6754,8 @@ function fileChanges(changes) {
6427
6754
  return changes.filter((c) => c.kind === "file");
6428
6755
  }
6429
6756
  function hashOnDisk(cwd, rel) {
6430
- try {
6431
- return contentHash(readFileSync11(join10(cwd, rel)));
6432
- } catch {
6433
- return null;
6434
- }
6757
+ const e = inspectRel(cwd, rel);
6758
+ return e.content == null ? null : contentHash(e.content);
6435
6759
  }
6436
6760
  function gitMode(mode) {
6437
6761
  return (mode & 64) !== 0 ? "100755" : "100644";
@@ -6478,6 +6802,11 @@ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree)
6478
6802
  const candidates = base ? [base, "HEAD", ""] : ["HEAD", ""];
6479
6803
  for (const rel of [...d.changed, ...d.deleted]) {
6480
6804
  if (seen.has(rel)) continue;
6805
+ const unjudgeable = unjudgeableFinding(cwd, rel);
6806
+ if (unjudgeable) {
6807
+ blocks.push(unjudgeable);
6808
+ continue;
6809
+ }
6481
6810
  const after = fileOnDisk(rel, { cwd });
6482
6811
  const trustedBlob = fileAt(trusted, rel, { cwd });
6483
6812
  const atGitState = after != null && trustedBlob != null && contentHash(after) === contentHash(trustedBlob);
@@ -6526,6 +6855,11 @@ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree)
6526
6855
  }
6527
6856
  for (const rel of d.added) {
6528
6857
  if (seen.has(rel)) continue;
6858
+ const unjudgeable = unjudgeableFinding(cwd, rel);
6859
+ if (unjudgeable) {
6860
+ blocks.push(unjudgeable);
6861
+ continue;
6862
+ }
6529
6863
  const after = fileOnDisk(rel, { cwd });
6530
6864
  if (after == null) {
6531
6865
  blocks.push({
@@ -6565,7 +6899,8 @@ function effectDriftBlocks(cwd, sessionId, policy) {
6565
6899
  (c) => c.kind === "file" && (drifted.has(c.path) || c.oldPath != null && drifted.has(c.oldPath))
6566
6900
  );
6567
6901
  const gap = uncoveredDrift(cwd, policy, expected, current, scoped, base, loadTurnTree(cwd, sessionId));
6568
- const blocks = evaluate([...scoped, ...gap.changes], policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(gap.blocks);
6902
+ const judged = [...scoped, ...gap.changes];
6903
+ const blocks = evaluate(judged, policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(gap.blocks, unjudgeableProtected(cwd, policy, judged));
6569
6904
  if (blocks.length > 0) return blocks;
6570
6905
  savePtree(cwd, sessionId, current);
6571
6906
  return null;
@@ -6602,7 +6937,7 @@ function preToolUseVerdict(input) {
6602
6937
  if (blocks.length === 0) sanctionPredictedWrites(cwd, input.session_id, policy, changes);
6603
6938
  return verdict(blocks, "PreToolUse");
6604
6939
  } catch (e) {
6605
- return failClosed("PreToolUse", errText(e));
6940
+ return failClosed("PreToolUse", errText2(e));
6606
6941
  }
6607
6942
  }
6608
6943
  function cursorPath(cwd, sessionId) {
@@ -6617,7 +6952,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
6617
6952
  if (!existsSync7(log)) return none;
6618
6953
  let offset = 0;
6619
6954
  if (cp && existsSync7(cp)) {
6620
- const n = Number(readFileSync11(cp, "utf8"));
6955
+ const n = Number(readFileSync9(cp, "utf8"));
6621
6956
  offset = Number.isFinite(n) ? n : 0;
6622
6957
  }
6623
6958
  const { events, newOffset } = readEvents(log, offset);
@@ -6629,12 +6964,8 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
6629
6964
  } };
6630
6965
  const persistent = new Set(changes.filter((c) => c.kind === "file").map((c) => c.path));
6631
6966
  const finalHash = (path) => {
6632
- try {
6633
- if (!statSync4(`${cwd}/${path}`).isFile()) return null;
6634
- return createHash4("sha256").update(readFileSync11(`${cwd}/${path}`)).digest("hex").slice(0, 16);
6635
- } catch {
6636
- return null;
6637
- }
6967
+ const e = inspectRel(cwd, path);
6968
+ return e.kind === "file" && e.content != null ? contentHash(e.content) : null;
6638
6969
  };
6639
6970
  const findings = transientFindings(events, persistent, policy, finalHash);
6640
6971
  recordWarns(findings.filter((f) => f.severity !== "block"));
@@ -6675,7 +7006,7 @@ function stopVerdict(input) {
6675
7006
  changes = changes.concat(gap.changes);
6676
7007
  hiddenBlocks = gap.blocks;
6677
7008
  }
6678
- blocks = evaluate(changes, policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(hiddenBlocks);
7009
+ blocks = evaluate(changes, policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(hiddenBlocks, unjudgeableProtected(cwd, policy, changes));
6679
7010
  const transient = turnTransientBlocks(cwd, input.session_id, policy, changes);
6680
7011
  blocks = blocks.concat(transient.blocks);
6681
7012
  commitCursor = transient.commit;
@@ -6684,7 +7015,7 @@ function stopVerdict(input) {
6684
7015
  saveTurnTree(cwd, input.session_id, current);
6685
7016
  }
6686
7017
  } catch (e) {
6687
- return failClosed("Stop", errText(e));
7018
+ return failClosed("Stop", errText2(e));
6688
7019
  }
6689
7020
  if (blocks.length === 0) {
6690
7021
  advanceTurnBaseline(cwd, input.session_id);
@@ -6700,28 +7031,28 @@ function preToolUseFromRaw(raw) {
6700
7031
  try {
6701
7032
  return preToolUseVerdict(parseInput(raw));
6702
7033
  } catch (e) {
6703
- return failClosed("PreToolUse", errText(e));
7034
+ return failClosed("PreToolUse", errText2(e));
6704
7035
  }
6705
7036
  }
6706
7037
  function stopFromRaw(raw) {
6707
7038
  try {
6708
7039
  return stopVerdict(parseInput(raw));
6709
7040
  } catch (e) {
6710
- return failClosed("Stop", errText(e));
7041
+ return failClosed("Stop", errText2(e));
6711
7042
  }
6712
7043
  }
6713
7044
  function runHookClaude() {
6714
7045
  try {
6715
7046
  return emit(preToolUseFromRaw(readStdin()));
6716
7047
  } catch (e) {
6717
- return emit(failClosed("PreToolUse", errText(e)));
7048
+ return emit(failClosed("PreToolUse", errText2(e)));
6718
7049
  }
6719
7050
  }
6720
7051
  function runSweepClaude() {
6721
7052
  try {
6722
7053
  return emit(stopFromRaw(readStdin()));
6723
7054
  } catch (e) {
6724
- return emit(failClosed("Stop", errText(e)));
7055
+ return emit(failClosed("Stop", errText2(e)));
6725
7056
  }
6726
7057
  }
6727
7058
 
@@ -6774,9 +7105,9 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
6774
7105
  // src/cli/init.ts
6775
7106
  import { execFileSync as execFileSync5 } from "node:child_process";
6776
7107
  import { createHash as createHash5 } from "node:crypto";
6777
- import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync12, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
6778
- import { basename, dirname as dirname4, isAbsolute as isAbsolute5, join as join11, relative as relative3, resolve as resolve2 } from "node:path";
6779
- var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
7108
+ import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync, statSync as statSync3 } from "node:fs";
7109
+ import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute7, join as join10, relative as relative3, resolve as resolve3 } from "node:path";
7110
+ var errText3 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
6780
7111
  var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6781
7112
  function gitConfig(cwd, key2) {
6782
7113
  try {
@@ -6925,13 +7256,13 @@ function readWorkflowMark(src) {
6925
7256
  if (!m) return null;
6926
7257
  return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
6927
7258
  }
6928
- function toolSet(matcher2) {
6929
- const m = String(matcher2 ?? "").trim();
7259
+ function toolSet(matcher) {
7260
+ const m = String(matcher ?? "").trim();
6930
7261
  if (m === "" || m === "*") return null;
6931
- return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
7262
+ return new Set(m.split(/[|,]/).map((t) => t.trim()).filter(Boolean));
6932
7263
  }
6933
- function missingTools(matcher2) {
6934
- const have = toolSet(matcher2);
7264
+ function missingTools(matcher) {
7265
+ const have = toolSet(matcher);
6935
7266
  if (have === null) return [];
6936
7267
  return PRE_MATCHER.split("|").filter((t) => !have.has(t));
6937
7268
  }
@@ -6951,12 +7282,12 @@ function hooksShapeError(hooks) {
6951
7282
  return null;
6952
7283
  }
6953
7284
  function planPolicy(cwd) {
6954
- const path = join11(cwd, POLICY_FILE);
7285
+ const path = join10(cwd, POLICY_FILE);
6955
7286
  if (existsSync8(path)) {
6956
7287
  try {
6957
7288
  loadPolicy(cwd);
6958
7289
  } catch (e) {
6959
- 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)` };
7290
+ return { item: "policy", path: POLICY_FILE, status: "error", detail: `present but does not load \u2014 ${errText3(e)}; fix it, then re-run init (left untouched)` };
6960
7291
  }
6961
7292
  return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present and loads \u2014 left untouched" };
6962
7293
  }
@@ -6970,12 +7301,12 @@ function planPolicy(cwd) {
6970
7301
  }
6971
7302
  function planClaudeHooks(cwd) {
6972
7303
  const rel = ".claude/settings.json";
6973
- const path = join11(cwd, rel);
7304
+ const path = join10(cwd, rel);
6974
7305
  let settings = {};
6975
7306
  if (existsSync8(path)) {
6976
7307
  let parsed;
6977
7308
  try {
6978
- parsed = JSON.parse(readFileSync12(path, "utf8"));
7309
+ parsed = JSON.parse(readFileSync10(path, "utf8"));
6979
7310
  } catch {
6980
7311
  return {
6981
7312
  item: "agent",
@@ -7001,7 +7332,8 @@ function planClaudeHooks(cwd) {
7001
7332
  const needStop = stopEntries.length === 0;
7002
7333
  const stale = preEntries.filter((m) => missingTools(m.matcher).length > 0);
7003
7334
  const repin = [...preEntries, ...stopEntries].flatMap((m) => m.hooks ?? []).filter((h) => stalePin(String(h.command ?? "")));
7004
- if (!needPre && !needStop && stale.length === 0 && repin.length === 0) {
7335
+ const needDisableFalse = settings.disableAllHooks !== false;
7336
+ if (!needPre && !needStop && stale.length === 0 && repin.length === 0 && !needDisableFalse) {
7005
7337
  return { item: "agent", path: rel, status: "ok", detail: "PreToolUse + Stop hooks already wired" };
7006
7338
  }
7007
7339
  const repairing = stale.length > 0 ? [...new Set(stale.flatMap((m) => missingTools(m.matcher)))] : [];
@@ -7014,7 +7346,8 @@ function planClaudeHooks(cwd) {
7014
7346
  needPre && "wire PreToolUse deny",
7015
7347
  needStop && "wire Stop sweep",
7016
7348
  repairing.length > 0 && `widen the PreToolUse matcher to cover ${repairing.join(", ")}`,
7017
- repin.length > 0 && `re-pin the hook commands to tamperward@${TW_VERSION} (was ${pins.join(", ")})`
7349
+ repin.length > 0 && `re-pin the hook commands to tamperward@${TW_VERSION} (was ${pins.join(", ")})`,
7350
+ needDisableFalse && (settings.disableAllHooks === void 0 ? "declare disableAllHooks: false so the user settings file cannot switch the hooks off" : `set disableAllHooks: false (was ${JSON.stringify(settings.disableAllHooks)})`)
7018
7351
  ].filter(Boolean).join(" + "),
7019
7352
  apply: () => {
7020
7353
  for (const h of repin) {
@@ -7033,6 +7366,7 @@ function planClaudeHooks(cwd) {
7033
7366
  if (needStop) {
7034
7367
  (hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
7035
7368
  }
7369
+ if (needDisableFalse) settings.disableAllHooks = false;
7036
7370
  mkdirSync5(dirname4(path), { recursive: true });
7037
7371
  writeFileSync5(path, JSON.stringify(settings, null, 2) + "\n");
7038
7372
  }
@@ -7065,14 +7399,14 @@ var MANAGED_HOOKS = [
7065
7399
  function hooksDir(cwd) {
7066
7400
  try {
7067
7401
  const out2 = execFileSync5("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
7068
- if (out2) return resolve2(cwd, out2);
7402
+ if (out2) return resolve3(cwd, out2);
7069
7403
  } catch {
7070
7404
  }
7071
- return existsSync8(join11(cwd, ".git")) ? join11(cwd, ".git", "hooks") : null;
7405
+ return existsSync8(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
7072
7406
  }
7073
7407
  function display(cwd, path) {
7074
7408
  const rel = relative3(cwd, path);
7075
- return rel && !rel.startsWith("..") && !isAbsolute5(rel) ? rel : path;
7409
+ return rel && !rel.startsWith("..") && !isAbsolute7(rel) ? rel : path;
7076
7410
  }
7077
7411
  function unconditionalExitAt(lines) {
7078
7412
  let depth = 0;
@@ -7095,30 +7429,30 @@ function planPreCommit(cwd) {
7095
7429
  const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
7096
7430
  let path;
7097
7431
  let note;
7098
- if (existsSync8(join11(cwd, ".husky"))) {
7099
- path = join11(cwd, ".husky", "pre-commit");
7432
+ if (existsSync8(join10(cwd, ".husky"))) {
7433
+ path = join10(cwd, ".husky", "pre-commit");
7100
7434
  note = "husky";
7101
7435
  } else {
7102
- const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join11(cwd, c))));
7436
+ const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join10(cwd, c))));
7103
7437
  if (managed) {
7104
7438
  return skip(`${managed.name} manages this repository's hooks and regenerates them on install, so a line in the hooks directory would not last \u2014 ${managed.hint}`);
7105
7439
  }
7106
7440
  const dir = hooksDir(cwd);
7107
7441
  if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
7108
- if (basename(dir) === "_" && basename(dirname4(dir)) === ".husky") {
7109
- path = join11(dirname4(dir), "pre-commit");
7442
+ if (basename2(dir) === "_" && basename2(dirname4(dir)) === ".husky") {
7443
+ path = join10(dirname4(dir), "pre-commit");
7110
7444
  note = "husky";
7111
7445
  } else {
7112
- path = join11(dir, "pre-commit");
7446
+ path = join10(dir, "pre-commit");
7113
7447
  const rel2 = display(cwd, dir);
7114
- note = rel2 === join11(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
7448
+ note = rel2 === join10(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
7115
7449
  }
7116
7450
  }
7117
7451
  const rel = display(cwd, path);
7118
- if (existsSync8(path) && !statSync5(path).isFile()) {
7452
+ if (existsSync8(path) && !statSync3(path).isFile()) {
7119
7453
  return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
7120
7454
  }
7121
- const existing = existsSync8(path) ? readFileSync12(path, "utf8") : null;
7455
+ const existing = existsSync8(path) ? readFileSync10(path, "utf8") : null;
7122
7456
  if (existing === null) {
7123
7457
  return {
7124
7458
  item: "pre-commit",
@@ -7207,10 +7541,10 @@ ${block.join("\n")}
7207
7541
  function planCodeowners(cwd) {
7208
7542
  const rel = ".github/CODEOWNERS";
7209
7543
  const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
7210
- (r) => existsSync8(join11(cwd, r))
7544
+ (r) => existsSync8(join10(cwd, r))
7211
7545
  );
7212
- const path = join11(cwd, existingRel ?? rel);
7213
- const existing = existingRel ? readFileSync12(path, "utf8") : null;
7546
+ const path = join10(cwd, existingRel ?? rel);
7547
+ const existing = existingRel ? readFileSync10(path, "utf8") : null;
7214
7548
  const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
7215
7549
  if (missing.length === 0) {
7216
7550
  return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
@@ -7243,7 +7577,7 @@ ${CODEOWNERS_MARK}
7243
7577
  }
7244
7578
  function planWorkflow(cwd, force) {
7245
7579
  const rel = ".github/workflows/tamperward.yml";
7246
- const path = join11(cwd, rel);
7580
+ const path = join10(cwd, rel);
7247
7581
  const write = () => {
7248
7582
  mkdirSync5(dirname4(path), { recursive: true });
7249
7583
  writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
@@ -7259,7 +7593,7 @@ function planWorkflow(cwd, force) {
7259
7593
  }
7260
7594
  let src;
7261
7595
  try {
7262
- src = readFileSync12(path, "utf8");
7596
+ src = readFileSync10(path, "utf8");
7263
7597
  } catch {
7264
7598
  return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
7265
7599
  }
@@ -7297,7 +7631,7 @@ function planned(item, path, plan) {
7297
7631
  try {
7298
7632
  return plan();
7299
7633
  } catch (e) {
7300
- return { item, path, status: "error", detail: `cannot plan this item \u2014 ${errText2(e)}` };
7634
+ return { item, path, status: "error", detail: `cannot plan this item \u2014 ${errText3(e)}` };
7301
7635
  }
7302
7636
  }
7303
7637
  function planInit(cwd, opts = {}) {
@@ -7321,7 +7655,7 @@ function runInit(opts) {
7321
7655
  applied++;
7322
7656
  } catch (e) {
7323
7657
  a.status = "error";
7324
- a.detail = `could not be written \u2014 ${errText2(e)}`;
7658
+ a.detail = `could not be written \u2014 ${errText3(e)}`;
7325
7659
  a.apply = void 0;
7326
7660
  }
7327
7661
  }
@@ -7358,15 +7692,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
7358
7692
  // src/cli/verify.ts
7359
7693
  import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
7360
7694
  import { createHash as createHash8 } from "node:crypto";
7361
- import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync15, readlinkSync as readlinkSync2, rmSync as rmSync3, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
7695
+ import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync3, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync3, rmSync as rmSync3, statSync as statSync5, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
7362
7696
  import { tmpdir as tmpdir2 } from "node:os";
7363
- import { dirname as dirname5, join as join14, resolve as resolve3 } from "node:path";
7697
+ import { dirname as dirname5, join as join13, resolve as resolve4 } from "node:path";
7364
7698
 
7365
7699
  // src/git/trusted.ts
7366
7700
  import { execFileSync as execFileSync6 } from "node:child_process";
7367
7701
  import { createHash as createHash6 } from "node:crypto";
7368
- import { existsSync as existsSync9, readFileSync as readFileSync13 } from "node:fs";
7369
- import { isAbsolute as isAbsolute6, join as join12 } from "node:path";
7702
+ import { existsSync as existsSync9, readFileSync as readFileSync11 } from "node:fs";
7703
+ import { isAbsolute as isAbsolute8, join as join11 } from "node:path";
7370
7704
  function trustedGitEnv() {
7371
7705
  return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
7372
7706
  }
@@ -7379,7 +7713,7 @@ function gitDirOf(cwd) {
7379
7713
  env: trustedGitEnv()
7380
7714
  }).trim();
7381
7715
  if (!d) return null;
7382
- return isAbsolute6(d) ? d : join12(cwd, d);
7716
+ return isAbsolute8(d) ? d : join11(cwd, d);
7383
7717
  } catch {
7384
7718
  return null;
7385
7719
  }
@@ -7404,9 +7738,9 @@ function objectRewriteState(cwd) {
7404
7738
  parts.push(`${rel}:<no-git-dir>`);
7405
7739
  continue;
7406
7740
  }
7407
- const p = join12(dir, rel);
7741
+ const p = join11(dir, rel);
7408
7742
  try {
7409
- parts.push(`${rel}:` + (existsSync9(p) ? readFileSync13(p, "utf8").trim() : ""));
7743
+ parts.push(`${rel}:` + (existsSync9(p) ? readFileSync11(p, "utf8").trim() : ""));
7410
7744
  } catch {
7411
7745
  parts.push(`${rel}:<unreadable>`);
7412
7746
  }
@@ -7417,8 +7751,8 @@ function objectRewriteState(cwd) {
7417
7751
  // src/fingerprint.ts
7418
7752
  import { execFileSync as execFileSync7 } from "node:child_process";
7419
7753
  import { createHash as createHash7 } from "node:crypto";
7420
- import { readdirSync as readdirSync3, readFileSync as readFileSync14, readlinkSync, statSync as statSync6 } from "node:fs";
7421
- import { join as join13 } from "node:path";
7754
+ import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync as readlinkSync2, statSync as statSync4 } from "node:fs";
7755
+ import { join as join12 } from "node:path";
7422
7756
  function treeFingerprint(cwd) {
7423
7757
  const files = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
7424
7758
  cwd,
@@ -7430,17 +7764,13 @@ function treeFingerprint(cwd) {
7430
7764
  for (const rel of files) {
7431
7765
  h.update(rel);
7432
7766
  h.update("\0");
7433
- try {
7434
- h.update(readFileSync14(join13(cwd, rel)));
7435
- } catch {
7436
- h.update("<unreadable>");
7437
- }
7767
+ h.update(inspectRel(cwd, rel).content ?? "<unreadable>");
7438
7768
  h.update("\0");
7439
7769
  }
7440
7770
  return h.digest("hex");
7441
7771
  }
7442
7772
  function depsFingerprint(cwd) {
7443
- const root = join13(cwd, "node_modules");
7773
+ const root = join12(cwd, "node_modules");
7444
7774
  const h = createHash7("sha256");
7445
7775
  const walk2 = (dir) => {
7446
7776
  let entries;
@@ -7450,20 +7780,20 @@ function depsFingerprint(cwd) {
7450
7780
  return;
7451
7781
  }
7452
7782
  for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
7453
- const p = join13(dir, e.name);
7783
+ const p = join12(dir, e.name);
7454
7784
  if (e.isDirectory()) walk2(p);
7455
7785
  else if (e.isFile()) {
7456
7786
  h.update(p);
7457
7787
  try {
7458
- h.update(readFileSync14(p));
7459
- h.update(String(statSync6(p).mode));
7788
+ h.update(readFileSync12(p));
7789
+ h.update(String(statSync4(p).mode));
7460
7790
  } catch {
7461
7791
  h.update("<unreadable>");
7462
7792
  }
7463
7793
  } else if (e.isSymbolicLink()) {
7464
7794
  h.update(p);
7465
7795
  try {
7466
- h.update(readlinkSync(p));
7796
+ h.update(readlinkSync2(p));
7467
7797
  } catch {
7468
7798
  h.update("<unreadable-link>");
7469
7799
  }
@@ -7471,7 +7801,7 @@ function depsFingerprint(cwd) {
7471
7801
  }
7472
7802
  };
7473
7803
  try {
7474
- if (!statSync6(root).isDirectory()) return "none";
7804
+ if (!statSync4(root).isDirectory()) return "none";
7475
7805
  } catch {
7476
7806
  return "none";
7477
7807
  }
@@ -7570,30 +7900,30 @@ function baseIsAncestorOfHead(base, cwd) {
7570
7900
  function materialize(cwd, dest) {
7571
7901
  const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
7572
7902
  for (const rel of listed) {
7573
- const src = join14(cwd, rel);
7903
+ const src = join13(cwd, rel);
7574
7904
  let st;
7575
7905
  try {
7576
- st = statSync7(src);
7906
+ st = statSync5(src);
7577
7907
  } catch {
7578
7908
  continue;
7579
7909
  }
7580
7910
  if (!st.isFile()) continue;
7581
- const out2 = join14(dest, rel);
7911
+ const out2 = join13(dest, rel);
7582
7912
  mkdirSync6(dirname5(out2), { recursive: true });
7583
7913
  dropSymlink(out2);
7584
- if (lstatSync(src).isSymbolicLink()) {
7585
- writeFileSync6(out2, readFileSync15(src));
7914
+ if (lstatSync3(src).isSymbolicLink()) {
7915
+ writeFileSync6(out2, readFileSync13(src));
7586
7916
  } else {
7587
7917
  cpSync(src, out2, { dereference: true });
7588
7918
  }
7589
7919
  chmodSync2(out2, st.mode);
7590
7920
  }
7591
- const nm = resolve3(cwd, "node_modules");
7592
- if (existsSync10(nm)) symlinkSync(nm, join14(dest, "node_modules"), "dir");
7921
+ const nm = resolve4(cwd, "node_modules");
7922
+ if (existsSync10(nm)) symlinkSync(nm, join13(dest, "node_modules"), "dir");
7593
7923
  }
7594
7924
  function dropSymlink(p) {
7595
7925
  try {
7596
- if (lstatSync(p).isSymbolicLink()) rmSync3(p, { force: true });
7926
+ if (lstatSync3(p).isSymbolicLink()) rmSync3(p, { force: true });
7597
7927
  } catch {
7598
7928
  }
7599
7929
  }
@@ -7622,11 +7952,11 @@ function overlayDigest(dest, paths) {
7622
7952
  for (const rel of [...paths].sort()) {
7623
7953
  h.update(rel);
7624
7954
  h.update("\0");
7625
- const p = join14(dest, rel);
7955
+ const p = join13(dest, rel);
7626
7956
  try {
7627
- const st = lstatSync(p);
7957
+ const st = lstatSync3(p);
7628
7958
  h.update(String(st.mode));
7629
- h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync15(p));
7959
+ h.update(st.isSymbolicLink() ? readlinkSync3(p) : readFileSync13(p));
7630
7960
  } catch {
7631
7961
  h.update("<gone>");
7632
7962
  }
@@ -7649,7 +7979,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
7649
7979
  throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
7650
7980
  }
7651
7981
  const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
7652
- const out2 = join14(dest, e.path);
7982
+ const out2 = join13(dest, e.path);
7653
7983
  mkdirSync6(dirname5(out2), { recursive: true });
7654
7984
  rmSync3(out2, { force: true });
7655
7985
  if (e.mode === "120000") {
@@ -7664,7 +7994,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
7664
7994
  const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
7665
7995
  for (const rel of inCopy) {
7666
7996
  if (!isOverlay(rel) || baseProtected.has(rel)) continue;
7667
- rmSync3(join14(dest, rel), { force: true });
7997
+ rmSync3(join13(dest, rel), { force: true });
7668
7998
  removed++;
7669
7999
  }
7670
8000
  return { restored, removed };
@@ -7706,8 +8036,8 @@ function suiteEnv(scratch) {
7706
8036
  if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
7707
8037
  env[k] = v;
7708
8038
  }
7709
- const userRc = join14(scratch, "npmrc-user");
7710
- const globalRc = join14(scratch, "npmrc-global");
8039
+ const userRc = join13(scratch, "npmrc-user");
8040
+ const globalRc = join13(scratch, "npmrc-global");
7711
8041
  for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
7712
8042
  env.npm_config_userconfig = userRc;
7713
8043
  env.npm_config_globalconfig = globalRc;
@@ -7717,8 +8047,8 @@ function suiteEnv(scratch) {
7717
8047
  }
7718
8048
  function runSuite(dir, cmd, budgetSecs) {
7719
8049
  const t0 = Date.now();
7720
- const outDir = mkdtempSync2(join14(tmpdir2(), "tw-verify-run-"));
7721
- const outFile = join14(outDir, "result.json");
8050
+ const outDir = mkdtempSync2(join13(tmpdir2(), "tw-verify-run-"));
8051
+ const outFile = join13(outDir, "result.json");
7722
8052
  try {
7723
8053
  spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
7724
8054
  cwd: dir,
@@ -7731,7 +8061,7 @@ function runSuite(dir, cmd, budgetSecs) {
7731
8061
  const secs = Math.round((Date.now() - t0) / 1e3);
7732
8062
  let r;
7733
8063
  try {
7734
- r = JSON.parse(readFileSync15(outFile, "utf8"));
8064
+ r = JSON.parse(readFileSync13(outFile, "utf8"));
7735
8065
  } catch {
7736
8066
  return { exit: null, secs };
7737
8067
  }
@@ -7782,8 +8112,8 @@ function runVerify(opts) {
7782
8112
  out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
7783
8113
  return 2;
7784
8114
  }
7785
- const visRoot = mkdtempSync2(join14(tmpdir2(), "tw-verify-vis-"));
7786
- const visDir = join14(visRoot, "t");
8115
+ const visRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-vis-"));
8116
+ const visDir = join13(visRoot, "t");
7787
8117
  const cleanup = (dirs) => {
7788
8118
  if (opts.keep) return;
7789
8119
  for (const d of dirs) rmSync3(d, { recursive: true, force: true });
@@ -7811,8 +8141,8 @@ function runVerify(opts) {
7811
8141
  out2("pristine run executes through it too. Failing closed, not open.");
7812
8142
  return 2;
7813
8143
  }
7814
- const priRoot = mkdtempSync2(join14(tmpdir2(), "tw-verify-pri-"));
7815
- const priDir = join14(priRoot, "t");
8144
+ const priRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-pri-"));
8145
+ const priDir = join13(priRoot, "t");
7816
8146
  let restored = [];
7817
8147
  let removedAdded = 0;
7818
8148
  try {
@@ -7924,8 +8254,8 @@ function parseVerify(args) {
7924
8254
 
7925
8255
  // src/cli/run.ts
7926
8256
  import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
7927
- import { readdirSync as readdirSync4, readFileSync as readFileSync16, realpathSync } from "node:fs";
7928
- import { resolve as resolve4 } from "node:path";
8257
+ import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync as realpathSync3 } from "node:fs";
8258
+ import { resolve as resolve5 } from "node:path";
7929
8259
  var out = (s) => process.stdout.write(s + "\n");
7930
8260
  var err = (s) => process.stderr.write(s + "\n");
7931
8261
  function git3(args, cwd) {
@@ -7933,7 +8263,7 @@ function git3(args, cwd) {
7933
8263
  }
7934
8264
  function nowTicks() {
7935
8265
  try {
7936
- return Math.floor(parseFloat(readFileSync16("/proc/uptime", "utf8").split(" ")[0]) * 100);
8266
+ return Math.floor(parseFloat(readFileSync14("/proc/uptime", "utf8").split(" ")[0]) * 100);
7937
8267
  } catch {
7938
8268
  return Number.POSITIVE_INFINITY;
7939
8269
  }
@@ -7942,7 +8272,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
7942
8272
  const out2 = [];
7943
8273
  let real;
7944
8274
  try {
7945
- real = realpathSync(cwd);
8275
+ real = realpathSync3(cwd);
7946
8276
  } catch {
7947
8277
  return out2;
7948
8278
  }
@@ -7957,13 +8287,13 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
7957
8287
  if (n === process.pid) continue;
7958
8288
  let held;
7959
8289
  try {
7960
- held = realpathSync(`/proc/${pid}/cwd`);
8290
+ held = realpathSync3(`/proc/${pid}/cwd`);
7961
8291
  } catch {
7962
8292
  continue;
7963
8293
  }
7964
8294
  if (held !== real && !held.startsWith(real + "/")) continue;
7965
8295
  try {
7966
- const stat = readFileSync16(`/proc/${pid}/stat`, "utf8");
8296
+ const stat = readFileSync14(`/proc/${pid}/stat`, "utf8");
7967
8297
  const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
7968
8298
  if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
7969
8299
  } catch {
@@ -7972,7 +8302,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
7972
8302
  return out2;
7973
8303
  }
7974
8304
  function runEnvelope(opts) {
7975
- const cwd = resolve4(opts.cwd ?? process.cwd());
8305
+ const cwd = resolve5(opts.cwd ?? process.cwd());
7976
8306
  if (!opts.argv.length) {
7977
8307
  err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
7978
8308
  return 2;