vigiles 15.0.2 β†’ 15.0.3

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 (82) hide show
  1. package/dist/adapters/claude-code/layout.js +3 -0
  2. package/dist/adapters/claude-code/run-scripts.d.ts +21 -9
  3. package/dist/adapters/claude-code/run-scripts.js +33 -22
  4. package/dist/adapters/codex/driver.js +3 -1
  5. package/dist/adapters/codex/eval.js +5 -0
  6. package/dist/audit-score.js +32 -6
  7. package/dist/check-count.d.ts +62 -3
  8. package/dist/check-count.js +123 -5
  9. package/dist/check.d.ts +55 -0
  10. package/dist/check.js +91 -0
  11. package/dist/cli.js +456 -54
  12. package/dist/core/bash-effects.d.ts +41 -0
  13. package/dist/core/bash-effects.js +278 -0
  14. package/dist/core/foreign-runner.d.ts +185 -0
  15. package/dist/core/foreign-runner.js +228 -0
  16. package/dist/core/harness-driver.d.ts +18 -1
  17. package/dist/core/hook-program.d.ts +319 -20
  18. package/dist/core/hook-program.js +743 -49
  19. package/dist/core/layout.d.ts +13 -0
  20. package/dist/core/lethal-trifecta.d.ts +93 -0
  21. package/dist/core/lethal-trifecta.js +409 -65
  22. package/dist/core/markdown.d.ts +32 -0
  23. package/dist/core/markdown.js +36 -0
  24. package/dist/core/merge-conflict.d.ts +50 -0
  25. package/dist/core/merge-conflict.js +84 -0
  26. package/dist/core/test-file-ext.d.ts +80 -0
  27. package/dist/core/test-file-ext.js +90 -0
  28. package/dist/core/types.d.ts +11 -0
  29. package/dist/coverage-artifact.d.ts +383 -0
  30. package/dist/coverage-artifact.js +586 -0
  31. package/dist/coverage-evidence.d.ts +87 -79
  32. package/dist/coverage-evidence.js +212 -211
  33. package/dist/coverage-probe.d.ts +125 -0
  34. package/dist/coverage-probe.js +700 -0
  35. package/dist/doc-commands.d.ts +119 -0
  36. package/dist/doc-commands.js +158 -0
  37. package/dist/eval-lock.d.ts +9 -0
  38. package/dist/eval-lock.js +14 -2
  39. package/dist/eval.js +28 -0
  40. package/dist/fs-walk.d.ts +80 -0
  41. package/dist/fs-walk.js +148 -0
  42. package/dist/harness-assert.js +42 -3
  43. package/dist/harness-test.js +31 -2
  44. package/dist/judge.js +4 -0
  45. package/dist/leaderboard.d.ts +16 -1
  46. package/dist/leaderboard.js +19 -2
  47. package/dist/load-hook.js +6 -1
  48. package/dist/mock-model.d.ts +58 -4
  49. package/dist/mock-model.js +133 -5
  50. package/dist/observe.d.ts +1 -1
  51. package/dist/observe.js +44 -1
  52. package/dist/plugin-loader.js +52 -13
  53. package/dist/run-hook.d.ts +58 -0
  54. package/dist/run-hook.js +70 -0
  55. package/dist/run-script.js +75 -0
  56. package/dist/scaffold-test.d.ts +3 -0
  57. package/dist/scaffold-test.js +10 -5
  58. package/dist/scan-behavioral.js +4 -0
  59. package/dist/scan-core.d.ts +22 -0
  60. package/dist/scan-core.js +46 -14
  61. package/dist/scan-files.js +19 -2
  62. package/dist/scan.d.ts +22 -5
  63. package/dist/scan.js +67 -13
  64. package/dist/skill-contract.d.ts +46 -0
  65. package/dist/skill-contract.js +201 -0
  66. package/dist/skill-refs.d.ts +67 -0
  67. package/dist/skill-refs.js +117 -0
  68. package/dist/test-coverage-files.d.ts +7 -1
  69. package/dist/test-coverage-files.js +66 -55
  70. package/dist/test-coverage.d.ts +155 -19
  71. package/dist/test-coverage.js +430 -91
  72. package/dist/testing.d.ts +8 -2
  73. package/dist/testing.js +19 -1
  74. package/dist/trigger-containment.d.ts +85 -0
  75. package/dist/trigger-containment.js +125 -0
  76. package/dist/ts-runner-caps.d.ts +16 -0
  77. package/dist/ts-runner-caps.js +43 -0
  78. package/dist/unit.d.ts +2 -2
  79. package/dist/unit.js +2 -1
  80. package/hooks/eval-lock-nudge.sh +14 -6
  81. package/package.json +2 -2
  82. package/skills/test-harness/SKILL.md +68 -2
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.defineReact = exports.nothing = exports.notice = exports.run = exports.defineInject = exports.inject = exports.tools = exports.HookCompileError = exports.tool = exports.ask = exports.deny = exports.allow = void 0;
4
4
  exports.gateAction = gateAction;
5
+ exports.trimTrailingSeparators = trimTrailingSeparators;
5
6
  exports.commandView = commandView;
6
7
  exports.defineHook = defineHook;
7
8
  exports.decideProgram = decideProgram;
@@ -15,6 +16,8 @@ exports.compileHookProgram = compileHookProgram;
15
16
  exports.stampHook = stampHook;
16
17
  exports.verifyHookStamp = verifyHookStamp;
17
18
  exports.pathView = pathView;
19
+ exports.projectRootOf = projectRootOf;
20
+ exports.undecidablePathWarning = undecidablePathWarning;
18
21
  exports.defineFileGate = defineFileGate;
19
22
  exports.decideFileGate = decideFileGate;
20
23
  exports.definePromptGate = definePromptGate;
@@ -24,8 +27,11 @@ exports.decideStopGate = decideStopGate;
24
27
  exports.runInject = runInject;
25
28
  exports.responseView = responseView;
26
29
  exports.runReact = runReact;
30
+ exports.rememberHookSource = rememberHookSource;
31
+ exports.hookSource = hookSource;
27
32
  exports.runHookProgram = runHookProgram;
28
33
  exports.isStampRepairEvent = isStampRepairEvent;
