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.
- package/dist/adapters/claude-code/layout.js +3 -0
- package/dist/adapters/claude-code/run-scripts.d.ts +21 -9
- package/dist/adapters/claude-code/run-scripts.js +33 -22
- package/dist/adapters/codex/driver.js +3 -1
- package/dist/adapters/codex/eval.js +5 -0
- package/dist/audit-score.js +32 -6
- package/dist/check-count.d.ts +62 -3
- package/dist/check-count.js +123 -5
- package/dist/check.d.ts +55 -0
- package/dist/check.js +91 -0
- package/dist/cli.js +456 -54
- package/dist/core/bash-effects.d.ts +41 -0
- package/dist/core/bash-effects.js +278 -0
- package/dist/core/foreign-runner.d.ts +185 -0
- package/dist/core/foreign-runner.js +228 -0
- package/dist/core/harness-driver.d.ts +18 -1
- package/dist/core/hook-program.d.ts +319 -20
- package/dist/core/hook-program.js +743 -49
- package/dist/core/layout.d.ts +13 -0
- package/dist/core/lethal-trifecta.d.ts +93 -0
- package/dist/core/lethal-trifecta.js +409 -65
- package/dist/core/markdown.d.ts +32 -0
- package/dist/core/markdown.js +36 -0
- package/dist/core/merge-conflict.d.ts +50 -0
- package/dist/core/merge-conflict.js +84 -0
- package/dist/core/test-file-ext.d.ts +80 -0
- package/dist/core/test-file-ext.js +90 -0
- package/dist/core/types.d.ts +11 -0
- package/dist/coverage-artifact.d.ts +383 -0
- package/dist/coverage-artifact.js +586 -0
- package/dist/coverage-evidence.d.ts +87 -79
- package/dist/coverage-evidence.js +212 -211
- package/dist/coverage-probe.d.ts +125 -0
- package/dist/coverage-probe.js +700 -0
- package/dist/doc-commands.d.ts +119 -0
- package/dist/doc-commands.js +158 -0
- package/dist/eval-lock.d.ts +9 -0
- package/dist/eval-lock.js +14 -2
- package/dist/eval.js +28 -0
- package/dist/fs-walk.d.ts +80 -0
- package/dist/fs-walk.js +148 -0
- package/dist/harness-assert.js +42 -3
- package/dist/harness-test.js +31 -2
- package/dist/judge.js +4 -0
- package/dist/leaderboard.d.ts +16 -1
- package/dist/leaderboard.js +19 -2
- package/dist/load-hook.js +6 -1
- package/dist/mock-model.d.ts +58 -4
- package/dist/mock-model.js +133 -5
- package/dist/observe.d.ts +1 -1
- package/dist/observe.js +44 -1
- package/dist/plugin-loader.js +52 -13
- package/dist/run-hook.d.ts +58 -0
- package/dist/run-hook.js +70 -0
- package/dist/run-script.js +75 -0
- package/dist/scaffold-test.d.ts +3 -0
- package/dist/scaffold-test.js +10 -5
- package/dist/scan-behavioral.js +4 -0
- package/dist/scan-core.d.ts +22 -0
- package/dist/scan-core.js +46 -14
- package/dist/scan-files.js +19 -2
- package/dist/scan.d.ts +22 -5
- package/dist/scan.js +67 -13
- package/dist/skill-contract.d.ts +46 -0
- package/dist/skill-contract.js +201 -0
- package/dist/skill-refs.d.ts +67 -0
- package/dist/skill-refs.js +117 -0
- package/dist/test-coverage-files.d.ts +7 -1
- package/dist/test-coverage-files.js +66 -55
- package/dist/test-coverage.d.ts +155 -19
- package/dist/test-coverage.js +430 -91
- package/dist/testing.d.ts +8 -2
- package/dist/testing.js +19 -1
- package/dist/trigger-containment.d.ts +85 -0
- package/dist/trigger-containment.js +125 -0
- package/dist/ts-runner-caps.d.ts +16 -0
- package/dist/ts-runner-caps.js +43 -0
- package/dist/unit.d.ts +2 -2
- package/dist/unit.js +2 -1
- package/hooks/eval-lock-nudge.sh +14 -6
- package/package.json +2 -2
- 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
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
/**
|
|
216
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
/**
|
|
389
|
-
|
|
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
|
-
/**
|
|
492
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
567
|
-
function
|
|
568
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
579
|
-
*
|
|
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
|
-
*
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
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
|
-
*
|
|
588
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
595
|
-
if (
|
|
1290
|
+
const filePath = repairTargetOf(event);
|
|
1291
|
+
if (filePath === null)
|
|
596
1292
|
return false;
|
|
597
|
-
|
|
598
|
-
|
|
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
|