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.
@@ -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 = [], remedy } = opts;
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
- const additionalContext = failClosedContext();
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 the bindings undefined, which main()'s typeof guard turns into a
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. main()'s typeof guard
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
- // The /prompt subpath is imported first: if it fails, the catch fires before
71
- // stripAnsiFully is assigned, so a half-load can never leave the stripper set
72
- // while the classifier is missing (main guards on the stripper alone).
73
- ({ classifyPrompt } = await import("agent-sanitizer/prompt"));
74
- ({ stripAnsiFully } = await import("agent-sanitizer"));
75
- } catch {
76
- // Leave classifyPrompt/stripAnsiFully undefined so main()'s typeof guard fails
77
- // closed — the prompt is blocked, never passed through with the package
78
- // half-loaded.
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: a missing stripper means agent-sanitizer never
118
- // loaded. Guarding on the stripper alone is sufficientit loads AFTER
119
- // classifyPrompt in the same try, so a present stripper proves the classifier
120
- // loaded too.
121
- if (typeof strip !== "function") throw missingPackageError("agent-sanitizer");
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.0",
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
  }>;