34
+ exports.isLoadPathRepairEvent = isLoadPathRepairEvent;
29
35
  /**
30
36
  * Compiled hooks β€” a hook as a CONSTRAINED TYPED PROGRAM, not arbitrary shell.
31
37
  *
@@ -61,6 +67,7 @@ const bash_effects_js_1 = require("./bash-effects.js");
61
67
  const hash_js_1 = require("./hash.js");
62
68
  const toml_1 = require("@iarna/toml");
63
69
  const hook_events_js_1 = require("./hook-events.js");
70
+ const merge_conflict_js_1 = require("./merge-conflict.js");
64
71
  const hook_providers_js_1 = require("./hook-providers.js");
65
72
  const allow = () => ({ kind: "allow" });
66
73
  exports.allow = allow;
@@ -104,10 +111,191 @@ function runsSeq(argv, tokens) {
104
111
  }
105
112
  return i === tokens.length;
106
113
  }
107
- /** Does a single token name a path at/under `prefix` (boundary-aware)? */
108
- function tokenUnder(token, prefix) {
109
- const t = token.replace(/^\.\//, "");
110
- return t === prefix || t.startsWith(prefix + "/") || t.endsWith("/" + prefix);
114
+ /**
115
+ * Collapse a {@link PrefixVerdict} to the boolean a matcher returns.
116
+ *
117
+ * `onUndecidable` HAS NO DEFAULT ON PURPOSE. The two callers need opposite
118
+ * values, and the one that guesses wrong (`"miss"` for a denylist) fails OPEN β€”
119
+ * the failure mode that made this refactor necessary. A default here would make
120
+ * that bug reachable by omission, which is how it was reachable before.
121
+ */
122
+ const matchesPrefix = (verdict, onUndecidable) => verdict === "under" ||
123
+ (verdict === "undecidable" && onUndecidable === "match");
124
+ /**
125
+ * Trailing separators removed β€” EXCEPT the one that is the path itself.
126
+ *
127
+ * πŸ”΄ THE CARVE-OUT IS THE RULE, NOT AN EDGE CASE, and skipping it fails
128
+ * SILENTLY. Strip every trailing separator and the POSIX root `"/"` becomes
129
+ * `""`, the Windows drive root `"C:/"` becomes `"C:"` β€” and `isAbsoluteRef`
130
+ * reads a bare drive letter as RELATIVE. Nothing throws: an allowlist prefix of
131
+ * `"C:/"` simply stops matching, so a confinement gate denies every path and a
132
+ * react hook goes quiet, both looking like a correct decision.
133
+ *
134
+ * This lives here, in one place, because the same normalisation is applied to
135
+ * two different things β€” the PREFIX being matched against, and the project ROOT
136
+ * a path is resolved against (`run-hook.ts` imports it for the latter). Round 28
137
+ * fixed the root and left the prefix; round 29 found the prefix. Two copies of a
138
+ * rule is how the second one survives the fix to the first.
139
+ */
140
+ function trimTrailingSeparators(path) {
141
+ const sep = /[/\\]+$/.exec(path)?.[0];
142
+ const trimmed = sep === undefined ? path : path.slice(0, -sep.length);
143
+ return trimmed === "" || /^[A-Za-z]:$/.test(trimmed)
144
+ ? trimmed + (sep?.[0] ?? "/")
145
+ : trimmed;
146
+ }
147
+ /**
148
+ * A prefix with its glob tail and trailing slash removed β€” `"src/**"`, `"src/"`
149
+ * and `"src"` all mean the same directory.
150
+ *
151
+ * The trailing slash is not cosmetic: a prefix of `"papers/"` used to compare
152
+ * against `"papers//"` and match NOTHING, a trap a shipped guard's own header
153
+ * records having fallen into.
154
+ */
155
+ const normalizePrefix = (prefix) => {
156
+ const stripped = prefix.replace(/\\/g, "/").replace(/\/?\*+$/, "");
157
+ // πŸ”΄ THE CATCH-ALL IS A SENTINEL, NOT A ROOT β€” and conflating them is a
158
+ // regression I shipped in the previous commit. `"**"`, `"*"` and `"/"` all
159
+ // reduce to an EMPTY base, which `prefixVerdict` answers `"under"` for
160
+ // outright, no root required. Handing that empty string to
161
+ // `trimTrailingSeparators` turned it into `"/"` β€” a genuine POSIX root β€” so a
162
+ // catch-all suddenly demanded an absolute spelling and `under(["**"])` went
163
+ // FALSE for every relative path with no root. A catch-all that denies
164
+ // everything is the loudest possible version of this bug and it still
165
+ // type-checked and passed the drive-root test beside it, because that test
166
+ // supplied a root.
167
+ //
168
+ // The drive-letter carve-out below is the opposite case: `"C:"` is not a
169
+ // catch-all, it is a real absolute root that must keep its separator to stay
170
+ // absolute. Same helper, and only the empty case differs.
171
+ if (/^[/\\]*$/.test(stripped))
172
+ return "";
173
+ return trimTrailingSeparators(stripped);
174
+ };
175
+ /**
176
+ * Case-insensitivity is a property of the FILESYSTEM, so it is decided by the
177
+ * root β€” never by the operand being compared.
178
+ *
179
+ * πŸ”΄ KEYING IT ON THE OPERAND COST A P1, and the shape is worth keeping in view.
180
+ * The first version asked "does this string look drive-rooted?", which is true of
181
+ * a root (`C:/repo`) and of an absolute prefix (`C:/repo/secrets`) but FALSE of a
182
+ * repo-relative prefix (`secrets`). So under a Windows root, `C:/REPO/SECRETS/x`
183
+ * resolved to the remainder `SECRETS/x`, was compared against `secrets`
184
+ * case-sensitively, and `writesTo(["secrets"])` returned false β€” a denylist
185
+ * allowing the protected write. The drive letter was known; it just never reached
186
+ * the comparison that needed it.
187
+ *
188
+ * The asymmetry is unchanged and load-bearing: fold only when the ROOT is
189
+ * drive-rooted. POSIX is case-sensitive, so folding there would judge a path from
190
+ * `/REPO` to be inside `/repo` β€” a silent FALSE GRANT, the direction never taken.
191
+ * With no root known, nothing is folded: an unprovable answer must not be turned
192
+ * into a match by a guess about someone else's filesystem.
193
+ */
194
+ const WINDOWS_ROOT =
195
+ // Drive: `C:/x`, `C:\x`. UNC share: `//server/share`, `\\server\share`.
196
+ // Extended-length prefixes `//?/C:/x` and `//?/UNC/server/share` reduce to the
197
+ // two above once `//?/` (and its `UNC/` marker) is consumed β€” which is why
198
+ // they are alternatives here rather than separate cases.
199
+ /^(?:[A-Za-z]:|[/\\]{2}(?:\?[/\\]+(?:UNC[/\\]+)?)?(?:[A-Za-z]:|[^/\\]+[/\\]+[^/\\]+))/;
200
+ /**
201
+ * HOW THIS LIST IS DECIDED, because a table without that note is how the last
202
+ * three rounds happened: Windows absolute paths have exactly two root forms β€”
203
+ * a drive (`C:`) and a UNC share (`//server/share`) β€” plus the extended-length
204
+ * `//?/` prefix, which is a spelling OF those two, not a third. Anything else
205
+ * (a relative path, a POSIX absolute path) is not a Windows root.
206
+ *
207
+ * ⚠️ The miss direction is stated rather than implied: an unrecognised root means
208
+ * NO folding, so a denylist can miss on casing alone. That is the wrong
209
+ * direction, and it is accepted only because the alternative β€” folding whenever
210
+ * we are unsure β€” is a false GRANT on Linux, where `/repo/Secrets` and
211
+ * `/repo/secrets` are two different files. If a genuine third root form turns
212
+ * up, it belongs in the regex above, not in a caller.
213
+ */
214
+ const caseInsensitiveFs = (root) => root !== undefined && WINDOWS_ROOT.test(root);
215
+ const foldWhen = (value, insensitive) => insensitive ? value.toLowerCase() : value;
216
+ /** Is `candidate` the prefix itself, or something below it? Boundary-aware. */
217
+ const isAtOrUnder = (rawCandidate, rawBase, insensitive = false) => {
218
+ // An absolute drive-rooted BASE names a Windows filesystem by itself, even when
219
+ // the caller could not say so (`under(["C:/x"])` with no root).
220
+ const fold = insensitive || WINDOWS_ROOT.test(rawBase);
221
+ const base = foldWhen(rawBase, fold);
222
+ const candidate = foldWhen(rawCandidate, fold);
223
+ return (candidate === base ||
224
+ // A base that IS a separator (`"/"`, `"C:/"` β€” see `trimTrailingSeparators`)
225
+ // already carries the boundary, so appending another looks for `"C://"` and
226
+ // matches nothing. Keeping the carve-out without this line trades one silent
227
+ // never-match for another.
228
+ candidate.startsWith(/[/\\]$/.test(base) ? base : base + "/"));
229
+ };
230
+ /**
231
+ * Could this path be under this prefix under SOME root or working directory?
232
+ *
233
+ * The tractable stand-in for "unprovable": the prefix's segments occur as a whole
234
+ * contiguous run somewhere in the path. `/home/u/mine/migratsiya/papers/x.tex`
235
+ * could be `migratsiya/papers/x.tex` in a repo rooted at `/home/u/mine`; the
236
+ * leading directories are exactly what we cannot rule out without a root.
237
+ *
238
+ * A leading `/` is stripped from the prefix first, so an ABSOLUTE prefix can
239
+ * still be recognized inside a relative token (`/etc` vs `etc/passwd` under an
240
+ * unknown `cd`).
241
+ *
242
+ * ⚠️ WHAT THIS STILL MISSES, stated rather than implied β€” in both cases the
243
+ * verdict is `"outside"`, so the denylist misses too:
244
+ *
245
+ * 1. The segments must be PRESENT. `cd migratsiya && sed -i s/a/b/ papers/x.tex`
246
+ * names only `papers/x.tex`, so a prefix of `migratsiya/papers` finds nothing.
247
+ * Resolving a leaf's argument against a preceding `cd` is a parser change, not
248
+ * a matcher one; it is the blind spot both shipped guards already document.
249
+ * 2. An ABSOLUTE prefix against a RELATIVE token with NO root β€” `touches(["/r/
250
+ * health/data/dna"])` vs the token `health/data/dna/g.txt`. The needle is the
251
+ * longer string, so containment cannot see it. Deliberately not chased: the
252
+ * runtime always supplies a root ({@link projectRootOf} falls back to the
253
+ * event's `cwd`, which Claude Code sends on every payload), and a gate that
254
+ * builds absolute prefixes at all builds them FROM a root, so by construction
255
+ * it has one. Widening this arm would also cost precision in the case that
256
+ * matters β€” a token that IS placeable would start matching on segments alone.
257
+ */
258
+ const mightBeUnder = (path, base, insensitive = false) => {
259
+ const needle = base.replace(/^\/+/, "").replace(/^[A-Za-z]:\//, "");
260
+ if (needle === "")
261
+ return true;
262
+ const raw = path.replace(/\\/g, "/").replace(/^\.\//, "");
263
+ // Same fold, same rule: keyed on the BASE, which is what decides whether a
264
+ // Windows filesystem is in play. Left case-sensitive here, the denylist's
265
+ // "could this be under it?" fallback misses on casing alone.
266
+ const fold = insensitive || WINDOWS_ROOT.test(base);
267
+ const p = foldWhen(raw, fold);
268
+ const n = foldWhen(needle, fold);
269
+ return (isAtOrUnder(p, n, fold) || p.endsWith("/" + n) || p.includes("/" + n + "/"));
270
+ };
271
+ /**
272
+ * The verdict for ONE path against ONE prefix, given the project root.
273
+ *
274
+ * A prefix is matched in ITS OWN spelling β€” a repo-relative prefix against the
275
+ * path made repo-relative, an absolute prefix against the path made absolute β€”
276
+ * because those are the only two comparisons that mean anything. When the needed
277
+ * spelling cannot be produced (an absolute path with no root, a path resolving
278
+ * outside the root, a relative path with no root to absolutize it), the path was
279
+ * not PLACED, and the answer falls to {@link mightBeUnder}.
280
+ *
281
+ * Note what does NOT happen: a placed path that is not under the prefix comes
282
+ * back `"under"`-less but is still offered to `mightBeUnder`, so a denylist
283
+ * over-blocks a sibling checkout's `…/migratsiya/papers/…` rather than silently
284
+ * allowing it. That over-block is the chosen error direction, not an oversight β€”
285
+ * `under` never sees it, because `under` accepts `"under"` only.
286
+ */
287
+ function prefixVerdict(path, prefix, root) {
288
+ const base = normalizePrefix(prefix);
289
+ if (base === "")
290
+ return "under"; // `"/"`, `"**"` β€” everything is under it
291
+ const slashed = path.replace(/\\/g, "/");
292
+ const placed = isAbsoluteRef(base)
293
+ ? absoluteSpelling(slashed, root)
294
+ : relativeSpelling(slashed, root);
295
+ const insensitive = caseInsensitiveFs(root);
296
+ if (placed !== undefined && isAtOrUnder(placed, base, insensitive))
297
+ return "under";
298
+ return mightBeUnder(slashed, base, insensitive) ? "undecidable" : "outside";
111
299
  }
112
300
  /** A leaf whose head is a shell reading stdin (no script-file argument). */
113
301
  function isBareShellLeaf(argv) {
@@ -183,7 +371,19 @@ function writeTargetsOf(leaf) {
183
371
  return [];
184
372
  }
185
373
  }
186
- function commandView(raw) {
374
+ /**
375
+ * An AST-backed view of a Bash command.
376
+ *
377
+ * @param raw - the command line as the tool event carried it.
378
+ * @param root - the project root repo-relative prefixes resolve against, from
379
+ * the runtime ({@link projectRootOf}: `$CLAUDE_PROJECT_DIR`, else the event's
380
+ * own `cwd`, never `process.cwd()`). Omitting it does NOT disarm the denylist
381
+ * matchers β€” without a root an absolute token is `"undecidable"` rather than
382
+ * placeable, and {@link CommandView.touches} treats that as a match. It does
383
+ * cost precision: with a root, `/somewhere-else/notes/x` is provably not this
384
+ * repo's `notes`; without one it can only be over-blocked.
385
+ */
386
+ function commandView(raw, root) {
187
387
  const leaves = (0, bash_effects_js_1.leafCommands)(raw);
188
388
  // The operation-normalized leaves carry the redirections (and quote-unwrapped,
189
389
  // wrapper-resolved argv) that `writesTo` needs; `leafCommands` cannot see them.
@@ -199,8 +399,40 @@ function commandView(raw) {
199
399
  return leaves.some((argv) => runsSeq(argv, tokens) && (opts?.force ? hasForce(argv) : true));
200
400
  },
201
401
  isSideEffecting: () => (0, bash_effects_js_1.classifyBashCommand)(raw) === "side-effecting",
202
- touches: (prefixes) => leaves.some((argv) => argv.slice(1).some((tok) => prefixes.some((p) => tokenUnder(tok, p)))),
203
- writesTo: (prefixes) => writeTargets.some((t) => prefixes.some((p) => tokenUnder(t, p))),
402
+ // Both of these are DENYLIST matchers, so both break an undecidable verdict
403
+ // toward "match" β€” see the block above `PrefixVerdict` for the measurement
404
+ // that made the opposite default a security hole.
405
+ // πŸ”΄ READS THE NORMALIZED ARGV, NOT THE RAW ONE, AND THAT IS THE FIX FOR A
406
+ // REAL BYPASS. `leafCommands` keeps a word exactly as written, quotes and
407
+ // all, so a token arrived as `"papers/x.tex"` β€” with the quote characters β€”
408
+ // and matched no prefix at all. MEASURED end-to-end on a shipped guard:
409
+ // `sed -i s/a/b/ migratsiya/papers/x/paper.tex` exited 2 (blocked) while
410
+ // `sed -i s/a/b/ "migratsiya/papers/x/paper.tex"` exited 0. Quoting a path
411
+ // is not an exotic evasion β€” it is what anyone writes for a path with a
412
+ // space in it, so every denylist built on `touches` was one quote from open.
413
+ // `writesTo` was already correct precisely because it reads `normalized`;
414
+ // this is the same class as the other siblings this PR keeps finding, so
415
+ // both denylists now read the SAME argv.
416
+ touches: (prefixes) =>
417
+ // πŸ”΄ THE UNION IS THE FIX, AND EACH HALF COVERS THE OTHER'S BLIND SPOT.
418
+ // Raw argv keeps a word exactly as written, so a QUOTED path arrives as
419
+ // `"papers/x.tex"` and matches nothing β€” measured end-to-end on a shipped
420
+ // guard: unquoted exited 2, quoted exited 0. Normalized argv unwraps the
421
+ // quotes, but `stripWrappers` also CONSUMES wrapper options, so
422
+ // `env -C secrets cat key` loses `secrets` entirely and a denylist on it
423
+ // fails open. Reading only one of the two trades one bypass for the other;
424
+ // that is exactly what the previous commit did, and it earned a P1.
425
+ // A denylist over-matching costs an argument. Missing costs the write.
426
+ [...leaves.map((argv) => argv.slice(1)), ...normalized.map((l) => l.args)]
427
+ .flat()
428
+ // `--chdir=secrets` is ONE token, so the path is invisible to a whole-token
429
+ // comparison. Only the tail of an `--opt=value` word can be a path, so the
430
+ // extra candidate is added rather than replacing the token.
431
+ .flatMap((tok) => /^-{1,2}[^=]+=/.test(tok)
432
+ ? [tok, tok.slice(tok.indexOf("=") + 1)]
433
+ : [tok])
434
+ .some((tok) => prefixes.some((p) => matchesPrefix(prefixVerdict(tok, p, root), "match"))),
435
+ writesTo: (prefixes) => writeTargets.some((t) => prefixes.some((p) => matchesPrefix(prefixVerdict(t, p, root), "match"))),
204
436
  pipesToShell: () => leaves.some(isBareShellLeaf),
205
437
  };
206
438
  }
@@ -212,8 +444,20 @@ function defineHook(p) {
212
444
  // ---------------------------------------------------------------------------
213
445
  // Run the program against a raw PreToolUse event (the runtime half)
214
446
  // ---------------------------------------------------------------------------
215
- /** Build the typed event from a raw PreToolUse event, then decide. */
216
- function decideProgram(program, rawEvent, ctx = {}) {
447
+ /**
448
+ * Build the typed event from a raw PreToolUse event, then decide.
449
+ *
450
+ * `root` is the project root repo-relative prefixes in the hook resolve against,
451
+ * threaded exactly as {@link decideFileGate} threads it: omitted, it falls back
452
+ * to the event's OWN `cwd` (a payload field, so this stays pure and reads no
453
+ * environment); the CLI passes {@link projectRootOf}, which prefers
454
+ * `$CLAUDE_PROJECT_DIR`. Until 2026-08-12 no root reached here at all, and the
455
+ * denylist matchers on {@link CommandView} were bypassable by spelling a path
456
+ * absolutely β€” see the block above {@link PrefixVerdict} for the measurement.
457
+ */
458
+ function decideProgram(program, rawEvent, ctx = {}, root = typeof rawEvent.cwd === "string"
459
+ ? rawEvent.cwd
460
+ : undefined) {
217
461
  if (rawEvent.tool_name !== program.match.tool)
218
462
  return (0, exports.allow)();
219
463
  const command = typeof rawEvent.tool_input?.command === "string"
@@ -222,7 +466,7 @@ function decideProgram(program, rawEvent, ctx = {}) {
222
466
  return program.decide({
223
467
  event: program.on,
224
468
  tool: "Bash",
225
- command: commandView(command),
469
+ command: commandView(command, root),
226
470
  ctx: ctx,
227
471
  });
228
472
  }
@@ -368,16 +612,185 @@ function stampHook(source) {
368
612
  function verifyHookStamp(source, stamp) {
369
613
  return stampHook(source) === stamp;
370
614
  }
371
- function pathView(raw) {
372
- const norm = raw.replace(/^\.\//, "");
615
+ /**
616
+ * A view of a file path, matched against prefixes by {@link PathView.under}.
617
+ *
618
+ * πŸ”΄ `root` IS NOT OPTIONAL IN PRACTICE β€” WITHOUT IT THIS IS DEAD FOR EVERY REAL
619
+ * EVENT. Claude Code's Edit/Write/MultiEdit tools always send an ABSOLUTE
620
+ * `file_path`, and every hook in the wild writes repo-relative prefixes
621
+ * (`under(["migratsiya/papers/"])`). Before the root existed, the two could
622
+ * never meet: the comparison ran `"/home/u/repo/migratsiya/papers/x.tex"`
623
+ * against `"migratsiya/papers"` and returned `false` for all input, forever.
624
+ * MEASURED 2026-08-12 on the live runtime with one compiled react hook and two
625
+ * spellings of the same file β€” the relative one printed its checklist, the
626
+ * absolute one exited 0 in silence. Three shipped hooks in a consumer repo were
627
+ * dead by it, all three compiled *specifically to replace hooks that were dead*,
628
+ * and none of the tests noticed because the harness helper built relative paths.
629
+ *
630
+ * ## Which way a miss errs, and why it is not the same answer as `touches`
631
+ *
632
+ * `under` is the ALLOWLIST/COVERAGE primitive: its callers spell
633
+ * `under(P) ? allow() : deny()` (confinement) and `under(P) ? notice() : nothing()`
634
+ * (a nudge). For both, an unprovable answer must be `false` β€” a confinement gate
635
+ * denies (fails closed) and a nudge stays quiet. So:
636
+ *
637
+ * - path resolves OUTSIDE the root β†’ `false` for every relative prefix. Not a
638
+ * bias, the truth: a repo-relative prefix names nothing in another checkout.
639
+ * This is the {@link resolveRef} lesson β€” the suffix match it replaced handed
640
+ * `/home/other-project/package.json` a grant meant for this repo's.
641
+ * - NO root known and the path is absolute β†’ `false` for every relative prefix.
642
+ * Here we genuinely cannot tell, and `false` is the choice: silence, never a
643
+ * false grant. It is also the pre-fix behaviour, so nothing regresses.
644
+ *
645
+ * ⚠️ THE ASYMMETRY INVERTS FOR A DENYLIST, AND `under` DOES NOT SERVE THAT CASE.
646
+ * A gate written `under(["secrets"]) ? deny() : allow()` reads an unprovable
647
+ * `false` as ALLOW β€” a false grant. The Bash-side counterpart {@link
648
+ * CommandView.touches} exists precisely for that direction and takes the
649
+ * opposite bias from the SAME rule ({@link prefixVerdict}), which is why the
650
+ * resolver {@link matchesPrefix} makes each caller name its own. `PathView` has
651
+ * no such counterpart; a file-side denylist inherits the allowlist bias.
652
+ * Reported, not papered over.
653
+ *
654
+ * @param raw - the `file_path` the tool event carried, any spelling.
655
+ * @param root - the project root relative prefixes resolve against. Comes from
656
+ * the runtime (`$CLAUDE_PROJECT_DIR`, else the event's own `cwd`), never from
657
+ * `process.cwd()` β€” under a git worktree the process's cwd is a DIFFERENT
658
+ * checkout from the one the harness resolved the hook out of, and that exact
659
+ * mismatch already wedged a repo once.
660
+ */
661
+ function pathView(raw, root) {
662
+ const slashed = raw.replace(/\\/g, "/");
373
663
  return {
374
664
  raw,
375
- under: (prefixes) => prefixes.some((p) => {
376
- const base = p.replace(/\/?\*+$/, "").replace(/\/$/, "");
377
- return base === "" || norm === base || norm.startsWith(base + "/");
378
- }),
665
+ rel: relativeSpelling(slashed, root),
666
+ // A prefix is matched only against its OWN spelling, and a path that cannot
667
+ // be produced in that spelling is a miss β€” never a silent fallback to the
668
+ // other one. `"miss"` is the ALLOWLIST bias, named here rather than
669
+ // inherited: `matchesPrefix` has no default, so the denylist matchers on
670
+ // `CommandView` cannot pick this one up by accident (and did not, for the
671
+ // whole time they shared a single boolean helper with it).
672
+ under: (prefixes) => prefixes.some((p) => matchesPrefix(prefixVerdict(slashed, p, root), "miss")),
379
673
  };
380
674
  }
675
+ /** The path as an absolute reference, or `undefined` when it cannot be one. */
676
+ /**
677
+ * A root only relates the two spellings if it is itself ABSOLUTE. A relative one
678
+ * β€” `"."`, `"repo"` β€” is treated as no root at all, and the reason is a wrong
679
+ * ANSWER rather than tidiness: `resolveRef(".", ".")` collapses to `""`, so
680
+ * `/etc/passwd` would come back as the repo-relative `etc/passwd` and satisfy
681
+ * `under(["etc"])` inside a repo that has no such directory. Claude Code always
682
+ * sends an absolute `cwd` and sets an absolute `$CLAUDE_PROJECT_DIR`, so this
683
+ * costs nothing real and closes the one input that produced a false grant.
684
+ */
685
+ const usableRoot = (root) => root !== undefined && isAbsoluteRef(root.replace(/\\/g, "/"))
686
+ ? root
687
+ : undefined;
688
+ /** The path as an absolute reference, or `undefined` when it cannot be one. */
689
+ function absoluteSpelling(path, root) {
690
+ const r = usableRoot(root);
691
+ if (r !== undefined)
692
+ return resolveRef(r, path);
693
+ // No usable root: an already-absolute path just needs its dot segments
694
+ // collapsed; a relative one has nothing to hang off, so there is no absolute
695
+ // spelling of it.
696
+ return isAbsoluteRef(path) ? resolveRef("/", path) : undefined;
697
+ }
698
+ /** The path as a repo-relative reference, or `undefined` when it cannot be one. */
699
+ function relativeSpelling(path, root) {
700
+ const r = usableRoot(root);
701
+ if (r !== undefined)
702
+ return relativeToRoot(r, path);
703
+ // No usable root: an absolute path is UNKNOWABLE β€” this is the case that used
704
+ // to be every real event, and the miss is chosen to cost silence (see
705
+ // `pathView`).
706
+ return isAbsoluteRef(path) ? undefined : path.replace(/^\.\//, "");
707
+ }
708
+ /**
709
+ * `raw` expressed relative to `root`, or `undefined` when it resolves outside it.
710
+ * Both sides are resolved and compared WHOLE (never by suffix) β€” see
711
+ * {@link resolveRef} for the hole that cost.
712
+ */
713
+ function relativeToRoot(root, raw) {
714
+ const base = resolveRef(root.replace(/\\/g, "/"), ".");
715
+ const full = resolveRef(root.replace(/\\/g, "/"), raw);
716
+ // πŸ”΄ CASE FOLDING IS DRIVE-ROOTED-ONLY, AND THE ASYMMETRY IS THE POINT.
717
+ // Windows filesystems are case-insensitive by default, so `C:/Repo` and
718
+ // `c:/repo/src/x.ts` name the same place β€” compared exactly, the second reads
719
+ // as OUTSIDE the first and the gate silently denies a same-repo edit. POSIX is
720
+ // case-SENSITIVE: `/repo/Secrets` and `/repo/secrets` are two different files,
721
+ // and folding there would invent a match that does not exist β€” turning a
722
+ // silent miss into a silent false grant, which is the worse direction. So the
723
+ // fold is applied only when the root is drive-rooted, and only for the
724
+ // comparison; the returned remainder keeps the path's own casing.
725
+ const insensitive = caseInsensitiveFs(base);
726
+ const foldedBase = foldWhen(base, insensitive);
727
+ const foldedFull = foldWhen(full, insensitive);
728
+ if (foldedFull === foldedBase)
729
+ return "";
730
+ if (foldedBase === "/")
731
+ return full.slice(1);
732
+ return foldedFull.startsWith(foldedBase + "/")
733
+ ? full.slice(base.length + 1)
734
+ : undefined;
735
+ }
736
+ /**
737
+ * The project root a compiled hook's repo-relative prefixes resolve against.
738
+ *
739
+ * Two sources, in order, and `process.cwd()` is deliberately NOT one of them:
740
+ *
741
+ * 1. **`$CLAUDE_PROJECT_DIR`** β€” what the harness itself resolved the hook's own
742
+ * path against (`.claude/settings.json` spells the command
743
+ * `"$CLAUDE_PROJECT_DIR/.claude/hooks/x.hook.ts"`), so it is the same root the
744
+ * hook was loaded from by construction.
745
+ * 2. **the event's `cwd`** β€” Claude Code puts the session's working directory in
746
+ * every hook payload. A fallback, not a peer: it is where the session is, and
747
+ * the two coincide in the ordinary case.
748
+ *
749
+ * `process.cwd()` is excluded because under a git worktree it can be a different
750
+ * checkout than the one the harness is driving, and a root from the wrong
751
+ * checkout turns every repo-relative prefix into a non-match β€” the same silent
752
+ * death this whole function exists to end. When NEITHER source is present the
753
+ * answer is `undefined` and the miss errs toward silence (see {@link pathView}).
754
+ */
755
+ function projectRootOf(event, env = {}) {
756
+ // πŸ”΄ THE FIRST *USABLE* ROOT, NOT THE FIRST NON-EMPTY STRING. A relative
757
+ // `$CLAUDE_PROJECT_DIR` β€” `.` is the obvious one β€” is not a root: `usableRoot`
758
+ // rejects it downstream, precisely so a relative value cannot turn
759
+ // `/etc/passwd` into the repo-relative `etc/passwd`. Returning it anyway
760
+ // MASKED a perfectly good absolute `cwd` from the payload, so absolute paths
761
+ // stopped matching repo-relative prefixes β€” and worse, `undecidablePathWarning`
762
+ // saw a defined root and stayed quiet, removing the one signal that says why.
763
+ // A source that cannot serve as a root must not consume the slot.
764
+ for (const candidate of [env.CLAUDE_PROJECT_DIR, event.cwd]) {
765
+ if (typeof candidate !== "string" || candidate.trim() === "")
766
+ continue;
767
+ if (usableRoot(candidate) !== undefined)
768
+ return candidate;
769
+ }
770
+ return undefined;
771
+ }
772
+ /**
773
+ * The one case {@link pathView} cannot answer and must not answer silently: an
774
+ * ABSOLUTE `file_path` with no project root in sight. Every repo-relative prefix
775
+ * in the hook returns `false`, so a gate waves everything through and a nudge
776
+ * never fires β€” indistinguishable, from the outside, from a hook with nothing to
777
+ * say. Returns the warning line, or `undefined` when there is nothing wrong.
778
+ *
779
+ * Narrow on purpose: a RELATIVE `file_path` is decidable without a root, so it
780
+ * warns about nothing. That matters because a react hook's `notice` also goes to
781
+ * stderr, and a warning on every event would read as the hook firing.
782
+ */
783
+ function undecidablePathWarning(filePath, root) {
784
+ if (root !== undefined)
785
+ return undefined;
786
+ if (typeof filePath !== "string" ||
787
+ !isAbsoluteRef(filePath.replace(/\\/g, "/")))
788
+ return undefined;
789
+ return (`vigiles: no project root β€” neither $CLAUDE_PROJECT_DIR nor the event's ` +
790
+ `\`cwd\` was set, and the event carries an ABSOLUTE file_path (${filePath}). ` +
791
+ `Repo-relative prefixes in this hook cannot match it, so the hook is ` +
792
+ `deciding on less than it looks like it is. Set CLAUDE_PROJECT_DIR.`);
793
+ }
381
794
  const tools = (...names) => ({
382
795
  tools: names,
383
796
  });
@@ -385,8 +798,16 @@ exports.tools = tools;
385
798
  function defineFileGate(p) {
386
799
  return { role: "gate", ...p };
387
800
  }
388
- /** Run a file-tool gate against a raw PreToolUse event (reads `file_path`). */
389
- function decideFileGate(hook, raw, ctx = {}) {
801
+ /**
802
+ * Run a file-tool gate against a raw PreToolUse event (reads `file_path`).
803
+ *
804
+ * `root` is the project root repo-relative prefixes resolve against; omitted, it
805
+ * falls back to the event's OWN `cwd` (a payload field β€” this stays pure and
806
+ * reads no environment). The CLI passes {@link projectRootOf} instead, which
807
+ * prefers `$CLAUDE_PROJECT_DIR`. Without either, an absolute `file_path` matches
808
+ * no relative prefix β€” see {@link pathView} for why that direction was chosen.
809
+ */
810
+ function decideFileGate(hook, raw, ctx = {}, root = typeof raw.cwd === "string" ? raw.cwd : undefined) {
390
811
  const t = raw.tool_name ?? "";
391
812
  if (!hook.match.tools.includes(t))
392
813
  return (0, exports.allow)();
@@ -396,7 +817,7 @@ function decideFileGate(hook, raw, ctx = {}) {
396
817
  return hook.decide({
397
818
  event: hook.on,
398
819
  tool: t,
399
- path: pathView(fp),
820
+ path: pathView(fp, root),
400
821
  ctx: ctx,
401
822
  });
402
823
  }
@@ -488,8 +909,13 @@ const defineReact = (p) => ({
488
909
  ...p,
489
910
  });
490
911
  exports.defineReact = defineReact;
491
- /** Run a react hook against a raw PostToolUse event β†’ the (classified) Reaction. */
492
- function runReact(hook, raw) {
912
+ /**
913
+ * Run a react hook against a raw PostToolUse event β†’ the (classified) Reaction.
914
+ *
915
+ * `root` behaves exactly as in {@link decideFileGate}: the event's own `cwd` by
916
+ * default, the CLI's {@link projectRootOf} when the runtime supplies one.
917
+ */
918
+ function runReact(hook, raw, root = typeof raw.cwd === "string" ? raw.cwd : undefined) {
493
919
  const t = raw.tool_name ?? "";
494
920
  if (!hook.match.tools.includes(t))
495
921
  return (0, exports.nothing)();
@@ -499,28 +925,59 @@ function runReact(hook, raw) {
499
925
  return hook.react({
500
926
  event: hook.on,
501
927
  tool: t,
502
- path: pathView(fp),
928
+ path: pathView(fp, root),
503
929
  response: responseView(raw.tool_response),
504
930
  });
505
931
  }
932
+ /**
933
+ * The file a hook program was LOADED from β€” the one coverage attribution in this
934
+ * codebase that needs no parsing at all.
935
+ *
936
+ * πŸ”΄ BY CONSTRUCTION, NOT BEST-EFFORT, and a reader should not treat this source
937
+ * as equivalent to the parsed one. `commandRefs` (coverage-probe.ts) infers what
938
+ * executed from an arbitrary command STRING β€” interpreter grammars, option
939
+ * bundles, shell short-circuits β€” and has been wrong in five review rounds
940
+ * because that grammar is unbounded. Here the path is the argument `loadHook`
941
+ * was called with and resolved itself. There is nothing to guess.
942
+ *
943
+ * A `WeakMap`, not a property on the hook: the object is the USER's, it is
944
+ * `export default`-ed from their module, and stamping a field on it would show
945
+ * up in their serialization and their equality checks. The map also lets the
946
+ * entry die with the hook.
947
+ *
948
+ * ⚠️ REMEMBERING IS NOT RECORDING. `loadHook(file)` calls this and nothing else β€”
949
+ * loading a hook to inspect its shape is not running it, and attributing a load
950
+ * would be the "an empty file counts" disease the execution tier exists to cure.
951
+ * The probe is emitted by `harness-assert.ts` at the moment the hook is
952
+ * EVALUATED, which is the only place both facts are in hand.
953
+ */
954
+ const HOOK_SOURCE = new WeakMap();
955
+ /** Record where a hook program was loaded from. Called by `loadHook` ONLY. */
956
+ function rememberHookSource(hook, file) {
957
+ HOOK_SOURCE.set(hook, file);
958
+ }
959
+ /** The file a hook was loaded from, or `undefined` for one built in-process. */
960
+ function hookSource(hook) {
961
+ return HOOK_SOURCE.get(hook);
962
+ }
506
963
  /**
507
964
  * Evaluate a compiled hook against a raw event, in-process, dispatching by role:
508
965
  * a gate β†’ its `Decision`, an inject β†’ the injected context text, a react β†’ its
509
966
  * (effect-classified) `Reaction`. Pure β€” no subprocess, no model. The ergonomic
510
967
  * base for testing a compiled hook (see `assertHookDenies` / `assertHookAllows`).
511
968
  */
512
- function runHookProgram(hook, event, ctx = {}) {
969
+ function runHookProgram(hook, event, ctx = {}, root = event.cwd) {
513
970
  const kind = dispatchKind(hook);
514
971
  switch (kind) {
515
972
  case "bash-gate":
516
973
  return {
517
974
  kind: "decision",
518
- decision: decideProgram(hook, event, ctx),
975
+ decision: decideProgram(hook, event, ctx, root),
519
976
  };
520
977
  case "file-gate":
521
978
  return {
522
979
  kind: "decision",
523
- decision: decideFileGate(hook, event, ctx),
980
+ decision: decideFileGate(hook, event, ctx, root),
524
981
  };
525
982
  case "prompt-gate":
526
983
  return {
@@ -539,7 +996,10 @@ function runHookProgram(hook, event, ctx = {}) {
539
996
  .additionalContext,
540
997
  };
541
998
  case "react":
542
- return { kind: "reaction", reaction: runReact(hook, event) };
999
+ return {
1000
+ kind: "reaction",
1001
+ reaction: runReact(hook, event, root),
1002
+ };
543
1003
  default:
544
1004
  return (0, hash_js_1.assertNever)(kind);
545
1005
  }
@@ -563,40 +1023,274 @@ function runHookProgram(hook, event, ctx = {}) {
563
1023
  // otherwise use. What the stamp buys is that a smuggled capability can never run
564
1024
  // SILENTLY, and that is unchanged.
565
1025
  // ---------------------------------------------------------------------------
566
- /** Compare two path references without node:path (core stays dependency-free). */
567
- function samePathRef(a, b) {
568
- const norm = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "");
569
- const [x, y] = [norm(a), norm(b)];
570
- return x === y || x.endsWith("/" + y) || y.endsWith("/" + x);
1026
+ /** A reference that already names a root β€” POSIX `/x` or Windows `C:/x`. */
1027
+ function isAbsoluteRef(ref) {
1028
+ return ref.startsWith("/") || /^[A-Za-z]:\//.test(ref);
571
1029
  }
572
- /** The basename of a path token, for matching `npx vigiles` / `./bin/vigiles`. */
1030
+ /**
1031
+ * Resolve a path reference against a root, without node:path (core stays
1032
+ * dependency-free). Mirrors `resolve(root, ref)`: an absolute ref wins, a
1033
+ * relative one hangs off the root, and `.` / `..` / `//` collapse.
1034
+ *
1035
+ * πŸ”΄ THIS REPLACED A SUFFIX COMPARISON, and the difference is SCOPE, not
1036
+ * spelling. The old helper accepted `a.endsWith("/" + b)`, so a write to
1037
+ * `/home/another-project/package.json` counted as a repair of THIS repo's
1038
+ * `package.json` β€” one wedged checkout handed out a write in every other
1039
+ * checkout on the disk. The tolerance that motivated the looseness is real (the
1040
+ * harness sends an absolute `file_path` while settings carry a relative one) and
1041
+ * survives untouched: both sides resolve against the same root and compare
1042
+ * whole, so `/repo/package.json` and `package.json` still match while a sibling
1043
+ * repo's never can.
1044
+ */
1045
+ function resolveRef(root, ref) {
1046
+ const slashes = (s) => s.replace(/\\/g, "/");
1047
+ const r = slashes(ref);
1048
+ const joined = isAbsoluteRef(r)
1049
+ ? r
1050
+ : `${slashes(root).replace(/\/+$/, "")}/${r}`;
1051
+ const out = [];
1052
+ for (const seg of joined.split("/")) {
1053
+ if (seg === "" || seg === ".")
1054
+ continue;
1055
+ if (seg === "..") {
1056
+ out.pop();
1057
+ continue;
1058
+ }
1059
+ out.push(seg);
1060
+ }
1061
+ // πŸ”΄ A UNC LEADER IS TWO SLASHES AND BOTH ARE LOAD-BEARING. Collapsing
1062
+ // `//server/share/x` to `/server/share/x` makes this function disagree with
1063
+ // `normalizePrefix`, which keeps the pair β€” so a UNC path and a UNC prefix
1064
+ // never compared equal, and an allowlist gate denied every valid edit on a
1065
+ // Windows network share while a react hook stayed silent. Two functions
1066
+ // normalising the SAME string differently is the defect class this PR keeps
1067
+ // finding; here it is inside one call.
1068
+ // ⚠️ AND THE PAIR IS KEPT ONLY WHEN THE ROOT SAYS THIS IS WINDOWS β€” the same
1069
+ // lesson as the case fold, relearned one commit later. Judged from the STRING,
1070
+ // `//repo/src/x.ts` looks like UNC; on Linux it is just `/repo/src/x.ts` with a
1071
+ // stutter, so preserving the pair unconditionally made it stop resolving
1072
+ // against a POSIX root and an allowlist gate denied a valid edit. Semantics
1073
+ // belong to the filesystem, and only the root knows which one that is.
1074
+ const unc = joined.startsWith("//") &&
1075
+ (WINDOWS_ROOT.test(root) || root.startsWith("//"));
1076
+ const leader = unc ? "//" : joined.startsWith("/") ? "/" : "";
1077
+ return leader + out.join("/");
1078
+ }
1079
+ /** The basename of a path token β€” the sidecar is keyed by the hook's basename. */
573
1080
  function basenameOf(token) {
574
1081
  const parts = token.replace(/\\/g, "/").split("/");
575
1082
  return parts[parts.length - 1] ?? token;
576
1083
  }
577
1084
  /**
578
- * True when this event IS the author repairing the hook β€” the only thing a
579
- * stale-stamp refusal must let through, or the repo wedges (see the note above):
1085
+ * The tool names a repair may arrive under: the ones MEASURED to write a file
1086
+ * and carry `file_path`. Everything else is refused, including a name this list
1087
+ * has never heard of.
580
1088
  *
581
- * - a Bash command that invokes `vigiles compile` (however it's launched β€”
582
- * `npx vigiles compile`, `pnpm exec vigiles compile`, `./node_modules/.bin/vigiles compile`),
583
- * which is what regenerates the stamp; or
584
- * - an edit/write whose target IS this hook's own source file, so a FILE gate
585
- * over the repo can still be fixed by editing the hook again.
1089
+ * πŸ”΄ THE ESCAPE USED TO IGNORE `tool_name` ENTIRELY, so a `Read` of
1090
+ * `package.json` was a "repair" β€” and if the wedged hook was registered for
1091
+ * `Read`, that read went through while the gate was refusing everything else. A
1092
+ * read repairs nothing, so it cannot be a repair. The door's whole justification
1093
+ * is "a file write executes nothing"; a predicate blind to which tool is running
1094
+ * cannot make that claim about anything.
586
1095
  *
587
- * AST-backed (`leafCommandsNormalized`), so it sees the invocation through a
588
- * compound command or a wrapper, exactly like every other matcher here.
1096
+ * ## How the list was decided, since an allow-list is the shape that has bitten
1097
+ *
1098
+ * Read off the LIVE harness's own tool schemas rather than from memory or a doc
1099
+ * page (measured 2026-08-12, this Claude Code build):
1100
+ *
1101
+ * ```
1102
+ * Write file_path β†’ writes β†’ ADMITTED
1103
+ * Edit file_path β†’ writes β†’ ADMITTED
1104
+ * MultiEdit file_path β†’ writes β†’ ADMITTED (absent from this
1105
+ * build; a vendored third-party
1106
+ * plugin still matches on it, so
1107
+ * other builds ship it)
1108
+ * Read file_path β†’ reads β†’ refused (the reported hole)
1109
+ * NotebookEdit notebook_path β†’ writes, but cannot reach this predicate
1110
+ * Glob / Grep pattern, path β†’ no `file_path` at all
1111
+ * Bash command β†’ no `file_path` at all
1112
+ * ```
1113
+ *
1114
+ * `NotebookEdit` is deliberately NOT listed. It writes, but its input field is
1115
+ * `notebook_path` β€” quoted from the live schema: *"notebook_path: The absolute
1116
+ * path to the Jupyter notebook file to edit"* β€” so it never carries the field
1117
+ * this predicate reads and listing it would be a fragment that can never
1118
+ * execute. If it ever grows `file_path`, this is a one-word change.
1119
+ *
1120
+ * ## What happens to an unrecognised name, and why that is not the wedge
1121
+ *
1122
+ * It is REFUSED. The other direction was weighed first, because "refuse
1123
+ * everything outside cwd" was rejected two rounds ago for making a wedged repo
1124
+ * unrecoverable β€” and the same objection does not land here. This list does not
1125
+ * have to be COMPLETE; it has to be NON-EMPTY at runtime. A new writing tool
1126
+ * appearing wedges nothing, because the author still repairs with `Write`. Only
1127
+ * the simultaneous disappearance of every name above could strand a repo, and a
1128
+ * release that deletes `Write` and `Edit` together has bigger problems.
1129
+ *
1130
+ * Admitting unknown names was the alternative, and it forfeits the one sentence
1131
+ * this door rests on: with an unrecognised tool we cannot say the call executes
1132
+ * nothing, so the exception would no longer be justified by the argument that
1133
+ * created it.
1134
+ */
1135
+ const REPAIR_TOOLS = ["Write", "Edit", "MultiEdit"];
1136
+ /**
1137
+ * The file a repair event targets, or `null` when this event is not a repair
1138
+ * shape at all β€” wrong tool, or no `file_path`. Shared by both doors so the
1139
+ * stamp escape and the load-path escape cannot drift apart (they already did
1140
+ * once: the suffix-match hole was fixed on one and found on the other).
589
1141
  */
590
- function isStampRepairEvent(event, hookFile) {
1142
+ function repairTargetOf(event) {
1143
+ const tool = event.tool_name;
1144
+ if (typeof tool !== "string" || !REPAIR_TOOLS.includes(tool))
1145
+ return null;
591
1146
  const filePath = event.tool_input?.file_path;
592
- if (typeof filePath === "string" && samePathRef(filePath, hookFile))
1147
+ return typeof filePath === "string" ? filePath : null;
1148
+ }
1149
+ /**
1150
+ * The stamp sidecar a hook's compile writes, as a repo-relative reference.
1151
+ * Mirrors `hookStampPath` in cli.ts (`.vigiles/hooks/<basename>.json`); kept as a
1152
+ * string here because core takes no `node:path`.
1153
+ */
1154
+ function stampSidecarRef(hookFile) {
1155
+ return `.vigiles/hooks/${basenameOf(hookFile)}.json`;
1156
+ }
1157
+ /**
1158
+ * True when this event IS the author repairing the hook β€” a FILE WRITE, and only
1159
+ * a file write.
1160
+ *
1161
+ * πŸ”΄ THIS USED TO ADMIT A BASH COMMAND, AND THAT WAS THE WHOLE PROBLEM. The
1162
+ * escape accepted `vigiles compile …`, and recognising a trusted ACTION from an
1163
+ * untrusted STRING produced five findings in a row, each fix correct and each
1164
+ * followed by another: `.some()` over the leaves β†’ the operand (`compile
1165
+ * /tmp/payload.spec.ts`, which `loadSpec` imports) β†’ the executable path
1166
+ * (`/tmp/vigiles compile`) β†’ the working directory (`cd /tmp/evil && vigiles
1167
+ * compile`). The degrees of freedom in a command string are unbounded β€” argv,
1168
+ * cwd, PATH, `node_modules` resolution, a hostile `.vigilesrc.json`, a shell
1169
+ * function β€” so no amount of parsing closes the class. Two more constraints
1170
+ * predict two more findings.
1171
+ *
1172
+ * ## Why the command was not needed at all β€” measured, not argued
1173
+ *
1174
+ * MEASURED 2026-08-12 against the real runtime, on a stale-stamp fixture:
1175
+ * writing `{}` into the stamp sidecar (or deleting it) makes
1176
+ * `verifyStampOrRefuse` return early, so the hook LOADS AND ENFORCES again β€”
1177
+ * `ls` passed and `git push --force` was still denied with the hook's own reason.
1178
+ * The repo is unwedged by a file write, and the gate is back on duty; the author
1179
+ * then runs `vigiles compile` through the NORMAL gate, needing no escape at all.
1180
+ *
1181
+ * MEASURED the same day on the load-wedge fixture: with `package.json` holding
1182
+ * conflict markers, `vigiles compile <hook>` exits 1 β€”
1183
+ * `Cannot load hook …: Invalid package config` β€” because compile has to load the
1184
+ * hook through the same broken resolver the runtime just failed on. The command
1185
+ * the escape existed to permit CANNOT repair that wedge. It was pure liability.
1186
+ *
1187
+ * So the Bash escape kept only a git whitelist β€” and a later round measured that
1188
+ * those run `.git/hooks/*` too and deleted them as well. NO command is an escape
1189
+ * now; the repair is what it always should have been: a write
1190
+ * ({@link isLoadPathRepairEvent}).
1191
+ *
1192
+ * ## What is accepted here
1193
+ *
1194
+ * A write whose target is the hook's own source, or its stamp sidecar
1195
+ * (`.vigiles/hooks/<basename>.json`). Both are repo-owned paths derived from the
1196
+ * file the runtime was invoked with, never from the event.
1197
+ *
1198
+ * πŸ”΄ SCOPE IS NOT THE SAME QUESTION AS EXECUTION. A previous round defended a
1199
+ * suffix comparison here on the ground that a write executes nothing β€” true, and
1200
+ * beside the point: `/home/another-project/.claude/hooks/guard.hook.mjs` ends
1201
+ * the same way as this repo's hook, so a wedge in one checkout granted a write
1202
+ * into another. Both sides now resolve against `repoRoot` and compare whole
1203
+ * ({@link resolveRef}); the absolute-vs-relative tolerance that suffix matching
1204
+ * was reached for is what resolution gives you properly.
1205
+ *
1206
+ * This grants nothing new: a Bash gate never gated file tools in the first place
1207
+ * (the 2026-08-10 incident was fixed by hand-editing JSON while every Bash
1208
+ * command was refused), and a file gate already let the hook's own source be
1209
+ * rewritten, which is strictly more powerful than clearing its stamp.
1210
+ */
1211
+ function isStampRepairEvent(event, hookFile, repoRoot) {
1212
+ const filePath = repairTargetOf(event);
1213
+ if (filePath === null)
1214
+ return false;
1215
+ const target = resolveRef(repoRoot, filePath);
1216
+ return (target === resolveRef(repoRoot, hookFile) ||
1217
+ target === resolveRef(repoRoot, stampSidecarRef(hookFile)));
1218
+ }
1219
+ /**
1220
+ * True when this event is a load-path REPAIR β€” a file write, and only a file
1221
+ * write, to one of the files whose breakage takes the runtime down.
1222
+ *
1223
+ * πŸ”΄ THE GIT ESCAPE USED TO LIVE HERE AND IT EXECUTED REPO CODE. `git merge
1224
+ * --abort` Β· `git rebase --abort` Β· `git checkout -- <path>` were admitted on the
1225
+ * reasoning that they "only move the tree to states git already holds; none
1226
+ * executes a line of repo code". MEASURED against git 2.43.0 with hooks installed
1227
+ * in `.git/hooks/`, and the reasoning is false for ALL THREE:
1228
+ *
1229
+ * git checkout -- f.txt β†’ post-checkout (args `<sha> <sha> 0`)
1230
+ * git merge --abort β†’ reference-transaction (prepared, committed)
1231
+ * git rebase --abort β†’ reference-transaction (Γ—4) + post-checkout
1232
+ *
1233
+ * `reference-transaction` fires on ANY ref update, and every one of these updates
1234
+ * a ref. `.git/hooks/*` is writable by exactly the actor this door assumes β€”
1235
+ * whoever could wedge the repo in the first place β€” so the whitelist was an
1236
+ * arbitrary-execution path standing open precisely while the gate enforced
1237
+ * nothing. Same shape as the `vigiles compile` escape one layer down: a command
1238
+ * believed inert because of what it MEANS rather than what it DOES.
1239
+ *
1240
+ * ## Why the replacement is a write, not a safer command
1241
+ *
1242
+ * `git -c core.hooksPath=/nonexistent …` does suppress all three (measured: no
1243
+ * hook ran for any of them). It was rejected anyway. It puts free-form structure
1244
+ * back into the accepted string β€” the exact thing five findings on the compile
1245
+ * door came from β€” and it buys nothing, because MEASURED on the load-wedge
1246
+ * fixture a plain file write already restores the gate to ENFORCING:
1247
+ *
1248
+ * wedged : `ls` refused
1249
+ * after Edit : `ls` allowed, `git push --force` DENIED by the hook's own reason
1250
+ *
1251
+ * You do not need to finish recovering, only to stop being wedged. Once the hook
1252
+ * loads, the gate decides normally and `git merge --abort` is an ordinary allowed
1253
+ * command β€” through the gate, not around it.
1254
+ *
1255
+ * ## What is accepted
1256
+ *
1257
+ * A write to the hook's own source, its stamp sidecar, or one of
1258
+ * {@link HARNESS_CONFIG_FILES}. That set is COMPLETE for a compiled hook rather
1259
+ * than a guess: `checkHookImports` rejects every import but `vigiles/hook`, so the
1260
+ * load path is the hook file plus the config that resolves that specifier β€”
1261
+ * nothing else can break it.
1262
+ *
1263
+ * A file write executes nothing, which is the property no command on this door
1264
+ * ever had.
1265
+ *
1266
+ * ## …IN THIS REPOSITORY, which is a separate question
1267
+ *
1268
+ * "A write executes nothing" answers the EXECUTION objection and says nothing
1269
+ * about SCOPE. Under the suffix comparison this used to do, a write to
1270
+ * `/home/another-project/package.json` was accepted while THIS repo was wedged β€”
1271
+ * a file that cannot repair the failure, in a checkout the wedged session has no
1272
+ * business touching. Every candidate is now resolved against
1273
+ * {@link HookRepoPaths.root} and compared whole against the paths the runtime
1274
+ * itself derived ({@link HookRepoPaths.loadPathFiles}, plus this root's
1275
+ * {@link HARNESS_CONFIG_FILES} so the floor case can never be argued away).
1276
+ *
1277
+ * ⚠️ THE HOOK'S OWN PATH DOES NOT GIVE THE ROOT, so the caller passes it. A hook
1278
+ * lives at `.claude/hooks/…`, `.vigiles/hooks/…`, a plugin subdirectory or the
1279
+ * repo root itself; there is no fixed depth to walk up, and finding a `.git`
1280
+ * marker would mean reading disk, which core does not do. Nor would a
1281
+ * git-derived root be RIGHT: `verifyStampOrRefuse` reads the hook and its stamp
1282
+ * sidecar via `process.cwd()`, so any other root would accept writes to files
1283
+ * the runtime does not read β€” repairs that do not repair. The root is therefore
1284
+ * the one the rest of the runtime already uses, and it is passed in rather than
1285
+ * invented here.
1286
+ */
1287
+ function isLoadPathRepairEvent(event, hookFile, repo) {
1288
+ if (isStampRepairEvent(event, hookFile, repo.root))
593
1289
  return true;
594
- const command = event.tool_input?.command;
595
- if (typeof command !== "string")
1290
+ const filePath = repairTargetOf(event);
1291
+ if (filePath === null)
596
1292
  return false;
597
- return (0, bash_effects_js_1.leafCommandsNormalized)(command).some((leaf) => {
598
- const i = leaf.argv.findIndex((a) => basenameOf(a) === "vigiles");
599
- return i !== -1 && leaf.argv.slice(i + 1).includes("compile");
600
- });
1293
+ const target = resolveRef(repo.root, filePath);
1294
+ return [...merge_conflict_js_1.HARNESS_CONFIG_FILES, ...repo.loadPathFiles].some((f) => target === resolveRef(repo.root, f));
601
1295
  }
602
1296
  //# sourceMappingURL=hook-program.js.map