agent-sanitizer 2.7.2 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude-hooks/lib/control-plane.mjs +39 -14
- package/claude-hooks/lib/hook-io.mjs +262 -3
- package/claude-hooks/pretooluse-sanitize.mjs +131 -26
- package/claude-hooks/sanitize-output.mjs +16 -13
- package/claude-hooks/sanitize-user-prompt.mjs +57 -17
- 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 +8 -3
- package/types/claude-hooks/sanitize-user-prompt.d.mts +25 -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
|
|
@@ -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,33 @@ 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
|
+
* }>}
|
|
76
|
+
*/
|
|
77
|
+
export const PRE_TOOL_USE_MESSAGES = Object.freeze({
|
|
78
|
+
unknownEvent:
|
|
79
|
+
"PreToolUse sanitization blocked (fail-closed): unrecognized hook payload.",
|
|
80
|
+
failed: (cause) => `PreToolUse sanitization failed (fail-closed): ${cause}`,
|
|
81
|
+
unparsable: (cause) => `PreToolUse input unparsable (fail-closed): ${cause}`,
|
|
82
|
+
});
|
|
83
|
+
|
|
53
84
|
// Layers 2 & 4 come from the agent-sanitizer package, bound via lazyImport (see
|
|
54
85
|
// its doc for the fail-OPEN hazard of a bare static npm import); a failed load
|
|
55
86
|
// leaves these bindings undefined, so the layer calls below throw into the CLI's
|
|
@@ -253,9 +284,19 @@ function assembleResponse({
|
|
|
253
284
|
* fail-closed posture holds even when the adapter never loaded.
|
|
254
285
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
255
286
|
* @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
|
|
287
|
+
* @param {{ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>, gates?: HostGate[] }} [opts]
|
|
288
|
+
* messages are merged over the defaults, so a partial table is supported
|
|
256
289
|
* @returns {Promise<import("agent-control-plane-core").Verdict>}
|
|
257
290
|
*/
|
|
258
|
-
export async function judgePreToolUseSanitize(event, rehydrate) {
|
|
291
|
+
export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
|
|
292
|
+
const { gates = [] } = opts;
|
|
293
|
+
// MERGED over the defaults, never substituted for them. A host that overrides
|
|
294
|
+
// one field would otherwise leave the rest undefined, and the miss lands in
|
|
295
|
+
// the fail-closed path: failClosedFields runs inside runJudgeCli's catch, so a
|
|
296
|
+
// TypeError on a missing field escapes the handler, the hook exits with no
|
|
297
|
+
// stdout, and Claude reads a PreToolUse hook that produced no response as
|
|
298
|
+
// non-blocking — the fail-closed ask becomes a fail-OPEN pass.
|
|
299
|
+
const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
|
|
259
300
|
const { Decision, EventKind } = controlPlane();
|
|
260
301
|
// A payload the adapter cannot classify (a missing/unexpected hook_event_name)
|
|
261
302
|
// would drive the pipeline with an empty event and no-op to ALLOW — a silent
|
|
@@ -264,15 +305,23 @@ export async function judgePreToolUseSanitize(event, rehydrate) {
|
|
|
264
305
|
// never a real call: deny-when-blind. Rewarding an unclassifiable payload with
|
|
265
306
|
// a pass is the one incentive a gate must never create.
|
|
266
307
|
if (event.event === EventKind.UNKNOWN)
|
|
267
|
-
return {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
308
|
+
return { decision: Decision.DENY, reason: messages.unknownEvent };
|
|
309
|
+
// The session identity travels in `meta`, not alongside the tool input; a host
|
|
310
|
+
// gate keyed on the session (a once-per-session checkpoint) cannot tell two
|
|
311
|
+
// sessions apart without it.
|
|
312
|
+
const input = {
|
|
313
|
+
tool_name: event.tool,
|
|
314
|
+
tool_input: event.input,
|
|
315
|
+
session_id: event.meta?.session_id,
|
|
316
|
+
};
|
|
317
|
+
// Host gates run BEFORE any rewriting layer, because they decide whether the
|
|
318
|
+
// call may happen at all rather than what its input contains — and returning
|
|
319
|
+
// early keeps a denied call from also being reported as a sanitized one.
|
|
320
|
+
for (const gate of gates) {
|
|
321
|
+
const denyReason = gate(input);
|
|
322
|
+
if (denyReason) return { decision: Decision.DENY, reason: denyReason };
|
|
323
|
+
}
|
|
324
|
+
const fields = await buildPreToolUseResponse(input, rehydrate);
|
|
276
325
|
if (fields === null) return { decision: Decision.ALLOW };
|
|
277
326
|
/** @type {Record<string, unknown>} */
|
|
278
327
|
const verdict = {
|
|
@@ -287,6 +336,42 @@ export async function judgePreToolUseSanitize(event, rehydrate) {
|
|
|
287
336
|
return /** @type {import("agent-control-plane-core").Verdict} */ (verdict);
|
|
288
337
|
}
|
|
289
338
|
|
|
339
|
+
/**
|
|
340
|
+
* The dependency-load failure hiding behind a hook error, or "". A binding that
|
|
341
|
+
* never loaded surfaces at use time as a bare TypeError ("X is not a function")
|
|
342
|
+
* that names neither the package nor the remedy; when any lazily-loaded package
|
|
343
|
+
* has a recorded load error, name it — the failed set is derived from the
|
|
344
|
+
* loader's own records, so a future dependency is covered without editing a list
|
|
345
|
+
* here. An error already reporting a missing package (missingPackageError's
|
|
346
|
+
* `DEP_UNAVAILABLE` tag) gets no second copy.
|
|
347
|
+
* @param {unknown} err
|
|
348
|
+
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
349
|
+
* @param {() => string[]} [failedPackages]
|
|
350
|
+
* @param {(pkg: string) => unknown} [loadErrorFor]
|
|
351
|
+
* @returns {string}
|
|
352
|
+
*/
|
|
353
|
+
export function depLoadHint(
|
|
354
|
+
err,
|
|
355
|
+
remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
356
|
+
failedPackages = failedLazyPackages,
|
|
357
|
+
loadErrorFor = lazyImportErrorFor,
|
|
358
|
+
) {
|
|
359
|
+
if (/** @type {{code?: unknown}} */ (err)?.code === "DEP_UNAVAILABLE")
|
|
360
|
+
return "";
|
|
361
|
+
// Only a TypeError. The recorded-failure set is process-wide and carries no
|
|
362
|
+
// link to THIS error, so naming a package from it is an inference — sound only
|
|
363
|
+
// for the failure this hint exists to explain, where an unloaded binding is
|
|
364
|
+
// called and V8 raises a TypeError ("X is not a function", "Cannot read
|
|
365
|
+
// properties of undefined"). Any other throw is a layer engine reporting its
|
|
366
|
+
// own problem, and appending a package name there sends the reader to a
|
|
367
|
+
// reinstall that fixes nothing.
|
|
368
|
+
if (!(err instanceof TypeError)) return "";
|
|
369
|
+
const [pkg] = failedPackages();
|
|
370
|
+
return pkg === undefined
|
|
371
|
+
? ""
|
|
372
|
+
: ` ${missingPackageMessage(pkg, loadErrorFor(pkg), remedy)}`;
|
|
373
|
+
}
|
|
374
|
+
|
|
290
375
|
/**
|
|
291
376
|
* The fail-closed hookSpecificOutput fields for a hook-level failure, chosen by
|
|
292
377
|
* WHICH failure it was. Corrupt/unparsable INPUT (`parsedOk` false — a JSON parse
|
|
@@ -297,16 +382,22 @@ export async function judgePreToolUseSanitize(event, rehydrate) {
|
|
|
297
382
|
* so it ASKS to keep a human in the loop rather than hard-block on infrastructure.
|
|
298
383
|
* @param {boolean} parsedOk whether the input parsed before the failure
|
|
299
384
|
* @param {unknown} err
|
|
385
|
+
* @param {{ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>, hint?: string }} [opts]
|
|
300
386
|
* @returns {Record<string, unknown>}
|
|
301
387
|
*/
|
|
302
|
-
export function failClosedFields(parsedOk, err) {
|
|
388
|
+
export function failClosedFields(parsedOk, err, opts = {}) {
|
|
389
|
+
const { hint = depLoadHint(err) } = opts;
|
|
390
|
+
// Merged, not substituted — see judgePreToolUseSanitize. This is the call site
|
|
391
|
+
// where a missing field would throw out of the catch and fail OPEN.
|
|
392
|
+
const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
|
|
393
|
+
const cause = `${safeErrMessage(err)}${hint}`;
|
|
303
394
|
return {
|
|
304
395
|
permissionDecision: parsedOk
|
|
305
396
|
? PermissionDecision.ASK
|
|
306
397
|
: PermissionDecision.DENY,
|
|
307
398
|
permissionDecisionReason: parsedOk
|
|
308
|
-
?
|
|
309
|
-
:
|
|
399
|
+
? messages.failed(cause)
|
|
400
|
+
: messages.unparsable(cause),
|
|
310
401
|
};
|
|
311
402
|
}
|
|
312
403
|
|
|
@@ -319,21 +410,35 @@ export function failClosedFields(parsedOk, err) {
|
|
|
319
410
|
* Exported so a bundle entry (which must claim the CLI slot before this module
|
|
320
411
|
* loads) can run the exact same wiring instead of duplicating the onError
|
|
321
412
|
* posture.
|
|
413
|
+
* @param {{
|
|
414
|
+
* messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
|
|
415
|
+
* gates?: HostGate[],
|
|
416
|
+
* remedy?: string,
|
|
417
|
+
* }} [opts]
|
|
322
418
|
* @returns {Promise<void>}
|
|
323
419
|
*/
|
|
324
|
-
export async function cliMain() {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
420
|
+
export async function cliMain(opts = {}) {
|
|
421
|
+
const { gates = [], remedy } = opts;
|
|
422
|
+
const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
|
|
423
|
+
await runJudgeCli(
|
|
424
|
+
HOOK_NAME,
|
|
425
|
+
(event) => judgePreToolUseSanitize(event, undefined, { messages, gates }),
|
|
426
|
+
{
|
|
427
|
+
// Fail closed WITHOUT the package: unparsable INPUT (`input` undefined)
|
|
428
|
+
// hard-denies (adversary-inducible, no benefit to failing); any throw
|
|
429
|
+
// after a clean parse — a layer engine down or the control-plane package
|
|
430
|
+
// unavailable — asks to keep a human in the loop. emitHookResponse renders
|
|
431
|
+
// natively, so this posture holds even when the adapter never loaded.
|
|
432
|
+
onError: (err, input) =>
|
|
433
|
+
emitHookResponse(
|
|
434
|
+
HookEvent.PRE_TOOL_USE,
|
|
435
|
+
failClosedFields(input !== undefined, err, {
|
|
436
|
+
messages,
|
|
437
|
+
hint: depLoadHint(err, remedy),
|
|
438
|
+
}),
|
|
439
|
+
),
|
|
440
|
+
},
|
|
441
|
+
);
|
|
337
442
|
}
|
|
338
443
|
|
|
339
444
|
if (isMain(import.meta.url)) {
|
|
@@ -28,6 +28,9 @@ import {
|
|
|
28
28
|
errMessage,
|
|
29
29
|
safeErrMessage,
|
|
30
30
|
makeDeadline,
|
|
31
|
+
lazyImportErrorFor,
|
|
32
|
+
missingPackageMessage,
|
|
33
|
+
DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
31
34
|
HookEvent,
|
|
32
35
|
} from "./lib/hook-io.mjs";
|
|
33
36
|
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
@@ -478,13 +481,6 @@ const FAIL_CLOSED_CONTEXT =
|
|
|
478
481
|
"(replaced with a placeholder) to fail closed -- the unsanitized output was " +
|
|
479
482
|
"not shown. Investigate the hook error before relying on this tool.";
|
|
480
483
|
|
|
481
|
-
// The one cause that is a broken INSTALL rather than a broken hook: the
|
|
482
|
-
// sanitizer's bindings are absent, so every subsequent tool call fails closed
|
|
483
|
-
// with no visible cause. Name the remedy in the emission itself.
|
|
484
|
-
const MISSING_DEPS_HINT =
|
|
485
|
-
" The cause is a missing dependency (agent-sanitizer did not load), not a" +
|
|
486
|
-
" hook defect: reinstall the plugin, then retry the tool call.";
|
|
487
|
-
|
|
488
484
|
/**
|
|
489
485
|
* Whether the sanitizer's bindings actually loaded. lazyImport swallows a
|
|
490
486
|
* missing package and yields `{}`, so the absence shows up as an undefined
|
|
@@ -501,15 +497,22 @@ export function sanitizerDepsLoaded() {
|
|
|
501
497
|
}
|
|
502
498
|
|
|
503
499
|
/**
|
|
504
|
-
* The model-facing note for a fail-closed emission
|
|
505
|
-
*
|
|
500
|
+
* The model-facing note for a fail-closed emission. When the sanitizer's own
|
|
501
|
+
* bindings are what is absent — a broken INSTALL rather than a broken hook, and
|
|
502
|
+
* otherwise invisible because every later tool call then fails closed with no
|
|
503
|
+
* stated cause — the recorded loader error and its remedy ride along. The text
|
|
504
|
+
* comes from missingPackageMessage so this hook, the PreToolUse gate and the
|
|
505
|
+
* prompt gate cannot drift apart on what a missing dependency reads like.
|
|
506
506
|
* @param {() => boolean} [depsLoaded] injectable seam for testing
|
|
507
|
+
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
507
508
|
* @returns {string}
|
|
508
509
|
*/
|
|
509
|
-
export function failClosedContext(
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
510
|
+
export function failClosedContext(
|
|
511
|
+
depsLoaded = sanitizerDepsLoaded,
|
|
512
|
+
remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
513
|
+
) {
|
|
514
|
+
if (depsLoaded()) return FAIL_CLOSED_CONTEXT;
|
|
515
|
+
return `${FAIL_CLOSED_CONTEXT} ${missingPackageMessage("agent-sanitizer", lazyImportErrorFor("agent-sanitizer"), remedy)}`;
|
|
513
516
|
}
|
|
514
517
|
|
|
515
518
|
/**
|
|
@@ -18,7 +18,12 @@
|
|
|
18
18
|
* (cursor movement, erase, OSC title-set, DCS/APC/PM) still blocks, as do the
|
|
19
19
|
* invisible-char thresholds, which are the actual web-paste payload defense.
|
|
20
20
|
*/
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
readStdinJson,
|
|
23
|
+
safeErrMessage,
|
|
24
|
+
isMain,
|
|
25
|
+
missingPackageError,
|
|
26
|
+
} from "./lib/hook-io.mjs";
|
|
22
27
|
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
23
28
|
import { trace, TraceEvent } from "./lib/trace.mjs";
|
|
24
29
|
// classifyPrompt (the user-prompt verdict) and stripAnsiFully (its ANSI stripper)
|
|
@@ -33,10 +38,28 @@ export let classifyPrompt;
|
|
|
33
38
|
/** @type {typeof import("agent-sanitizer").stripAnsiFully} */
|
|
34
39
|
let stripAnsiFully;
|
|
35
40
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The reasons this gate emits, as a table a host overrides. A host that knows
|
|
43
|
+
* which of ITS files wires the adapter, and what a reader should do about a
|
|
44
|
+
* failure, can say so — the package cannot, since it has no idea where it is
|
|
45
|
+
* installed. Every field is a plain string or a string-returning function, so
|
|
46
|
+
* an override is auditable next to the default it replaces.
|
|
47
|
+
* @type {Readonly<{
|
|
48
|
+
* unknownEvent: string,
|
|
49
|
+
* blockContext: string,
|
|
50
|
+
* sgrNote: string,
|
|
51
|
+
* hookFailed: (cause: string) => string,
|
|
52
|
+
* }>}
|
|
53
|
+
*/
|
|
54
|
+
export const USER_PROMPT_MESSAGES = Object.freeze({
|
|
55
|
+
unknownEvent: "User prompt blocked (fail-closed): unrecognized hook payload.",
|
|
56
|
+
blockContext:
|
|
57
|
+
"User prompt blocked: payload-capable invisible/ANSI characters detected.",
|
|
58
|
+
sgrNote:
|
|
59
|
+
"The prompt contains ANSI SGR color codes (pasted terminal output). They are display-only formatting noise; read through them.",
|
|
60
|
+
hookFailed: (cause) =>
|
|
61
|
+
`sanitize-user-prompt hook failed (fail-closed): ${cause}`,
|
|
62
|
+
});
|
|
40
63
|
|
|
41
64
|
/* c8 ignore start — module-load boundary: the imports resolve in every real
|
|
42
65
|
* run, and their failure (the package absent) can't be simulated in-process, so
|
|
@@ -67,9 +90,20 @@ try {
|
|
|
67
90
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
68
91
|
* @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
|
|
69
92
|
* to the package's stripAnsiFully; injectable so the fail-closed path is testable)
|
|
93
|
+
* @param {Partial<typeof USER_PROMPT_MESSAGES>} [overrides] reason overrides,
|
|
94
|
+
* merged over the defaults so a partial table can never leave a field unset
|
|
70
95
|
* @returns {import("agent-control-plane-core").Verdict}
|
|
71
96
|
*/
|
|
72
|
-
export function judgeSanitizeUserPrompt(
|
|
97
|
+
export function judgeSanitizeUserPrompt(
|
|
98
|
+
event,
|
|
99
|
+
strip = stripAnsiFully,
|
|
100
|
+
overrides = USER_PROMPT_MESSAGES,
|
|
101
|
+
) {
|
|
102
|
+
// MERGED over the defaults, never substituted for them — a host that overrides
|
|
103
|
+
// one field would otherwise leave the rest undefined. main() threads the same
|
|
104
|
+
// object into its onError, where a missing field throws out of the catch and
|
|
105
|
+
// the gate emits nothing, which the harness reads as a pass: fail OPEN.
|
|
106
|
+
const messages = { ...USER_PROMPT_MESSAGES, ...overrides };
|
|
73
107
|
const { Decision, EventKind } = controlPlane();
|
|
74
108
|
// A payload the adapter cannot classify carries no readable prompt, so an
|
|
75
109
|
// abstain would fail OPEN on harness contract drift; this gate's posture is
|
|
@@ -77,18 +111,14 @@ export function judgeSanitizeUserPrompt(event, strip = stripAnsiFully) {
|
|
|
77
111
|
// channel — a non-PRE_TOOL event has no permissionDecision body — which Claude
|
|
78
112
|
// honors on UserPromptSubmit.)
|
|
79
113
|
if (event.event === EventKind.UNKNOWN)
|
|
80
|
-
return {
|
|
81
|
-
decision: Decision.DENY,
|
|
82
|
-
reason: "User prompt blocked (fail-closed): unrecognized hook payload.",
|
|
83
|
-
};
|
|
114
|
+
return { decision: Decision.DENY, reason: messages.unknownEvent };
|
|
84
115
|
if (event.event !== EventKind.PROMPT_SUBMIT)
|
|
85
116
|
return { decision: Decision.ALLOW };
|
|
86
117
|
// The module-load guard: a missing stripper means agent-sanitizer never
|
|
87
118
|
// loaded. Guarding on the stripper alone is sufficient — it loads AFTER
|
|
88
119
|
// classifyPrompt in the same try, so a present stripper proves the classifier
|
|
89
120
|
// loaded too.
|
|
90
|
-
if (typeof strip !== "function")
|
|
91
|
-
throw new Error("agent-sanitizer is unavailable");
|
|
121
|
+
if (typeof strip !== "function") throw missingPackageError("agent-sanitizer");
|
|
92
122
|
// The contract guarantees a string here: every adapter normalizes the
|
|
93
123
|
// prompt-submit input (Claude's parse coerces a missing/non-string prompt to
|
|
94
124
|
// "" via asString), so a defensive typeof re-check is a dead branch.
|
|
@@ -97,13 +127,13 @@ export function judgeSanitizeUserPrompt(event, strip = stripAnsiFully) {
|
|
|
97
127
|
const verdict = classifyPrompt(prompt, strip);
|
|
98
128
|
if (verdict.action === "pass") return { decision: Decision.ALLOW };
|
|
99
129
|
if (verdict.action === "note")
|
|
100
|
-
return { decision: Decision.ALLOW, additional_context:
|
|
130
|
+
return { decision: Decision.ALLOW, additional_context: messages.sgrNote };
|
|
101
131
|
// block: carry the reason AND a context note — UserPromptSubmit can't rewrite
|
|
102
132
|
// the prompt, so the context is the only forward signal about why it dropped.
|
|
103
133
|
return {
|
|
104
134
|
decision: Decision.DENY,
|
|
105
135
|
reason: verdict.reason,
|
|
106
|
-
additional_context:
|
|
136
|
+
additional_context: messages.blockContext,
|
|
107
137
|
};
|
|
108
138
|
}
|
|
109
139
|
|
|
@@ -112,9 +142,19 @@ export function judgeSanitizeUserPrompt(event, strip = stripAnsiFully) {
|
|
|
112
142
|
* @param {(chunk: string) => void} write
|
|
113
143
|
* @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
|
|
114
144
|
* to the package's stripAnsiFully; injectable so the fail-closed path is testable)
|
|
145
|
+
* @param {Partial<typeof USER_PROMPT_MESSAGES>} [overrides] reason overrides,
|
|
146
|
+
* merged over the defaults so a partial table can never leave a field unset
|
|
115
147
|
* @returns {Promise<void>}
|
|
116
148
|
*/
|
|
117
|
-
export async function main(
|
|
149
|
+
export async function main(
|
|
150
|
+
read,
|
|
151
|
+
write,
|
|
152
|
+
strip = stripAnsiFully,
|
|
153
|
+
overrides = USER_PROMPT_MESSAGES,
|
|
154
|
+
) {
|
|
155
|
+
// Merged, not substituted — see judgeSanitizeUserPrompt. onError below is the
|
|
156
|
+
// call site where a missing field would throw out of the catch and fail OPEN.
|
|
157
|
+
const messages = { ...USER_PROMPT_MESSAGES, ...overrides };
|
|
118
158
|
// Delegate the parse → judge → render → write contract to the shared
|
|
119
159
|
// runJudgeCli so this hook doesn't re-implement the control-plane boundary:
|
|
120
160
|
// runJudgeCli reads stdin BEFORE loading the control-plane package, so a
|
|
@@ -125,7 +165,7 @@ export async function main(read, write, strip = stripAnsiFully) {
|
|
|
125
165
|
await runJudgeCli(
|
|
126
166
|
"sanitize-user-prompt",
|
|
127
167
|
(event) => {
|
|
128
|
-
const verdict = judgeSanitizeUserPrompt(event, strip);
|
|
168
|
+
const verdict = judgeSanitizeUserPrompt(event, strip, messages);
|
|
129
169
|
// Announce engagement on the trace channel like the other stdin hooks —
|
|
130
170
|
// a prompt gate that silently stopped running is otherwise invisible.
|
|
131
171
|
trace(TraceEvent.HOOK_RAN, {
|
|
@@ -146,7 +186,7 @@ export async function main(read, write, strip = stripAnsiFully) {
|
|
|
146
186
|
write(
|
|
147
187
|
JSON.stringify({
|
|
148
188
|
decision: "block",
|
|
149
|
-
reason:
|
|
189
|
+
reason: messages.hookFailed(safeErrMessage(err)),
|
|
150
190
|
}),
|
|
151
191
|
),
|
|
152
192
|
},
|
|
@@ -7,7 +7,15 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { readFileSync, globSync, writeFileSync, unlinkSync } from "node:fs";
|
|
9
9
|
import { join, relative } from "node:path";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
awaitLazyDependency,
|
|
12
|
+
hookgateMarkerPath,
|
|
13
|
+
isMain,
|
|
14
|
+
lazyImport,
|
|
15
|
+
markerIsTrusted,
|
|
16
|
+
probeSetupAlive,
|
|
17
|
+
writeFileNoFollow,
|
|
18
|
+
} from "./lib/hook-io.mjs";
|
|
11
19
|
import {
|
|
12
20
|
ALERT_FILE,
|
|
13
21
|
ALERT_ACK_FILE,
|
|
@@ -17,9 +25,11 @@ import { trace, TraceEvent } from "./lib/trace.mjs";
|
|
|
17
25
|
|
|
18
26
|
// Layer-1 primitives, bound via lazyImport (see its doc for the fail-OPEN
|
|
19
27
|
// hazard of a bare static npm import — here the instruction files would load
|
|
20
|
-
// UNSCANNED). A failed load leaves the bindings undefined
|
|
21
|
-
//
|
|
22
|
-
|
|
28
|
+
// UNSCANNED). A failed load leaves the bindings undefined; on a cold container
|
|
29
|
+
// (node deps not yet installed) cliMain's guard below waits out session-setup
|
|
30
|
+
// before giving up, and fails loud rather than silently passing.
|
|
31
|
+
// `let`, not `const`: the cold-start poll re-binds these once the package loads.
|
|
32
|
+
let {
|
|
23
33
|
LONG_RUN_RE,
|
|
24
34
|
LONG_RUN_THRESHOLD,
|
|
25
35
|
SCATTERED_THRESHOLD: TOTAL_INVISIBLE_THRESHOLD,
|
|
@@ -29,6 +39,44 @@ const {
|
|
|
29
39
|
await lazyImport("agent-sanitizer/invisible")
|
|
30
40
|
);
|
|
31
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Re-attempt the sanitizer import, waiting out an in-flight session-setup before
|
|
44
|
+
* giving up. On a cold container the node deps this hook needs are still being
|
|
45
|
+
* installed when SessionStart fires; without this wait `stripInvisible` is
|
|
46
|
+
* undefined, the scan is skipped, and the instruction files load UNSCANNED for the
|
|
47
|
+
* whole session (fail open) — silently. Reuses the control-plane poll (marker +
|
|
48
|
+
* PID liveness) so the wait bound matches every other cold-start-aware gate.
|
|
49
|
+
* @returns {Promise<boolean>} whether the sanitizer is now bound
|
|
50
|
+
*/
|
|
51
|
+
async function ensureSanitizerLoaded() {
|
|
52
|
+
if (typeof stripInvisible === "function") return true;
|
|
53
|
+
/* c8 ignore start -- cold-start reload: only runs when the top-level
|
|
54
|
+
agent-sanitizer import above failed (node deps not yet installed), which
|
|
55
|
+
can't be simulated in-process or in the spawned-subprocess CLI run the tests
|
|
56
|
+
observe (the test env always has the deps, so the guard above early-returns).
|
|
57
|
+
The reload reuses awaitLazyDependency / markerIsTrusted /
|
|
58
|
+
probeSetupAlive, each unit-tested directly. */
|
|
59
|
+
const marker = hookgateMarkerPath();
|
|
60
|
+
const reloaded = await awaitLazyDependency({
|
|
61
|
+
tryImport: async () => {
|
|
62
|
+
const mod = await lazyImport("agent-sanitizer/invisible");
|
|
63
|
+
return typeof mod.stripInvisible === "function" ? mod : null;
|
|
64
|
+
},
|
|
65
|
+
markerPresent: () => markerIsTrusted(marker),
|
|
66
|
+
setupAlive: () => probeSetupAlive(marker),
|
|
67
|
+
});
|
|
68
|
+
if (!reloaded) return false;
|
|
69
|
+
({
|
|
70
|
+
LONG_RUN_RE,
|
|
71
|
+
LONG_RUN_THRESHOLD,
|
|
72
|
+
SCATTERED_THRESHOLD: TOTAL_INVISIBLE_THRESHOLD,
|
|
73
|
+
STRIP,
|
|
74
|
+
stripInvisible,
|
|
75
|
+
} = /** @type {typeof import("agent-sanitizer/invisible")} */ (reloaded));
|
|
76
|
+
return true;
|
|
77
|
+
/* c8 ignore stop */
|
|
78
|
+
}
|
|
79
|
+
|
|
32
80
|
// Decoder
|
|
33
81
|
|
|
34
82
|
/**
|
|
@@ -229,14 +277,15 @@ export async function cliMain() {
|
|
|
229
277
|
/* c8 ignore start -- fail-closed module-load guard: only reachable when the
|
|
230
278
|
agent-sanitizer import above failed, which can't be simulated in the
|
|
231
279
|
spawned-subprocess CLI run the tests observe. */
|
|
232
|
-
if (
|
|
280
|
+
if (!(await ensureSanitizerLoaded())) {
|
|
233
281
|
// Emit the engagement event with a "skipped" outcome so the loss is LOUD on
|
|
234
282
|
// the trace channel — a scan that never ran is otherwise invisible, and the
|
|
235
283
|
// downstream PreToolUse sanitize gate then passes cleanly all session.
|
|
236
284
|
trace(TraceEvent.SCAN_INVISIBLE_CHARS_RAN, { outcome: "skipped" });
|
|
237
285
|
process.stderr.write(
|
|
238
|
-
"scan-invisible-chars: agent-sanitizer failed to load
|
|
239
|
-
"
|
|
286
|
+
"scan-invisible-chars: agent-sanitizer failed to load (node deps not " +
|
|
287
|
+
"installed and session-setup did not finish in time); instruction " +
|
|
288
|
+
"files were NOT scanned for hidden Unicode. Run `pnpm install`.\n",
|
|
240
289
|
);
|
|
241
290
|
process.exit(1);
|
|
242
291
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -153,6 +153,34 @@
|
|
|
153
153
|
"./claude-hooks/lib/control-plane": {
|
|
154
154
|
"types": "./types/claude-hooks/lib/control-plane.d.mts",
|
|
155
155
|
"default": "./claude-hooks/lib/control-plane.mjs"
|
|
156
|
+
},
|
|
157
|
+
"./claude-hooks/lib/invisible-alert": {
|
|
158
|
+
"types": "./types/claude-hooks/lib/invisible-alert.d.mts",
|
|
159
|
+
"default": "./claude-hooks/lib/invisible-alert.mjs"
|
|
160
|
+
},
|
|
161
|
+
"./claude-hooks/lib/authored-content": {
|
|
162
|
+
"types": "./types/claude-hooks/lib/authored-content.d.mts",
|
|
163
|
+
"default": "./claude-hooks/lib/authored-content.mjs"
|
|
164
|
+
},
|
|
165
|
+
"./claude-hooks/lib/env-config": {
|
|
166
|
+
"types": "./types/claude-hooks/lib/env-config.d.mts",
|
|
167
|
+
"default": "./claude-hooks/lib/env-config.mjs"
|
|
168
|
+
},
|
|
169
|
+
"./claude-hooks/lib/secret-annotate": {
|
|
170
|
+
"types": "./types/claude-hooks/lib/secret-annotate.d.mts",
|
|
171
|
+
"default": "./claude-hooks/lib/secret-annotate.mjs"
|
|
172
|
+
},
|
|
173
|
+
"./claude-hooks/lib/reveal": {
|
|
174
|
+
"types": "./types/claude-hooks/lib/reveal.d.mts",
|
|
175
|
+
"default": "./claude-hooks/lib/reveal.mjs"
|
|
176
|
+
},
|
|
177
|
+
"./claude-hooks/lib/redactor-client": {
|
|
178
|
+
"types": "./types/claude-hooks/lib/redactor-client.d.mts",
|
|
179
|
+
"default": "./claude-hooks/lib/redactor-client.mjs"
|
|
180
|
+
},
|
|
181
|
+
"./claude-hooks/lib/trace": {
|
|
182
|
+
"types": "./types/claude-hooks/lib/trace.d.mts",
|
|
183
|
+
"default": "./claude-hooks/lib/trace.mjs"
|
|
156
184
|
}
|
|
157
185
|
},
|
|
158
186
|
"files": [
|
|
@@ -62,6 +62,47 @@ export function registeredLazyModule(specifier: string): Record<string, any> | u
|
|
|
62
62
|
* @returns {Promise<Record<string, any>>}
|
|
63
63
|
*/
|
|
64
64
|
export function lazyImport(specifier: string): Promise<Record<string, any>>;
|
|
65
|
+
/**
|
|
66
|
+
* The most recently recorded load error for `pkg` under any of its specifiers —
|
|
67
|
+
* the bare package or a subpath export (`pkg/output`, `pkg/invisible`) — or
|
|
68
|
+
* undefined when none is recorded. Hooks import a package through several
|
|
69
|
+
* subpaths; any one of them names why the package is absent, and the newest
|
|
70
|
+
* record reflects the current failure when they differ.
|
|
71
|
+
* @param {string} pkg
|
|
72
|
+
* @returns {unknown}
|
|
73
|
+
*/
|
|
74
|
+
export function lazyImportErrorFor(pkg: string): unknown;
|
|
75
|
+
/**
|
|
76
|
+
* Package names (never relative-path specifiers) with a recorded load error,
|
|
77
|
+
* newest first — so a fail-closed reason can name whichever dependency actually
|
|
78
|
+
* failed instead of consulting a hardcoded package list.
|
|
79
|
+
* @returns {string[]}
|
|
80
|
+
*/
|
|
81
|
+
export function failedLazyPackages(): string[];
|
|
82
|
+
/**
|
|
83
|
+
* The fail-closed reason for a package a hook could not load: the recorded
|
|
84
|
+
* loader error plus the remedy. The cause is scrubbed (it is spliced into
|
|
85
|
+
* reasons shown to user and model) and its cap is COMPUTED so that
|
|
86
|
+
* prefix + cause + remedy always fits the downstream 300-char safeErrMessage
|
|
87
|
+
* re-scrub — the remedy can never be truncated off, whatever the package name.
|
|
88
|
+
* A remedy that alone exceeds that budget (roughly 260 characters) leaves the
|
|
89
|
+
* cause nothing to spend and still overruns; keep host remedies to a sentence.
|
|
90
|
+
* @param {string} pkg
|
|
91
|
+
* @param {unknown} [err]
|
|
92
|
+
* @param {string} [remedy]
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
export function missingPackageMessage(pkg: string, err?: unknown, remedy?: string): string;
|
|
96
|
+
/**
|
|
97
|
+
* {@link missingPackageMessage} as a throwable, tagged `code: "DEP_UNAVAILABLE"`
|
|
98
|
+
* so downstream reason-builders can recognize it structurally and not append a
|
|
99
|
+
* second copy of the same cause.
|
|
100
|
+
* @param {string} pkg
|
|
101
|
+
* @param {unknown} [err]
|
|
102
|
+
* @param {string} [remedy]
|
|
103
|
+
* @returns {Error}
|
|
104
|
+
*/
|
|
105
|
+
export function missingPackageError(pkg: string, err?: unknown, remedy?: string): Error;
|
|
65
106
|
/**
|
|
66
107
|
* A monotonic wall-clock budget shared across one hook run's downstream blocking
|
|
67
108
|
* calls. `remainingMs()` returns the milliseconds left until the budget is spent
|
|
@@ -126,6 +167,77 @@ export function safeErrMessage(err: unknown, cap?: number): string;
|
|
|
126
167
|
* @returns {void}
|
|
127
168
|
*/
|
|
128
169
|
export function emitHookResponse(hookEventName: string, fields: Record<string, unknown>): void;
|
|
170
|
+
/**
|
|
171
|
+
* Path of the cold-start in-flight marker a host's setup script writes
|
|
172
|
+
* SYNCHRONOUSLY before it starts installing deps (its own PID as the contents)
|
|
173
|
+
* and removes once the hook dependencies are provisioned. A hook that fires
|
|
174
|
+
* before setup finishes finds the marker and WAITS for its dependency rather
|
|
175
|
+
* than failing closed on it — so the first turn is merely delayed, never
|
|
176
|
+
* blocked, for as long as setup is still alive (the PID lets the hook tell a
|
|
177
|
+
* live install from a stale marker left by a killed setup). Derived purely from
|
|
178
|
+
* the raw CLAUDE_PROJECT_DIR the harness sets for both processes (no
|
|
179
|
+
* canonicalization — the two must produce byte-identical paths), so no env has
|
|
180
|
+
* to propagate from setup to the hook. Null when CLAUDE_PROJECT_DIR is unset (no
|
|
181
|
+
* setup ran → nothing to wait on).
|
|
182
|
+
* @param {string | undefined} [projectDir]
|
|
183
|
+
* @param {string | undefined} [runtimeDir]
|
|
184
|
+
* @returns {string | null}
|
|
185
|
+
*/
|
|
186
|
+
export function hookgateMarkerPath(projectDir?: string | undefined, runtimeDir?: string | undefined): string | null;
|
|
187
|
+
/**
|
|
188
|
+
* Is the setup process that wrote `markerPath` still alive? `process.kill(pid, 0)`
|
|
189
|
+
* probes liveness without signalling: it throws ESRCH once the process is gone (a
|
|
190
|
+
* killed setup → stale marker, so stop waiting) and EPERM when it exists but isn't
|
|
191
|
+
* ours (still alive). An unreadable / not-yet-written marker is treated as alive —
|
|
192
|
+
* favouring a brief wait over a premature give-up during setup's write race. A null
|
|
193
|
+
* markerPath (no project dir → no setup to wait on) reads as alive so the caller's
|
|
194
|
+
* own grace/ceiling bound governs.
|
|
195
|
+
* @param {string | null} markerPath
|
|
196
|
+
* @returns {boolean}
|
|
197
|
+
*/
|
|
198
|
+
export function probeSetupAlive(markerPath: string | null): boolean;
|
|
199
|
+
/**
|
|
200
|
+
* Resolve a lazily-loaded dependency, blocking through the cold-start window while
|
|
201
|
+
* setup is still installing it. Returns the loaded value, or null once it gives up
|
|
202
|
+
* (the caller leaves its bindings undefined so the hook fails closed). It waits for
|
|
203
|
+
* as long as setup is genuinely alive, so a slow install is never cut off; the only
|
|
204
|
+
* bound on that wait is a backstop ceiling that stays under the hook's harness
|
|
205
|
+
* timeout — a hook killed for running over is a fail-OPEN, the opposite of what a
|
|
206
|
+
* gate wants. The give-up cases are the honest ones (setup finished/died without the
|
|
207
|
+
* dep, or no setup at all), so a genuinely-absent dep fails closed fast, never after
|
|
208
|
+
* a long block:
|
|
209
|
+
* - import succeeds → return immediately (warm session: no wait).
|
|
210
|
+
* - marker present AND setup alive → setup is working; wait it out (ceilingMs is a
|
|
211
|
+
* backstop only, for a hung-but-alive setup).
|
|
212
|
+
* - was installing, now not (marker cleared, or a stale marker from a killed setup)
|
|
213
|
+
* → settleMs grace for a just-orphaned install to
|
|
214
|
+
* land, then give up: the dep is absent.
|
|
215
|
+
* - no live setup ever seen → wait only graceMs (tolerating setup not having
|
|
216
|
+
* written the marker yet), then give up.
|
|
217
|
+
* @param {{
|
|
218
|
+
* tryImport: () => Promise<object | null>,
|
|
219
|
+
* markerPresent: () => boolean,
|
|
220
|
+
* setupAlive: () => boolean,
|
|
221
|
+
* now?: () => number,
|
|
222
|
+
* sleep?: (ms: number) => Promise<void>,
|
|
223
|
+
* graceMs?: number,
|
|
224
|
+
* settleMs?: number,
|
|
225
|
+
* ceilingMs?: number,
|
|
226
|
+
* intervalMs?: number,
|
|
227
|
+
* }} deps
|
|
228
|
+
* @returns {Promise<object | null>}
|
|
229
|
+
*/
|
|
230
|
+
export function awaitLazyDependency({ tryImport, markerPresent, setupAlive, now, sleep, graceMs, settleMs, ceilingMs, intervalMs, }: {
|
|
231
|
+
tryImport: () => Promise<object | null>;
|
|
232
|
+
markerPresent: () => boolean;
|
|
233
|
+
setupAlive: () => boolean;
|
|
234
|
+
now?: () => number;
|
|
235
|
+
sleep?: (ms: number) => Promise<void>;
|
|
236
|
+
graceMs?: number;
|
|
237
|
+
settleMs?: number;
|
|
238
|
+
ceilingMs?: number;
|
|
239
|
+
intervalMs?: number;
|
|
240
|
+
}): Promise<object | null>;
|
|
129
241
|
/**
|
|
130
242
|
* Is the file at `path` one WE wrote — a regular file owned by this uid — rather
|
|
131
243
|
* than a squat? These markers live at predictable, world-visible $TMPDIR paths, so
|
|
@@ -194,3 +306,10 @@ export const PermissionDecision: Readonly<{
|
|
|
194
306
|
* and take its own fail-closed output down with it.
|
|
195
307
|
*/
|
|
196
308
|
export const MAX_STDIN_BYTES: number;
|
|
309
|
+
/**
|
|
310
|
+
* The remedy {@link missingPackageMessage} states when the host does not supply
|
|
311
|
+
* one of its own. A host whose install has a specific entry point (a setup
|
|
312
|
+
* script, a devcontainer rebuild) passes that instead, so the reason names the
|
|
313
|
+
* command the reader should actually run.
|
|
314
|
+
*/
|
|
315
|
+
export const DEFAULT_MISSING_PACKAGE_REMEDY: "reinstall the hook dependencies (pnpm install) and retry.";
|
|
@@ -19,9 +19,29 @@ export function buildPreToolUseResponse(input: any, rehydrate?: (tool: string, t
|
|
|
19
19
|
* fail-closed posture holds even when the adapter never loaded.
|
|
20
20
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
21
21
|
* @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
|
|
22
|
+
* @param {{ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>, gates?: HostGate[] }} [opts]
|
|
23
|
+
* messages are merged over the defaults, so a partial table is supported
|
|
22
24
|
* @returns {Promise<import("agent-control-plane-core").Verdict>}
|
|
23
25
|
*/
|
|
24
|
-
export function judgePreToolUseSanitize(event: import("agent-control-plane-core").ToolCallEvent, rehydrate?: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted
|
|
26
|
+
export function judgePreToolUseSanitize(event: import("agent-control-plane-core").ToolCallEvent, rehydrate?: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>, opts?: {
|
|
27
|
+
messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
|
|
28
|
+
gates?: HostGate[];
|
|
29
|
+
}): Promise<import("agent-control-plane-core").Verdict>;
|
|
30
|
+
/**
|
|
31
|
+
* The dependency-load failure hiding behind a hook error, or "". A binding that
|
|
32
|
+
* never loaded surfaces at use time as a bare TypeError ("X is not a function")
|
|
33
|
+
* that names neither the package nor the remedy; when any lazily-loaded package
|
|
34
|
+
* has a recorded load error, name it — the failed set is derived from the
|
|
35
|
+
* loader's own records, so a future dependency is covered without editing a list
|
|
36
|
+
* here. An error already reporting a missing package (missingPackageError's
|
|
37
|
+
* `DEP_UNAVAILABLE` tag) gets no second copy.
|
|
38
|
+
* @param {unknown} err
|
|
39
|
+
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
40
|
+
* @param {() => string[]} [failedPackages]
|
|
41
|
+
* @param {(pkg: string) => unknown} [loadErrorFor]
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function depLoadHint(err: unknown, remedy?: string, failedPackages?: () => string[], loadErrorFor?: (pkg: string) => unknown): string;
|
|
25
45
|
/**
|
|
26
46
|
* The fail-closed hookSpecificOutput fields for a hook-level failure, chosen by
|
|
27
47
|
* WHICH failure it was. Corrupt/unparsable INPUT (`parsedOk` false — a JSON parse
|
|
@@ -32,16 +52,64 @@ export function judgePreToolUseSanitize(event: import("agent-control-plane-core"
|
|
|
32
52
|
* so it ASKS to keep a human in the loop rather than hard-block on infrastructure.
|
|
33
53
|
* @param {boolean} parsedOk whether the input parsed before the failure
|
|
34
54
|
* @param {unknown} err
|
|
55
|
+
* @param {{ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>, hint?: string }} [opts]
|
|
35
56
|
* @returns {Record<string, unknown>}
|
|
36
57
|
*/
|
|
37
|
-
export function failClosedFields(parsedOk: boolean, err: unknown
|
|
58
|
+
export function failClosedFields(parsedOk: boolean, err: unknown, opts?: {
|
|
59
|
+
messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
|
|
60
|
+
hint?: string;
|
|
61
|
+
}): Record<string, unknown>;
|
|
38
62
|
/**
|
|
39
63
|
* The hook's CLI: parse → judge → render, with this hook's fail-closed posture.
|
|
40
64
|
* Exported so a bundle entry (which must claim the CLI slot before this module
|
|
41
65
|
* loads) can run the exact same wiring instead of duplicating the onError
|
|
42
66
|
* posture.
|
|
67
|
+
* @param {{
|
|
68
|
+
* messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
|
|
69
|
+
* gates?: HostGate[],
|
|
70
|
+
* remedy?: string,
|
|
71
|
+
* }} [opts]
|
|
43
72
|
* @returns {Promise<void>}
|
|
44
73
|
*/
|
|
45
|
-
export function cliMain(
|
|
74
|
+
export function cliMain(opts?: {
|
|
75
|
+
messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
|
|
76
|
+
gates?: HostGate[];
|
|
77
|
+
remedy?: string;
|
|
78
|
+
}): Promise<void>;
|
|
79
|
+
/**
|
|
80
|
+
* A host-supplied deny gate: given the PreToolUse input, the reason this call
|
|
81
|
+
* must be blocked, or null to let the pipeline continue. Hosts use these for
|
|
82
|
+
* policy the package has no view of (a required workflow step, a project-local
|
|
83
|
+
* rule); the package ships none.
|
|
84
|
+
* @typedef {(input: { tool_name: string | null, tool_input: any, session_id?: string })
|
|
85
|
+
* => string | null | undefined} HostGate
|
|
86
|
+
*/
|
|
87
|
+
/**
|
|
88
|
+
* The reasons this hook emits, as a table a host overrides. A host that knows
|
|
89
|
+
* which of ITS files wires the adapter, and what a reader should do about a
|
|
90
|
+
* failure, can say so — the package cannot, since it has no idea where it is
|
|
91
|
+
* installed.
|
|
92
|
+
* @type {Readonly<{
|
|
93
|
+
* unknownEvent: string,
|
|
94
|
+
* failed: (cause: string) => string,
|
|
95
|
+
* unparsable: (cause: string) => string,
|
|
96
|
+
* }>}
|
|
97
|
+
*/
|
|
98
|
+
export const PRE_TOOL_USE_MESSAGES: Readonly<{
|
|
99
|
+
unknownEvent: string;
|
|
100
|
+
failed: (cause: string) => string;
|
|
101
|
+
unparsable: (cause: string) => string;
|
|
102
|
+
}>;
|
|
103
|
+
/**
|
|
104
|
+
* A host-supplied deny gate: given the PreToolUse input, the reason this call
|
|
105
|
+
* must be blocked, or null to let the pipeline continue. Hosts use these for
|
|
106
|
+
* policy the package has no view of (a required workflow step, a project-local
|
|
107
|
+
* rule); the package ships none.
|
|
108
|
+
*/
|
|
109
|
+
export type HostGate = (input: {
|
|
110
|
+
tool_name: string | null;
|
|
111
|
+
tool_input: any;
|
|
112
|
+
session_id?: string;
|
|
113
|
+
}) => string | null | undefined;
|
|
46
114
|
declare const rehydrateRedacted: typeof import("agent-sanitizer/rehydrate").rehydrateRedacted;
|
|
47
115
|
export {};
|
|
@@ -118,12 +118,17 @@ export function failClosedReplacement(input: any, message: string): any;
|
|
|
118
118
|
*/
|
|
119
119
|
export function sanitizerDepsLoaded(): boolean;
|
|
120
120
|
/**
|
|
121
|
-
* The model-facing note for a fail-closed emission
|
|
122
|
-
*
|
|
121
|
+
* The model-facing note for a fail-closed emission. When the sanitizer's own
|
|
122
|
+
* bindings are what is absent — a broken INSTALL rather than a broken hook, and
|
|
123
|
+
* otherwise invisible because every later tool call then fails closed with no
|
|
124
|
+
* stated cause — the recorded loader error and its remedy ride along. The text
|
|
125
|
+
* comes from missingPackageMessage so this hook, the PreToolUse gate and the
|
|
126
|
+
* prompt gate cannot drift apart on what a missing dependency reads like.
|
|
123
127
|
* @param {() => boolean} [depsLoaded] injectable seam for testing
|
|
128
|
+
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
124
129
|
* @returns {string}
|
|
125
130
|
*/
|
|
126
|
-
export function failClosedContext(depsLoaded?: () => boolean): string;
|
|
131
|
+
export function failClosedContext(depsLoaded?: () => boolean, remedy?: string): string;
|
|
127
132
|
/**
|
|
128
133
|
* Emit a fail-closed PostToolUse response, robust to the suppression itself
|
|
129
134
|
* throwing. The shape-matching replacement walks `input.tool_response` and the
|
|
@@ -8,16 +8,39 @@
|
|
|
8
8
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
9
9
|
* @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
|
|
10
10
|
* to the package's stripAnsiFully; injectable so the fail-closed path is testable)
|
|
11
|
+
* @param {Partial<typeof USER_PROMPT_MESSAGES>} [overrides] reason overrides,
|
|
12
|
+
* merged over the defaults so a partial table can never leave a field unset
|
|
11
13
|
* @returns {import("agent-control-plane-core").Verdict}
|
|
12
14
|
*/
|
|
13
|
-
export function judgeSanitizeUserPrompt(event: import("agent-control-plane-core").ToolCallEvent, strip?: ((s: string) => string) | null): import("agent-control-plane-core").Verdict;
|
|
15
|
+
export function judgeSanitizeUserPrompt(event: import("agent-control-plane-core").ToolCallEvent, strip?: ((s: string) => string) | null, overrides?: Partial<typeof USER_PROMPT_MESSAGES>): import("agent-control-plane-core").Verdict;
|
|
14
16
|
/**
|
|
15
17
|
* @param {() => Promise<any> | any} read
|
|
16
18
|
* @param {(chunk: string) => void} write
|
|
17
19
|
* @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
|
|
18
20
|
* to the package's stripAnsiFully; injectable so the fail-closed path is testable)
|
|
21
|
+
* @param {Partial<typeof USER_PROMPT_MESSAGES>} [overrides] reason overrides,
|
|
22
|
+
* merged over the defaults so a partial table can never leave a field unset
|
|
19
23
|
* @returns {Promise<void>}
|
|
20
24
|
*/
|
|
21
|
-
export function main(read: () => Promise<any> | any, write: (chunk: string) => void, strip?: ((s: string) => string) | null): Promise<void>;
|
|
25
|
+
export function main(read: () => Promise<any> | any, write: (chunk: string) => void, strip?: ((s: string) => string) | null, overrides?: Partial<typeof USER_PROMPT_MESSAGES>): Promise<void>;
|
|
22
26
|
/** @type {typeof import("agent-sanitizer/prompt").classifyPrompt} */
|
|
23
27
|
export let classifyPrompt: typeof import("agent-sanitizer/prompt").classifyPrompt;
|
|
28
|
+
/**
|
|
29
|
+
* The reasons this gate emits, as a table a host overrides. A host that knows
|
|
30
|
+
* which of ITS files wires the adapter, and what a reader should do about a
|
|
31
|
+
* failure, can say so — the package cannot, since it has no idea where it is
|
|
32
|
+
* installed. Every field is a plain string or a string-returning function, so
|
|
33
|
+
* an override is auditable next to the default it replaces.
|
|
34
|
+
* @type {Readonly<{
|
|
35
|
+
* unknownEvent: string,
|
|
36
|
+
* blockContext: string,
|
|
37
|
+
* sgrNote: string,
|
|
38
|
+
* hookFailed: (cause: string) => string,
|
|
39
|
+
* }>}
|
|
40
|
+
*/
|
|
41
|
+
export const USER_PROMPT_MESSAGES: Readonly<{
|
|
42
|
+
unknownEvent: string;
|
|
43
|
+
blockContext: string;
|
|
44
|
+
sgrNote: string;
|
|
45
|
+
hookFailed: (cause: string) => string;
|
|
46
|
+
}>;
|
|
@@ -42,9 +42,9 @@ export function scanFile(filePath: string): Array<{
|
|
|
42
42
|
}>;
|
|
43
43
|
import { ALERT_FILE } from "./lib/invisible-alert.mjs";
|
|
44
44
|
import { ALERT_ACK_FILE } from "./lib/invisible-alert.mjs";
|
|
45
|
-
export
|
|
46
|
-
export
|
|
47
|
-
export
|
|
45
|
+
export let LONG_RUN_RE: RegExp;
|
|
46
|
+
export let LONG_RUN_THRESHOLD: 10;
|
|
47
|
+
export let TOTAL_INVISIBLE_THRESHOLD: 30;
|
|
48
48
|
/**
|
|
49
49
|
* @param {Array<{
|
|
50
50
|
* file: string,
|