agent-sanitizer 2.8.0 → 2.9.1
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/claude-hooks/lib/control-plane.mjs +39 -14
- package/claude-hooks/lib/hook-io.mjs +262 -3
- package/claude-hooks/plugin-hooks.mjs +1 -0
- package/claude-hooks/pretooluse-sanitize.mjs +136 -26
- package/claude-hooks/sanitize-output.mjs +29 -14
- package/claude-hooks/sanitize-user-prompt.mjs +95 -34
- package/claude-hooks/scan-invisible-chars.mjs +56 -7
- package/package.json +29 -1
- package/types/claude-hooks/lib/hook-io.d.mts +119 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +71 -3
- package/types/claude-hooks/sanitize-output.d.mts +22 -4
- package/types/claude-hooks/sanitize-user-prompt.d.mts +27 -2
- package/types/claude-hooks/scan-invisible-chars.d.mts +3 -3
|
@@ -7,7 +7,16 @@
|
|
|
7
7
|
* transport rule (nativeStdout), and the shared judge-CLI transport
|
|
8
8
|
* (runJudgeCli).
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
awaitLazyDependency,
|
|
12
|
+
errMessage,
|
|
13
|
+
hookgateMarkerPath,
|
|
14
|
+
lazyImport,
|
|
15
|
+
markerIsTrusted,
|
|
16
|
+
missingPackageError,
|
|
17
|
+
probeSetupAlive,
|
|
18
|
+
readStdinJson,
|
|
19
|
+
} from "./hook-io.mjs";
|
|
11
20
|
|
|
12
21
|
// Loaded via a *caught* dynamic import — never a bare static `import … from`.
|
|
13
22
|
// A static npm import resolves before any try/catch, so a missing node_modules
|
|
@@ -26,20 +35,36 @@ let EventKind;
|
|
|
26
35
|
/* c8 ignore start -- module-load boundary: the real import resolves in every
|
|
27
36
|
in-process test and spawned CLI run, and a missing node_modules can't be
|
|
28
37
|
simulated in-process, so this glue's failure arm is unobservable here. The
|
|
29
|
-
observable
|
|
38
|
+
observable logic lives in awaitLazyDependency, unit-tested directly. */
|
|
30
39
|
// Stryker disable all
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
const marker = hookgateMarkerPath();
|
|
41
|
+
const loaded = await awaitLazyDependency({
|
|
42
|
+
tryImport: async () => {
|
|
43
|
+
// lazyImport is the shared caught npm import (see hook-io): it returns the
|
|
44
|
+
// module on success and {} on a failed load, so a missing binding is the
|
|
45
|
+
// null signal the poll loop waits on — no bare import()/try-catch here.
|
|
46
|
+
const { claudeAdapter: adapter } =
|
|
47
|
+
/** @type {Partial<typeof import("agent-control-plane-core/claude")>} */ (
|
|
48
|
+
await lazyImport("agent-control-plane-core/claude")
|
|
49
|
+
);
|
|
50
|
+
const { Decision: decision, EventKind: eventKind } =
|
|
51
|
+
/** @type {Partial<typeof import("agent-control-plane-core")>} */ (
|
|
52
|
+
await lazyImport("agent-control-plane-core")
|
|
53
|
+
);
|
|
54
|
+
if (!adapter || !decision || !eventKind) return null;
|
|
55
|
+
return { claudeAdapter: adapter, Decision: decision, EventKind: eventKind };
|
|
56
|
+
},
|
|
57
|
+
markerPresent: () => markerIsTrusted(marker),
|
|
58
|
+
setupAlive: () => probeSetupAlive(marker),
|
|
59
|
+
});
|
|
60
|
+
if (loaded) {
|
|
61
|
+
const bound =
|
|
62
|
+
/** @type {{ claudeAdapter: typeof claudeAdapter, Decision: typeof Decision, EventKind: typeof EventKind }} */ (
|
|
63
|
+
loaded
|
|
39
64
|
);
|
|
40
|
-
claudeAdapter =
|
|
41
|
-
Decision =
|
|
42
|
-
EventKind =
|
|
65
|
+
claudeAdapter = bound.claudeAdapter;
|
|
66
|
+
Decision = bound.Decision;
|
|
67
|
+
EventKind = bound.EventKind;
|
|
43
68
|
}
|
|
44
69
|
// Stryker restore all
|
|
45
70
|
/* c8 ignore stop */
|
|
@@ -58,7 +83,7 @@ let EventKind;
|
|
|
58
83
|
export function controlPlane(overrides = {}) {
|
|
59
84
|
const bindings = { claudeAdapter, Decision, EventKind, ...overrides };
|
|
60
85
|
if (!bindings.claudeAdapter || !bindings.Decision || !bindings.EventKind)
|
|
61
|
-
throw
|
|
86
|
+
throw missingPackageError("agent-control-plane-core");
|
|
62
87
|
return /** @type {ReturnType<typeof controlPlane>} */ (bindings);
|
|
63
88
|
}
|
|
64
89
|
|
|
@@ -4,10 +4,12 @@ import {
|
|
|
4
4
|
openSync,
|
|
5
5
|
closeSync,
|
|
6
6
|
lstatSync,
|
|
7
|
+
readFileSync,
|
|
7
8
|
unlinkSync,
|
|
8
9
|
writeFileSync,
|
|
9
10
|
} from "node:fs";
|
|
10
11
|
import { userInfo } from "node:os";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
11
13
|
import { pathToFileURL } from "node:url";
|
|
12
14
|
|
|
13
15
|
let cliEntryClaimed = false;
|
|
@@ -154,6 +156,14 @@ export function registeredLazyModule(specifier) {
|
|
|
154
156
|
return registeredLazyModules[specifier];
|
|
155
157
|
}
|
|
156
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Last load error per specifier, recorded when {@link lazyImport} swallows a
|
|
161
|
+
* failed import. Read by {@link lazyImportErrorFor} so a fail-closed hook can
|
|
162
|
+
* say WHY its dependency is absent instead of a bare "unavailable".
|
|
163
|
+
* @type {Map<string, unknown>}
|
|
164
|
+
*/
|
|
165
|
+
const lazyImportErrors = new Map();
|
|
166
|
+
|
|
157
167
|
/**
|
|
158
168
|
* Dynamic-import `specifier`, yielding `{}` when the module cannot be loaded.
|
|
159
169
|
* Hooks bind their npm packages through this instead of a bare static import: a
|
|
@@ -169,14 +179,121 @@ export function registeredLazyModule(specifier) {
|
|
|
169
179
|
*/
|
|
170
180
|
export async function lazyImport(specifier) {
|
|
171
181
|
const registered = registeredLazyModules[specifier];
|
|
172
|
-
if (registered)
|
|
182
|
+
if (registered) {
|
|
183
|
+
lazyImportErrors.delete(specifier);
|
|
184
|
+
return registered;
|
|
185
|
+
}
|
|
173
186
|
try {
|
|
174
|
-
|
|
175
|
-
|
|
187
|
+
const loaded = await import(specifier);
|
|
188
|
+
lazyImportErrors.delete(specifier);
|
|
189
|
+
return loaded;
|
|
190
|
+
} catch (err) {
|
|
191
|
+
// Delete before set: a Map keeps a re-set key at its ORIGINAL insertion
|
|
192
|
+
// position, and both readers below are recency-ordered — so re-recording in
|
|
193
|
+
// place would let a stale first failure outrank the one that just happened.
|
|
194
|
+
lazyImportErrors.delete(specifier);
|
|
195
|
+
lazyImportErrors.set(specifier, err);
|
|
176
196
|
return {};
|
|
177
197
|
}
|
|
178
198
|
}
|
|
179
199
|
|
|
200
|
+
/**
|
|
201
|
+
* The most recently recorded load error for `pkg` under any of its specifiers —
|
|
202
|
+
* the bare package or a subpath export (`pkg/output`, `pkg/invisible`) — or
|
|
203
|
+
* undefined when none is recorded. Hooks import a package through several
|
|
204
|
+
* subpaths; any one of them names why the package is absent, and the newest
|
|
205
|
+
* record reflects the current failure when they differ.
|
|
206
|
+
* @param {string} pkg
|
|
207
|
+
* @returns {unknown}
|
|
208
|
+
*/
|
|
209
|
+
export function lazyImportErrorFor(pkg) {
|
|
210
|
+
for (const [specifier, err] of [...lazyImportErrors].reverse())
|
|
211
|
+
if (specifier === pkg || specifier.startsWith(`${pkg}/`)) return err;
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Package names (never relative-path specifiers) with a recorded load error,
|
|
217
|
+
* newest first — so a fail-closed reason can name whichever dependency actually
|
|
218
|
+
* failed instead of consulting a hardcoded package list.
|
|
219
|
+
* @returns {string[]}
|
|
220
|
+
*/
|
|
221
|
+
export function failedLazyPackages() {
|
|
222
|
+
const pkgs = new Set();
|
|
223
|
+
for (const specifier of [...lazyImportErrors.keys()].reverse()) {
|
|
224
|
+
// Only bare package names: relative/absolute paths and URL specifiers
|
|
225
|
+
// (file:, node:) are not npm packages a reinstall could restore.
|
|
226
|
+
if (!/^[\w@]/.test(specifier) || specifier.includes(":")) continue;
|
|
227
|
+
const parts = specifier.split("/");
|
|
228
|
+
pkgs.add(
|
|
229
|
+
specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0],
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return [...pkgs];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The remedy {@link missingPackageMessage} states when the host does not supply
|
|
237
|
+
* one of its own. A host whose install has a specific entry point (a setup
|
|
238
|
+
* script, a devcontainer rebuild) passes that instead, so the reason names the
|
|
239
|
+
* command the reader should actually run.
|
|
240
|
+
*/
|
|
241
|
+
export const DEFAULT_MISSING_PACKAGE_REMEDY =
|
|
242
|
+
"reinstall the hook dependencies (pnpm install) and retry.";
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The fail-closed reason for a package a hook could not load: the recorded
|
|
246
|
+
* loader error plus the remedy. The cause is scrubbed (it is spliced into
|
|
247
|
+
* reasons shown to user and model) and its cap is COMPUTED so that
|
|
248
|
+
* prefix + cause + remedy always fits the downstream 300-char safeErrMessage
|
|
249
|
+
* re-scrub — the remedy can never be truncated off, whatever the package name.
|
|
250
|
+
* A remedy that alone exceeds that budget (roughly 260 characters) leaves the
|
|
251
|
+
* cause nothing to spend and still overruns; keep host remedies to a sentence.
|
|
252
|
+
* @param {string} pkg
|
|
253
|
+
* @param {unknown} [err]
|
|
254
|
+
* @param {string} [remedy]
|
|
255
|
+
* @returns {string}
|
|
256
|
+
*/
|
|
257
|
+
export function missingPackageMessage(
|
|
258
|
+
pkg,
|
|
259
|
+
err = lazyImportErrorFor(pkg),
|
|
260
|
+
remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
261
|
+
) {
|
|
262
|
+
const prefix = `${pkg} is unavailable: `;
|
|
263
|
+
// 2 for the "; " joiner; 12 for safeErrMessage's own "…[truncated]" marker,
|
|
264
|
+
// which lands past its cap when the cause is cut. Clamped at 0 because the
|
|
265
|
+
// remedy is host text: a long one drives the budget negative, and
|
|
266
|
+
// safeErrMessage does not clamp — `slice(0, -n)` trims from the END, returning
|
|
267
|
+
// nearly the whole cause and pushing the joined message past 300, so the
|
|
268
|
+
// downstream re-scrub cuts the remedy off. At 0 the cause degrades to the
|
|
269
|
+
// truncation marker and the remedy always survives.
|
|
270
|
+
const causeCap = Math.max(0, 300 - prefix.length - remedy.length - 2 - 12);
|
|
271
|
+
const cause =
|
|
272
|
+
err === undefined
|
|
273
|
+
? "no load error recorded — the package likely loaded but lacks an expected export (version skew)"
|
|
274
|
+
: safeErrMessage(err, causeCap);
|
|
275
|
+
return `${prefix}${cause}; ${remedy}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* {@link missingPackageMessage} as a throwable, tagged `code: "DEP_UNAVAILABLE"`
|
|
280
|
+
* so downstream reason-builders can recognize it structurally and not append a
|
|
281
|
+
* second copy of the same cause.
|
|
282
|
+
* @param {string} pkg
|
|
283
|
+
* @param {unknown} [err]
|
|
284
|
+
* @param {string} [remedy]
|
|
285
|
+
* @returns {Error}
|
|
286
|
+
*/
|
|
287
|
+
export function missingPackageError(
|
|
288
|
+
pkg,
|
|
289
|
+
err = lazyImportErrorFor(pkg),
|
|
290
|
+
remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
291
|
+
) {
|
|
292
|
+
return Object.assign(new Error(missingPackageMessage(pkg, err, remedy)), {
|
|
293
|
+
code: "DEP_UNAVAILABLE",
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
180
297
|
/**
|
|
181
298
|
* A monotonic wall-clock budget shared across one hook run's downstream blocking
|
|
182
299
|
* calls. `remainingMs()` returns the milliseconds left until the budget is spent
|
|
@@ -274,6 +391,148 @@ export function emitHookResponse(hookEventName, fields) {
|
|
|
274
391
|
);
|
|
275
392
|
}
|
|
276
393
|
|
|
394
|
+
/** The marker filename stem; the project directory is appended to it. */
|
|
395
|
+
const HOOKGATE_MARKER_STEM = "agent-sanitizer-hookgate-inflight-";
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Path of the cold-start in-flight marker a host's setup script writes
|
|
399
|
+
* SYNCHRONOUSLY before it starts installing deps (its own PID as the contents)
|
|
400
|
+
* and removes once the hook dependencies are provisioned. A hook that fires
|
|
401
|
+
* before setup finishes finds the marker and WAITS for its dependency rather
|
|
402
|
+
* than failing closed on it — so the first turn is merely delayed, never
|
|
403
|
+
* blocked, for as long as setup is still alive (the PID lets the hook tell a
|
|
404
|
+
* live install from a stale marker left by a killed setup). Derived purely from
|
|
405
|
+
* the raw CLAUDE_PROJECT_DIR the harness sets for both processes (no
|
|
406
|
+
* canonicalization — the two must produce byte-identical paths), so no env has
|
|
407
|
+
* to propagate from setup to the hook. Null when CLAUDE_PROJECT_DIR is unset (no
|
|
408
|
+
* setup ran → nothing to wait on).
|
|
409
|
+
* @param {string | undefined} [projectDir]
|
|
410
|
+
* @param {string | undefined} [runtimeDir]
|
|
411
|
+
* @returns {string | null}
|
|
412
|
+
*/
|
|
413
|
+
export function hookgateMarkerPath(
|
|
414
|
+
projectDir = process.env.CLAUDE_PROJECT_DIR,
|
|
415
|
+
runtimeDir = process.env.XDG_RUNTIME_DIR,
|
|
416
|
+
) {
|
|
417
|
+
if (!projectDir) return null;
|
|
418
|
+
// Prefer the per-user, mode-0700 runtime dir when the harness gives an
|
|
419
|
+
// absolute one; else the world-writable /tmp, where markerIsTrusted() — not
|
|
420
|
+
// the path — defends against a squatted marker.
|
|
421
|
+
const base = runtimeDir && runtimeDir.startsWith("/") ? runtimeDir : "/tmp";
|
|
422
|
+
// The flattened dir is for a human reading `ls /tmp`; the digest of the RAW
|
|
423
|
+
// dir is the identity. Flattening alone is lossy — /work/a-b, /work/a_b and
|
|
424
|
+
// "/work/a b" all collapse to one name — and two such projects on one machine
|
|
425
|
+
// would then share a marker: B's hook waits out A's install for a dependency A
|
|
426
|
+
// is not installing, and A clearing the marker aborts B's legitimate wait. Both
|
|
427
|
+
// directions are silent. A setup script reproduces the digest with sha256sum.
|
|
428
|
+
const digest = createHash("sha256")
|
|
429
|
+
.update(projectDir)
|
|
430
|
+
.digest("hex")
|
|
431
|
+
.slice(0, 8);
|
|
432
|
+
const flattened = projectDir.replace(/[^A-Za-z0-9]/g, "_");
|
|
433
|
+
return `${base}/${HOOKGATE_MARKER_STEM}${flattened}-${digest}`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Is the setup process that wrote `markerPath` still alive? `process.kill(pid, 0)`
|
|
438
|
+
* probes liveness without signalling: it throws ESRCH once the process is gone (a
|
|
439
|
+
* killed setup → stale marker, so stop waiting) and EPERM when it exists but isn't
|
|
440
|
+
* ours (still alive). An unreadable / not-yet-written marker is treated as alive —
|
|
441
|
+
* favouring a brief wait over a premature give-up during setup's write race. A null
|
|
442
|
+
* markerPath (no project dir → no setup to wait on) reads as alive so the caller's
|
|
443
|
+
* own grace/ceiling bound governs.
|
|
444
|
+
* @param {string | null} markerPath
|
|
445
|
+
* @returns {boolean}
|
|
446
|
+
*/
|
|
447
|
+
export function probeSetupAlive(markerPath) {
|
|
448
|
+
if (markerPath === null) return true;
|
|
449
|
+
let pid;
|
|
450
|
+
try {
|
|
451
|
+
pid = parseInt(readFileSync(markerPath, "utf8"), 10);
|
|
452
|
+
} catch {
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
if (!Number.isInteger(pid) || pid <= 0) return true;
|
|
456
|
+
try {
|
|
457
|
+
process.kill(pid, 0);
|
|
458
|
+
return true;
|
|
459
|
+
} catch (err) {
|
|
460
|
+
return /** @type {NodeJS.ErrnoException} */ (err).code === "EPERM";
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Resolve a lazily-loaded dependency, blocking through the cold-start window while
|
|
466
|
+
* setup is still installing it. Returns the loaded value, or null once it gives up
|
|
467
|
+
* (the caller leaves its bindings undefined so the hook fails closed). It waits for
|
|
468
|
+
* as long as setup is genuinely alive, so a slow install is never cut off; the only
|
|
469
|
+
* bound on that wait is a backstop ceiling that stays under the hook's harness
|
|
470
|
+
* timeout — a hook killed for running over is a fail-OPEN, the opposite of what a
|
|
471
|
+
* gate wants. The give-up cases are the honest ones (setup finished/died without the
|
|
472
|
+
* dep, or no setup at all), so a genuinely-absent dep fails closed fast, never after
|
|
473
|
+
* a long block:
|
|
474
|
+
* - import succeeds → return immediately (warm session: no wait).
|
|
475
|
+
* - marker present AND setup alive → setup is working; wait it out (ceilingMs is a
|
|
476
|
+
* backstop only, for a hung-but-alive setup).
|
|
477
|
+
* - was installing, now not (marker cleared, or a stale marker from a killed setup)
|
|
478
|
+
* → settleMs grace for a just-orphaned install to
|
|
479
|
+
* land, then give up: the dep is absent.
|
|
480
|
+
* - no live setup ever seen → wait only graceMs (tolerating setup not having
|
|
481
|
+
* written the marker yet), then give up.
|
|
482
|
+
* @param {{
|
|
483
|
+
* tryImport: () => Promise<object | null>,
|
|
484
|
+
* markerPresent: () => boolean,
|
|
485
|
+
* setupAlive: () => boolean,
|
|
486
|
+
* now?: () => number,
|
|
487
|
+
* sleep?: (ms: number) => Promise<void>,
|
|
488
|
+
* graceMs?: number,
|
|
489
|
+
* settleMs?: number,
|
|
490
|
+
* ceilingMs?: number,
|
|
491
|
+
* intervalMs?: number,
|
|
492
|
+
* }} deps
|
|
493
|
+
* @returns {Promise<object | null>}
|
|
494
|
+
*/
|
|
495
|
+
export async function awaitLazyDependency({
|
|
496
|
+
tryImport,
|
|
497
|
+
markerPresent,
|
|
498
|
+
setupAlive,
|
|
499
|
+
now = () => Date.now(),
|
|
500
|
+
sleep = (ms) =>
|
|
501
|
+
new Promise((resolve) => {
|
|
502
|
+
setTimeout(resolve, ms);
|
|
503
|
+
}),
|
|
504
|
+
graceMs = 5000,
|
|
505
|
+
settleMs = 1000,
|
|
506
|
+
ceilingMs = 900000,
|
|
507
|
+
intervalMs = 250,
|
|
508
|
+
}) {
|
|
509
|
+
const start = now();
|
|
510
|
+
let sawInstalling = false;
|
|
511
|
+
let enteredDone = false;
|
|
512
|
+
let doneAt = 0;
|
|
513
|
+
for (;;) {
|
|
514
|
+
const bindings = await tryImport();
|
|
515
|
+
if (bindings) return bindings;
|
|
516
|
+
const installing = markerPresent() && setupAlive();
|
|
517
|
+
let giveUp;
|
|
518
|
+
if (installing) {
|
|
519
|
+
sawInstalling = true;
|
|
520
|
+
enteredDone = false;
|
|
521
|
+
giveUp = now() - start > ceilingMs;
|
|
522
|
+
} else if (sawInstalling) {
|
|
523
|
+
if (!enteredDone) {
|
|
524
|
+
enteredDone = true;
|
|
525
|
+
doneAt = now();
|
|
526
|
+
}
|
|
527
|
+
giveUp = now() - doneAt > settleMs;
|
|
528
|
+
} else {
|
|
529
|
+
giveUp = now() - start > graceMs;
|
|
530
|
+
}
|
|
531
|
+
if (giveUp) return null;
|
|
532
|
+
await sleep(intervalMs);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
277
536
|
/**
|
|
278
537
|
* Is the file at `path` one WE wrote — a regular file owned by this uid — rather
|
|
279
538
|
* than a squat? These markers live at predictable, world-visible $TMPDIR paths, so
|
|
@@ -34,6 +34,7 @@ const LAZY_LOADERS = {
|
|
|
34
34
|
"agent-sanitizer/confusables": () => import("agent-sanitizer/confusables"),
|
|
35
35
|
"agent-sanitizer/invisible": () => import("agent-sanitizer/invisible"),
|
|
36
36
|
"agent-sanitizer/output": () => import("agent-sanitizer/output"),
|
|
37
|
+
"agent-sanitizer/prompt": () => import("agent-sanitizer/prompt"),
|
|
37
38
|
"agent-sanitizer/rehydrate": () => import("agent-sanitizer/rehydrate"),
|
|
38
39
|
"namespace-guard": () => import("namespace-guard"),
|
|
39
40
|
};
|
|
@@ -27,6 +27,10 @@ import { readFileSync } from "node:fs";
|
|
|
27
27
|
import {
|
|
28
28
|
isMain,
|
|
29
29
|
lazyImport,
|
|
30
|
+
lazyImportErrorFor,
|
|
31
|
+
failedLazyPackages,
|
|
32
|
+
missingPackageMessage,
|
|
33
|
+
DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
30
34
|
registeredLazyModule,
|
|
31
35
|
emitHookResponse,
|
|
32
36
|
safeErrMessage,
|
|
@@ -50,6 +54,39 @@ import { trace, TraceEvent } from "./lib/trace.mjs";
|
|
|
50
54
|
|
|
51
55
|
const HOOK_NAME = "pretooluse-sanitize";
|
|
52
56
|
|
|
57
|
+
/**
|
|
58
|
+
* A host-supplied deny gate: given the PreToolUse input, the reason this call
|
|
59
|
+
* must be blocked, or null to let the pipeline continue. Hosts use these for
|
|
60
|
+
* policy the package has no view of (a required workflow step, a project-local
|
|
61
|
+
* rule); the package ships none.
|
|
62
|
+
* @typedef {(input: { tool_name: string | null, tool_input: any, session_id?: string })
|
|
63
|
+
* => string | null | undefined} HostGate
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The reasons this hook emits, as a table a host overrides. A host that knows
|
|
68
|
+
* which of ITS files wires the adapter, and what a reader should do about a
|
|
69
|
+
* failure, can say so — the package cannot, since it has no idea where it is
|
|
70
|
+
* installed.
|
|
71
|
+
* @type {Readonly<{
|
|
72
|
+
* unknownEvent: string,
|
|
73
|
+
* failed: (cause: string) => string,
|
|
74
|
+
* unparsable: (cause: string) => string,
|
|
75
|
+
* remedy: string,
|
|
76
|
+
* }>}
|
|
77
|
+
*/
|
|
78
|
+
export const PRE_TOOL_USE_MESSAGES = Object.freeze({
|
|
79
|
+
unknownEvent:
|
|
80
|
+
"PreToolUse sanitization blocked (fail-closed): unrecognized hook payload.",
|
|
81
|
+
failed: (cause) => `PreToolUse sanitization failed (fail-closed): ${cause}`,
|
|
82
|
+
unparsable: (cause) => `PreToolUse input unparsable (fail-closed): ${cause}`,
|
|
83
|
+
// What a reader should run when a dependency is what is missing. It rides in
|
|
84
|
+
// this table rather than a separate argument because it is host text exactly
|
|
85
|
+
// like the reasons above, and one channel means a host cannot supply its
|
|
86
|
+
// wording in one place and forget it in the other.
|
|
87
|
+
remedy: DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
88
|
+
});
|
|
89
|
+
|
|
53
90
|
// Layers 2 & 4 come from the agent-sanitizer package, bound via lazyImport (see
|
|
54
91
|
// its doc for the fail-OPEN hazard of a bare static npm import); a failed load
|
|
55
92
|
// leaves these bindings undefined, so the layer calls below throw into the CLI's
|
|
@@ -253,9 +290,19 @@ function assembleResponse({
|
|
|
253
290
|
* fail-closed posture holds even when the adapter never loaded.
|
|
254
291
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
255
292
|
* @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
|
|
293
|
+
* @param {{ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>, gates?: HostGate[] }} [opts]
|
|
294
|
+
* messages are merged over the defaults, so a partial table is supported
|
|
256
295
|
* @returns {Promise<import("agent-control-plane-core").Verdict>}
|
|
257
296
|
*/
|
|
258
|
-
export async function judgePreToolUseSanitize(event, rehydrate) {
|
|
297
|
+
export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
|
|
298
|
+
const { gates = [] } = opts;
|
|
299
|
+
// MERGED over the defaults, never substituted for them. A host that overrides
|
|
300
|
+
// one field would otherwise leave the rest undefined, and the miss lands in
|
|
301
|
+
// the fail-closed path: failClosedFields runs inside runJudgeCli's catch, so a
|
|
302
|
+
// TypeError on a missing field escapes the handler, the hook exits with no
|
|
303
|
+
// stdout, and Claude reads a PreToolUse hook that produced no response as
|
|
304
|
+
// non-blocking — the fail-closed ask becomes a fail-OPEN pass.
|
|
305
|
+
const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
|
|
259
306
|
const { Decision, EventKind } = controlPlane();
|
|
260
307
|
// A payload the adapter cannot classify (a missing/unexpected hook_event_name)
|
|
261
308
|
// would drive the pipeline with an empty event and no-op to ALLOW — a silent
|
|
@@ -264,15 +311,23 @@ export async function judgePreToolUseSanitize(event, rehydrate) {
|
|
|
264
311
|
// never a real call: deny-when-blind. Rewarding an unclassifiable payload with
|
|
265
312
|
// a pass is the one incentive a gate must never create.
|
|
266
313
|
if (event.event === EventKind.UNKNOWN)
|
|
267
|
-
return {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
314
|
+
return { decision: Decision.DENY, reason: messages.unknownEvent };
|
|
315
|
+
// The session identity travels in `meta`, not alongside the tool input; a host
|
|
316
|
+
// gate keyed on the session (a once-per-session checkpoint) cannot tell two
|
|
317
|
+
// sessions apart without it.
|
|
318
|
+
const input = {
|
|
319
|
+
tool_name: event.tool,
|
|
320
|
+
tool_input: event.input,
|
|
321
|
+
session_id: event.meta?.session_id,
|
|
322
|
+
};
|
|
323
|
+
// Host gates run BEFORE any rewriting layer, because they decide whether the
|
|
324
|
+
// call may happen at all rather than what its input contains — and returning
|
|
325
|
+
// early keeps a denied call from also being reported as a sanitized one.
|
|
326
|
+
for (const gate of gates) {
|
|
327
|
+
const denyReason = gate(input);
|
|
328
|
+
if (denyReason) return { decision: Decision.DENY, reason: denyReason };
|
|
329
|
+
}
|
|
330
|
+
const fields = await buildPreToolUseResponse(input, rehydrate);
|
|
276
331
|
if (fields === null) return { decision: Decision.ALLOW };
|
|
277
332
|
/** @type {Record<string, unknown>} */
|
|
278
333
|
const verdict = {
|
|
@@ -287,6 +342,42 @@ export async function judgePreToolUseSanitize(event, rehydrate) {
|
|
|
287
342
|
return /** @type {import("agent-control-plane-core").Verdict} */ (verdict);
|
|
288
343
|
}
|
|
289
344
|
|
|
345
|
+
/**
|
|
346
|
+
* The dependency-load failure hiding behind a hook error, or "". A binding that
|
|
347
|
+
* never loaded surfaces at use time as a bare TypeError ("X is not a function")
|
|
348
|
+
* that names neither the package nor the remedy; when any lazily-loaded package
|
|
349
|
+
* has a recorded load error, name it — the failed set is derived from the
|
|
350
|
+
* loader's own records, so a future dependency is covered without editing a list
|
|
351
|
+
* here. An error already reporting a missing package (missingPackageError's
|
|
352
|
+
* `DEP_UNAVAILABLE` tag) gets no second copy.
|
|
353
|
+
* @param {unknown} err
|
|
354
|
+
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
355
|
+
* @param {() => string[]} [failedPackages]
|
|
356
|
+
* @param {(pkg: string) => unknown} [loadErrorFor]
|
|
357
|
+
* @returns {string}
|
|
358
|
+
*/
|
|
359
|
+
export function depLoadHint(
|
|
360
|
+
err,
|
|
361
|
+
remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
362
|
+
failedPackages = failedLazyPackages,
|
|
363
|
+
loadErrorFor = lazyImportErrorFor,
|
|
364
|
+
) {
|
|
365
|
+
if (/** @type {{code?: unknown}} */ (err)?.code === "DEP_UNAVAILABLE")
|
|
366
|
+
return "";
|
|
367
|
+
// Only a TypeError. The recorded-failure set is process-wide and carries no
|
|
368
|
+
// link to THIS error, so naming a package from it is an inference — sound only
|
|
369
|
+
// for the failure this hint exists to explain, where an unloaded binding is
|
|
370
|
+
// called and V8 raises a TypeError ("X is not a function", "Cannot read
|
|
371
|
+
// properties of undefined"). Any other throw is a layer engine reporting its
|
|
372
|
+
// own problem, and appending a package name there sends the reader to a
|
|
373
|
+
// reinstall that fixes nothing.
|
|
374
|
+
if (!(err instanceof TypeError)) return "";
|
|
375
|
+
const [pkg] = failedPackages();
|
|
376
|
+
return pkg === undefined
|
|
377
|
+
? ""
|
|
378
|
+
: ` ${missingPackageMessage(pkg, loadErrorFor(pkg), remedy)}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
290
381
|
/**
|
|
291
382
|
* The fail-closed hookSpecificOutput fields for a hook-level failure, chosen by
|
|
292
383
|
* WHICH failure it was. Corrupt/unparsable INPUT (`parsedOk` false — a JSON parse
|
|
@@ -297,16 +388,22 @@ export async function judgePreToolUseSanitize(event, rehydrate) {
|
|
|
297
388
|
* so it ASKS to keep a human in the loop rather than hard-block on infrastructure.
|
|
298
389
|
* @param {boolean} parsedOk whether the input parsed before the failure
|
|
299
390
|
* @param {unknown} err
|
|
391
|
+
* @param {{ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>, hint?: string }} [opts]
|
|
300
392
|
* @returns {Record<string, unknown>}
|
|
301
393
|
*/
|
|
302
|
-
export function failClosedFields(parsedOk, err) {
|
|
394
|
+
export function failClosedFields(parsedOk, err, opts = {}) {
|
|
395
|
+
const { hint = depLoadHint(err) } = opts;
|
|
396
|
+
// Merged, not substituted — see judgePreToolUseSanitize. This is the call site
|
|
397
|
+
// where a missing field would throw out of the catch and fail OPEN.
|
|
398
|
+
const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
|
|
399
|
+
const cause = `${safeErrMessage(err)}${hint}`;
|
|
303
400
|
return {
|
|
304
401
|
permissionDecision: parsedOk
|
|
305
402
|
? PermissionDecision.ASK
|
|
306
403
|
: PermissionDecision.DENY,
|
|
307
404
|
permissionDecisionReason: parsedOk
|
|
308
|
-
?
|
|
309
|
-
:
|
|
405
|
+
? messages.failed(cause)
|
|
406
|
+
: messages.unparsable(cause),
|
|
310
407
|
};
|
|
311
408
|
}
|
|
312
409
|
|
|
@@ -319,21 +416,34 @@ export function failClosedFields(parsedOk, err) {
|
|
|
319
416
|
* Exported so a bundle entry (which must claim the CLI slot before this module
|
|
320
417
|
* loads) can run the exact same wiring instead of duplicating the onError
|
|
321
418
|
* posture.
|
|
419
|
+
* @param {{
|
|
420
|
+
* messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
|
|
421
|
+
* gates?: HostGate[],
|
|
422
|
+
* }} [opts]
|
|
322
423
|
* @returns {Promise<void>}
|
|
323
424
|
*/
|
|
324
|
-
export async function cliMain() {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
425
|
+
export async function cliMain(opts = {}) {
|
|
426
|
+
const { gates = [] } = opts;
|
|
427
|
+
const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
|
|
428
|
+
await runJudgeCli(
|
|
429
|
+
HOOK_NAME,
|
|
430
|
+
(event) => judgePreToolUseSanitize(event, undefined, { messages, gates }),
|
|
431
|
+
{
|
|
432
|
+
// Fail closed WITHOUT the package: unparsable INPUT (`input` undefined)
|
|
433
|
+
// hard-denies (adversary-inducible, no benefit to failing); any throw
|
|
434
|
+
// after a clean parse — a layer engine down or the control-plane package
|
|
435
|
+
// unavailable — asks to keep a human in the loop. emitHookResponse renders
|
|
436
|
+
// natively, so this posture holds even when the adapter never loaded.
|
|
437
|
+
onError: (err, input) =>
|
|
438
|
+
emitHookResponse(
|
|
439
|
+
HookEvent.PRE_TOOL_USE,
|
|
440
|
+
failClosedFields(input !== undefined, err, {
|
|
441
|
+
messages,
|
|
442
|
+
hint: depLoadHint(err, messages.remedy),
|
|
443
|
+
}),
|
|
444
|
+
),
|
|
445
|
+
},
|
|
446
|
+
);
|
|
337
447
|
}
|
|
338
448
|
|
|
339
449
|
if (isMain(import.meta.url)) {
|