agent-sanitizer 2.9.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/plugin-hooks.mjs +1 -0
- package/claude-hooks/pretooluse-sanitize.mjs +8 -3
- package/claude-hooks/sanitize-output.mjs +13 -1
- package/claude-hooks/sanitize-user-prompt.mjs +40 -19
- package/package.json +1 -1
- package/types/claude-hooks/pretooluse-sanitize.d.mts +2 -2
- package/types/claude-hooks/sanitize-output.d.mts +14 -1
- package/types/claude-hooks/sanitize-user-prompt.d.mts +2 -0
|
@@ -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
|
};
|
|
@@ -72,6 +72,7 @@ const HOOK_NAME = "pretooluse-sanitize";
|
|
|
72
72
|
* unknownEvent: string,
|
|
73
73
|
* failed: (cause: string) => string,
|
|
74
74
|
* unparsable: (cause: string) => string,
|
|
75
|
+
* remedy: string,
|
|
75
76
|
* }>}
|
|
76
77
|
*/
|
|
77
78
|
export const PRE_TOOL_USE_MESSAGES = Object.freeze({
|
|
@@ -79,6 +80,11 @@ export const PRE_TOOL_USE_MESSAGES = Object.freeze({
|
|
|
79
80
|
"PreToolUse sanitization blocked (fail-closed): unrecognized hook payload.",
|
|
80
81
|
failed: (cause) => `PreToolUse sanitization failed (fail-closed): ${cause}`,
|
|
81
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,
|
|
82
88
|
});
|
|
83
89
|
|
|
84
90
|
// Layers 2 & 4 come from the agent-sanitizer package, bound via lazyImport (see
|
|
@@ -413,12 +419,11 @@ export function failClosedFields(parsedOk, err, opts = {}) {
|
|
|
413
419
|
* @param {{
|
|
414
420
|
* messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
|
|
415
421
|
* gates?: HostGate[],
|
|
416
|
-
* remedy?: string,
|
|
417
422
|
* }} [opts]
|
|
418
423
|
* @returns {Promise<void>}
|
|
419
424
|
*/
|
|
420
425
|
export async function cliMain(opts = {}) {
|
|
421
|
-
const { gates = []
|
|
426
|
+
const { gates = [] } = opts;
|
|
422
427
|
const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
|
|
423
428
|
await runJudgeCli(
|
|
424
429
|
HOOK_NAME,
|
|
@@ -434,7 +439,7 @@ export async function cliMain(opts = {}) {
|
|
|
434
439
|
HookEvent.PRE_TOOL_USE,
|
|
435
440
|
failClosedFields(input !== undefined, err, {
|
|
436
441
|
messages,
|
|
437
|
-
hint: depLoadHint(err, remedy),
|
|
442
|
+
hint: depLoadHint(err, messages.remedy),
|
|
438
443
|
}),
|
|
439
444
|
),
|
|
440
445
|
},
|
|
@@ -195,6 +195,11 @@ async function redactSecrets(text, webIngress = false, deadline) {
|
|
|
195
195
|
* @property {(record: { tool: string | null, modified: boolean, output: unknown, context?: string }) => Promise<void> | void} [audit]
|
|
196
196
|
* Awaited once per judged event that carried a tool response, with the output
|
|
197
197
|
* the model will actually see.
|
|
198
|
+
* @property {string} [remedy]
|
|
199
|
+
* What a reader should run when the sanitizer's own bindings are what is
|
|
200
|
+
* missing. This hook's host channel is `ext`, where the other two gates use a
|
|
201
|
+
* frozen message table; either way it is one channel per gate, so a host
|
|
202
|
+
* cannot supply its wording somewhere the fail-closed context never reads.
|
|
198
203
|
*/
|
|
199
204
|
|
|
200
205
|
/**
|
|
@@ -528,14 +533,19 @@ export function failClosedContext(
|
|
|
528
533
|
* @param {any} input parsed hook input, or undefined if parsing threw
|
|
529
534
|
* @param {string} message
|
|
530
535
|
* @param {(fields: Record<string, unknown>) => void} [emit]
|
|
536
|
+
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
531
537
|
* @returns {void}
|
|
532
538
|
*/
|
|
533
539
|
export function emitFailClosed(
|
|
534
540
|
input,
|
|
535
541
|
message,
|
|
536
542
|
emit = (fields) => emitHookResponse(HookEvent.POST_TOOL_USE, fields),
|
|
543
|
+
remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
537
544
|
) {
|
|
538
|
-
|
|
545
|
+
// Threaded rather than defaulted here: this is the ONLY production caller of
|
|
546
|
+
// failClosedContext, so a remedy it does not pass is a remedy no host can ever
|
|
547
|
+
// reach — the parameter would be live only from tests.
|
|
548
|
+
const additionalContext = failClosedContext(sanitizerDepsLoaded, remedy);
|
|
539
549
|
try {
|
|
540
550
|
emit({
|
|
541
551
|
updatedToolOutput: failClosedReplacement(input, message),
|
|
@@ -774,6 +784,8 @@ export async function cliMain(ext = {}) {
|
|
|
774
784
|
"[SANITIZATION FAILED — original output suppressed for safety. Hook error: " +
|
|
775
785
|
safeErrMessage(err) +
|
|
776
786
|
"]",
|
|
787
|
+
undefined,
|
|
788
|
+
ext.remedy,
|
|
777
789
|
),
|
|
778
790
|
},
|
|
779
791
|
);
|
|
@@ -22,7 +22,9 @@ import {
|
|
|
22
22
|
readStdinJson,
|
|
23
23
|
safeErrMessage,
|
|
24
24
|
isMain,
|
|
25
|
+
lazyImport,
|
|
25
26
|
missingPackageError,
|
|
27
|
+
DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
26
28
|
} from "./lib/hook-io.mjs";
|
|
27
29
|
import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
|
|
28
30
|
import { trace, TraceEvent } from "./lib/trace.mjs";
|
|
@@ -31,8 +33,9 @@ import { trace, TraceEvent } from "./lib/trace.mjs";
|
|
|
31
33
|
// import, never a bare top-level `import … from "…"`: a static npm import
|
|
32
34
|
// resolves before any try/catch, so a missing node_modules would crash this hook
|
|
33
35
|
// at load and let the prompt through UNSANITIZED (fail-open). A failed load
|
|
34
|
-
// leaves
|
|
35
|
-
// fail-closed block
|
|
36
|
+
// leaves that binding undefined, which the judge's typeof guards turn into a
|
|
37
|
+
// fail-closed block — one guard each, since the two loads succeed or fail
|
|
38
|
+
// independently.
|
|
36
39
|
/** @type {typeof import("agent-sanitizer/prompt").classifyPrompt} */
|
|
37
40
|
export let classifyPrompt;
|
|
38
41
|
/** @type {typeof import("agent-sanitizer").stripAnsiFully} */
|
|
@@ -49,6 +52,7 @@ let stripAnsiFully;
|
|
|
49
52
|
* blockContext: string,
|
|
50
53
|
* sgrNote: string,
|
|
51
54
|
* hookFailed: (cause: string) => string,
|
|
55
|
+
* remedy: string,
|
|
52
56
|
* }>}
|
|
53
57
|
*/
|
|
54
58
|
export const USER_PROMPT_MESSAGES = Object.freeze({
|
|
@@ -59,24 +63,31 @@ export const USER_PROMPT_MESSAGES = Object.freeze({
|
|
|
59
63
|
"The prompt contains ANSI SGR color codes (pasted terminal output). They are display-only formatting noise; read through them.",
|
|
60
64
|
hookFailed: (cause) =>
|
|
61
65
|
`sanitize-user-prompt hook failed (fail-closed): ${cause}`,
|
|
66
|
+
// What a reader should run when the package itself is what is missing. It
|
|
67
|
+
// rides in this table rather than a separate argument because it is host text
|
|
68
|
+
// exactly like the reasons above, and one channel means a host cannot supply
|
|
69
|
+
// its wording in one place and forget it in the other.
|
|
70
|
+
remedy: DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
62
71
|
});
|
|
63
72
|
|
|
64
73
|
/* c8 ignore start — module-load boundary: the imports resolve in every real
|
|
65
74
|
* run, and their failure (the package absent) can't be simulated in-process, so
|
|
66
|
-
* neither arm is observable to the in-process tests.
|
|
75
|
+
* neither arm is observable to the in-process tests. The judge's typeof guard
|
|
67
76
|
* converts an undefined stripper into a fail-closed block — that guard IS tested. */
|
|
68
77
|
// Stryker disable all
|
|
69
|
-
try
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
78
|
+
// lazyImport rather than a bare `await import` in a try/catch: it RECORDS the
|
|
79
|
+
// loader error, which is the only thing that can tell a missing install apart
|
|
80
|
+
// from a present package missing an export. Discarding it leaves
|
|
81
|
+
// missingPackageError with nothing to report but a guess.
|
|
82
|
+
// The cast asserts the loaded SHAPE, not that it loaded: lazyImport yields {} on
|
|
83
|
+
// failure, so either binding can still be undefined here — which is exactly what
|
|
84
|
+
// the judge's typeof guard turns into a fail-closed block.
|
|
85
|
+
({ classifyPrompt } = /** @type {typeof import("agent-sanitizer/prompt")} */ (
|
|
86
|
+
await lazyImport("agent-sanitizer/prompt")
|
|
87
|
+
));
|
|
88
|
+
({ stripAnsiFully } = /** @type {typeof import("agent-sanitizer")} */ (
|
|
89
|
+
await lazyImport("agent-sanitizer")
|
|
90
|
+
));
|
|
80
91
|
// Stryker restore all
|
|
81
92
|
/* c8 ignore stop */
|
|
82
93
|
|
|
@@ -114,11 +125,21 @@ export function judgeSanitizeUserPrompt(
|
|
|
114
125
|
return { decision: Decision.DENY, reason: messages.unknownEvent };
|
|
115
126
|
if (event.event !== EventKind.PROMPT_SUBMIT)
|
|
116
127
|
return { decision: Decision.ALLOW };
|
|
117
|
-
// The module-load guard
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
128
|
+
// The module-load guard, one arm per binding. The two lazyImports are
|
|
129
|
+
// INDEPENDENT — each yields {} on its own failure — so a present stripper does
|
|
130
|
+
// not prove the classifier loaded, and guarding on it alone would let a
|
|
131
|
+
// classifier-only failure reach `classifyPrompt(...)` as a bare TypeError
|
|
132
|
+
// naming no package, no cause and no remedy: the exact diagnostic this hook
|
|
133
|
+
// now exists to produce. lazyImportErrorFor matches subpaths, so naming
|
|
134
|
+
// `agent-sanitizer/prompt` still recovers the recorded cause.
|
|
135
|
+
if (typeof strip !== "function")
|
|
136
|
+
throw missingPackageError("agent-sanitizer", undefined, messages.remedy);
|
|
137
|
+
if (typeof classifyPrompt !== "function")
|
|
138
|
+
throw missingPackageError(
|
|
139
|
+
"agent-sanitizer/prompt",
|
|
140
|
+
undefined,
|
|
141
|
+
messages.remedy,
|
|
142
|
+
);
|
|
122
143
|
// The contract guarantees a string here: every adapter normalizes the
|
|
123
144
|
// prompt-submit input (Claude's parse coerces a missing/non-string prompt to
|
|
124
145
|
// "" via asString), so a defensive typeof re-check is a dead branch.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.1",
|
|
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": {
|
|
@@ -67,14 +67,12 @@ export function failClosedFields(parsedOk: boolean, err: unknown, opts?: {
|
|
|
67
67
|
* @param {{
|
|
68
68
|
* messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
|
|
69
69
|
* gates?: HostGate[],
|
|
70
|
-
* remedy?: string,
|
|
71
70
|
* }} [opts]
|
|
72
71
|
* @returns {Promise<void>}
|
|
73
72
|
*/
|
|
74
73
|
export function cliMain(opts?: {
|
|
75
74
|
messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
|
|
76
75
|
gates?: HostGate[];
|
|
77
|
-
remedy?: string;
|
|
78
76
|
}): Promise<void>;
|
|
79
77
|
/**
|
|
80
78
|
* A host-supplied deny gate: given the PreToolUse input, the reason this call
|
|
@@ -93,12 +91,14 @@ export function cliMain(opts?: {
|
|
|
93
91
|
* unknownEvent: string,
|
|
94
92
|
* failed: (cause: string) => string,
|
|
95
93
|
* unparsable: (cause: string) => string,
|
|
94
|
+
* remedy: string,
|
|
96
95
|
* }>}
|
|
97
96
|
*/
|
|
98
97
|
export const PRE_TOOL_USE_MESSAGES: Readonly<{
|
|
99
98
|
unknownEvent: string;
|
|
100
99
|
failed: (cause: string) => string;
|
|
101
100
|
unparsable: (cause: string) => string;
|
|
101
|
+
remedy: string;
|
|
102
102
|
}>;
|
|
103
103
|
/**
|
|
104
104
|
* A host-supplied deny gate: given the PreToolUse input, the reason this call
|
|
@@ -30,6 +30,11 @@
|
|
|
30
30
|
* @property {(record: { tool: string | null, modified: boolean, output: unknown, context?: string }) => Promise<void> | void} [audit]
|
|
31
31
|
* Awaited once per judged event that carried a tool response, with the output
|
|
32
32
|
* the model will actually see.
|
|
33
|
+
* @property {string} [remedy]
|
|
34
|
+
* What a reader should run when the sanitizer's own bindings are what is
|
|
35
|
+
* missing. This hook's host channel is `ext`, where the other two gates use a
|
|
36
|
+
* frozen message table; either way it is one channel per gate, so a host
|
|
37
|
+
* cannot supply its wording somewhere the fail-closed context never reads.
|
|
33
38
|
*/
|
|
34
39
|
/**
|
|
35
40
|
* Run Layers 1-4 over a single text blob, delegated to the package's output seam
|
|
@@ -142,9 +147,10 @@ export function failClosedContext(depsLoaded?: () => boolean, remedy?: string):
|
|
|
142
147
|
* @param {any} input parsed hook input, or undefined if parsing threw
|
|
143
148
|
* @param {string} message
|
|
144
149
|
* @param {(fields: Record<string, unknown>) => void} [emit]
|
|
150
|
+
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
145
151
|
* @returns {void}
|
|
146
152
|
*/
|
|
147
|
-
export function emitFailClosed(input: any, message: string, emit?: (fields: Record<string, unknown>) => void): void;
|
|
153
|
+
export function emitFailClosed(input: any, message: string, emit?: (fields: Record<string, unknown>) => void, remedy?: string): void;
|
|
148
154
|
/**
|
|
149
155
|
* Run the sanitization pipeline over a tool output and return the contract-
|
|
150
156
|
* shaped verdict fields — `mutated_output` (the shape-matching sanitized value)
|
|
@@ -266,4 +272,11 @@ export type SanitizeExtensions = {
|
|
|
266
272
|
output: unknown;
|
|
267
273
|
context?: string;
|
|
268
274
|
}) => Promise<void> | void) | undefined;
|
|
275
|
+
/**
|
|
276
|
+
* What a reader should run when the sanitizer's own bindings are what is
|
|
277
|
+
* missing. This hook's host channel is `ext`, where the other two gates use a
|
|
278
|
+
* frozen message table; either way it is one channel per gate, so a host
|
|
279
|
+
* cannot supply its wording somewhere the fail-closed context never reads.
|
|
280
|
+
*/
|
|
281
|
+
remedy?: string | undefined;
|
|
269
282
|
};
|
|
@@ -36,6 +36,7 @@ export let classifyPrompt: typeof import("agent-sanitizer/prompt").classifyPromp
|
|
|
36
36
|
* blockContext: string,
|
|
37
37
|
* sgrNote: string,
|
|
38
38
|
* hookFailed: (cause: string) => string,
|
|
39
|
+
* remedy: string,
|
|
39
40
|
* }>}
|
|
40
41
|
*/
|
|
41
42
|
export const USER_PROMPT_MESSAGES: Readonly<{
|
|
@@ -43,4 +44,5 @@ export const USER_PROMPT_MESSAGES: Readonly<{
|
|
|
43
44
|
blockContext: string;
|
|
44
45
|
sgrNote: string;
|
|
45
46
|
hookFailed: (cause: string) => string;
|
|
47
|
+
remedy: string;
|
|
46
48
|
}>;
|