agent-sanitizer 2.19.2 → 2.19.4

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.
@@ -16,7 +16,9 @@
16
16
  * BOTH a confusable AND a stego payload had one fix non-deterministically
17
17
  * clobbered by the other. Composing them here makes the rewrite deterministic
18
18
  * (normalize, then strip the normalized text) and pays a single Node start
19
- * instead of three on the hottest path.
19
+ * instead of three on the hottest path. Layers 2-4 run through the declared
20
+ * pipeline in lib/layer-pipeline.mjs, which is what keeps the confusable fold's
21
+ * skip decisions sound once the erasing strip follows it.
20
22
  *
21
23
  * Layers 2 and 4 are the provider-agnostic transforms in the agent-sanitizer
22
24
  * package; this file binds its peers (namespace-guard, the redactor daemon, the
@@ -34,11 +36,11 @@ import {
34
36
  registeredLazyModule,
35
37
  emitHookResponse,
36
38
  safeErrMessage,
37
- failOpenEnabled,
38
- failOpenContext,
39
39
  HookEvent,
40
40
  PermissionDecision,
41
41
  } from "./lib/hook-io.mjs";
42
+ import { registerFaultPolicy, hookFaultOutcome } from "./lib/hook-fault.mjs";
43
+ import { runLayerPipeline } from "./lib/layer-pipeline.mjs";
42
44
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
43
45
  import {
44
46
  invisibleCharAlert,
@@ -168,6 +170,83 @@ function emitTraced(emitTrace, toolName, fields) {
168
170
  return fields;
169
171
  }
170
172
 
173
+ /**
174
+ * The declared layer chain, layers 2-4. Every entry states the two properties
175
+ * the driver reasons about — whether it ERASES code points another layer reads,
176
+ * and whether its own decision is SKIP-BASED and therefore invalidated by such
177
+ * an erasure — so the confusable fold's ordering precondition is enforced by the
178
+ * table instead of restated as a comment.
179
+ *
180
+ * Layer 3 is dropped from the table (not merely skipped at run time) when
181
+ * AGENT_SANITIZER_OUTPUT_DISABLED=1, so the driver sees the chain that will
182
+ * actually run: with no erasing layer left after the fold there is no fixed
183
+ * point to reach and no extra pass to pay for.
184
+ * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} rehydrate
185
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
186
+ * @returns {import("./lib/layer-pipeline.mjs").Layer[]}
187
+ */
188
+ export function preToolUseLayers(rehydrate, env = process.env) {
189
+ /** @type {import("./lib/layer-pipeline.mjs").Layer[]} */
190
+ const layers = [
191
+ {
192
+ name: "confusables",
193
+ // Folding SUBSTITUTES a glyph for its ASCII canon, which drops the
194
+ // original code point; and it SKIPS any token still holding an unmapped
195
+ // glyph, which is the decision an erasure can invalidate.
196
+ erases: true,
197
+ skipBased: true,
198
+ run: (tool, toolInput) => {
199
+ const norm = normalizeConfusables(tool, toolInput, {
200
+ scan: confusableScan,
201
+ });
202
+ return norm === null
203
+ ? null
204
+ : {
205
+ updatedInput: norm.updatedInput,
206
+ context: normalizeContext(norm.normalized),
207
+ };
208
+ },
209
+ },
210
+ {
211
+ name: "authored-content",
212
+ // Erases payload-capable invisible characters, and skips below the
213
+ // payload-capable floor — the erasure that broke the fold's precondition.
214
+ erases: true,
215
+ skipBased: true,
216
+ run: (tool, toolInput) => {
217
+ const authored = sanitizeAuthoredContent(tool, toolInput);
218
+ return authored === null
219
+ ? null
220
+ : {
221
+ updatedInput: authored.updatedInput,
222
+ context: authoredContext(authored.changed),
223
+ };
224
+ },
225
+ },
226
+ {
227
+ name: "rehydrate",
228
+ erases: true,
229
+ skipBased: false,
230
+ // Terminal by contract, not by luck: it re-anchors Edit/Write inputs onto
231
+ // the on-disk bytes, and the secrets it restores must NOT be re-stripped
232
+ // by layer 3 or re-folded by layer 2 on a later pass.
233
+ terminal: true,
234
+ run: async (tool, toolInput) => {
235
+ const rehydrated = await rehydrate(tool, toolInput);
236
+ if (!rehydrated) return null;
237
+ if ("deny" in rehydrated) return { deny: rehydrated.deny };
238
+ return {
239
+ updatedInput: rehydrated.updatedInput,
240
+ context: rehydrated.context,
241
+ };
242
+ },
243
+ },
244
+ ];
245
+ return env.AGENT_SANITIZER_OUTPUT_DISABLED === "1"
246
+ ? layers.filter((layer) => layer.name !== "authored-content")
247
+ : layers;
248
+ }
249
+
171
250
  /**
172
251
  * Compose the four protections. Returns the `hookSpecificOutput` fields to
173
252
  * emit, or null for a clean no-op. Throws only if a layer's engine throws; the
@@ -207,44 +286,21 @@ export async function buildPreToolUseResponse(
207
286
 
208
287
  const { tool_name: tool, tool_input: toolInput } = input;
209
288
 
210
- // Layers 2 then 3, chained: normalize confusables first, then strip authored
211
- // stego/terminal-control from the normalized text.
212
- let current = toolInput;
213
- let changed = false;
214
-
215
- const norm = normalizeConfusables(tool, current, { scan: confusableScan });
216
- if (norm) {
217
- current = norm.updatedInput;
218
- changed = true;
219
- contexts.push(normalizeContext(norm.normalized));
220
- }
221
-
222
- if (process.env.AGENT_SANITIZER_OUTPUT_DISABLED !== "1") {
223
- const authored = sanitizeAuthoredContent(tool, current);
224
- if (authored) {
225
- current = authored.updatedInput;
226
- changed = true;
227
- contexts.push(authoredContext(authored.changed));
228
- }
229
- }
230
-
231
- // Layer 4: re-anchor Edit/Write inputs composed from a sanitized file view
232
- // ([REDACTED…] placeholders, stripped invisible characters) back onto the
233
- // on-disk bytes. Runs last so it sees the final authored text and its
234
- // rehydrated secrets are not re-stripped by layer 3. An unresolvable or
235
- // secret-exposing call is denied outright — that verdict outranks any ask
236
- // above, so it returns immediately.
237
- const rehydrated = await rehydrate(tool, current);
238
- if (rehydrated && "deny" in rehydrated)
289
+ // Layers 2-4, run by the declared pipeline: the driver not this call order —
290
+ // is what keeps the confusable fold's soundness precondition true once an
291
+ // erasing layer follows it (see lib/layer-pipeline.mjs).
292
+ const {
293
+ updatedInput: current,
294
+ changed,
295
+ contexts: layerContexts,
296
+ deny,
297
+ } = await runLayerPipeline(tool, toolInput, preToolUseLayers(rehydrate));
298
+ if (deny !== undefined)
239
299
  return emitTraced(emitTrace, input.tool_name, {
240
300
  permissionDecision: PermissionDecision.DENY,
241
- permissionDecisionReason: rehydrated.deny,
301
+ permissionDecisionReason: deny,
242
302
  });
243
- if (rehydrated) {
244
- current = rehydrated.updatedInput;
245
- changed = true;
246
- contexts.push(rehydrated.context);
247
- }
303
+ contexts.push(...layerContexts);
248
304
 
249
305
  return emitTraced(
250
306
  emitTrace,
@@ -447,11 +503,30 @@ export function failClosedFields(parsedOk, err, opts = {}) {
447
503
  * @returns {Record<string, unknown>}
448
504
  */
449
505
  export function hookFailureFields(parsedOk, err, opts = {}) {
450
- if (failOpenEnabled(opts.env))
451
- return { additionalContext: failOpenContext(HOOK_NAME, "tool input", err) };
452
- return failClosedFields(parsedOk, err, opts);
506
+ return /** @type {Record<string, unknown>} */ (
507
+ hookFaultOutcome(HOOK_NAME, err, {
508
+ parsedOk,
509
+ env: opts.env,
510
+ messages: opts.messages,
511
+ hint: opts.hint,
512
+ }).fields
513
+ );
453
514
  }
454
515
 
516
+ // This hook's entry in the one posture table (lib/hook-fault.mjs). The OPEN arm
517
+ // is the shared default — a warning context and no verdict — so only the CLOSED
518
+ // verdict, which is this hook's own ask/deny split, is stated here.
519
+ registerFaultPolicy(HOOK_NAME, {
520
+ event: HookEvent.PRE_TOOL_USE,
521
+ guarded: "tool input",
522
+ closed: (ctx) => ({
523
+ fields: failClosedFields(ctx.parsedOk, ctx.err, {
524
+ messages: ctx.messages,
525
+ hint: ctx.hint,
526
+ }),
527
+ }),
528
+ });
529
+
455
530
  // Stryker disable all: CLI wiring — it runs only in the spawned hook
456
531
  // subprocess, never in-process, so every mutant from here down is NoCoverage.
457
532
  // The exported judgePreToolUseSanitize and failClosedFields above carry the
@@ -26,15 +26,13 @@ import {
26
26
  lazyImport,
27
27
  emitHookResponse,
28
28
  errMessage,
29
- safeErrMessage,
30
- failOpenEnabled,
31
- failOpenContext,
32
29
  makeDeadline,
33
30
  lazyImportErrorFor,
34
31
  missingPackageMessage,
35
32
  DEFAULT_MISSING_PACKAGE_REMEDY,
36
33
  HookEvent,
37
34
  } from "./lib/hook-io.mjs";
35
+ import { registerFaultPolicy, hookFaultOutcome } from "./lib/hook-fault.mjs";
38
36
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
39
37
  import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
40
38
  import { hasEnvBoundSecret } from "./lib/secret-annotate.mjs";
@@ -552,19 +550,48 @@ export function emitFailClosed(
552
550
  message,
553
551
  emit = (fields) => emitHookResponse(HookEvent.POST_TOOL_USE, fields),
554
552
  remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
553
+ ) {
554
+ const { fields, fallbackFields } = failClosedParts(input, message, remedy);
555
+ try {
556
+ emit(fields);
557
+ } catch {
558
+ emit(fallbackFields);
559
+ }
560
+ }
561
+
562
+ /**
563
+ * The fail-closed response fields plus the shallow fallback to emit if
564
+ * serializing them throws. Split out from {@link emitFailClosed} so the posture
565
+ * table can state this hook's CLOSED verdict as a VALUE — the table is what
566
+ * `test/claude-hooks-posture.test.mjs` compares each hook's emission against, so
567
+ * a verdict reachable only by running the emitter could not be pinned there.
568
+ * @param {any} input parsed hook input, or undefined if parsing threw
569
+ * @param {string} message
570
+ * @param {string} [remedy] what a reader should run; hosts pass their own
571
+ * @returns {{ fields: Record<string, unknown>, fallbackFields: Record<string, unknown> }}
572
+ */
573
+ function failClosedParts(
574
+ input,
575
+ message,
576
+ remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
555
577
  ) {
556
578
  // Threaded rather than defaulted here: this is the ONLY production caller of
557
579
  // failClosedContext, so a remedy it does not pass is a remedy no host can ever
558
580
  // reach — the parameter would be live only from tests.
559
581
  const additionalContext = failClosedContext(sanitizerDepsLoaded, remedy);
582
+ const fallbackFields = { updatedToolOutput: message, additionalContext };
583
+ let updatedToolOutput;
560
584
  try {
561
- emit({
562
- updatedToolOutput: failClosedReplacement(input, message),
563
- additionalContext,
564
- });
585
+ updatedToolOutput = failClosedReplacement(input, message);
565
586
  } catch {
566
- emit({ updatedToolOutput: message, additionalContext });
587
+ // The shape-matching walk overflowed on a pathologically deep (but valid)
588
+ // tool_response. The bare string is shallow, always serializable, and still
589
+ // a valid string tool_response — so the hook stays CLOSED rather than
590
+ // throwing out of its own failure path, which would emit nothing and let
591
+ // the harness show the raw, unvetted output.
592
+ return { fields: fallbackFields, fallbackFields };
567
593
  }
594
+ return { fields: { updatedToolOutput, additionalContext }, fallbackFields };
568
595
  }
569
596
 
570
597
  /**
@@ -595,20 +622,40 @@ export function emitHookFailure(
595
622
  remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
596
623
  env = process.env,
597
624
  ) {
598
- if (failOpenEnabled(env)) {
599
- emit({
600
- additionalContext: failOpenContext("sanitize-output", "tool output", err),
601
- });
602
- return;
625
+ const outcome = hookFaultOutcome(HOOK_NAME, err, { input, remedy, env });
626
+ const fields = /** @type {Record<string, unknown>} */ (outcome.fields);
627
+ try {
628
+ emit(fields);
629
+ } catch (emitErr) {
630
+ // The open posture's fields are a lone string context — always
631
+ // serializable — so it declares no fallback, and a throw there is a real
632
+ // bug the caller must see rather than a suppression to retry.
633
+ if (outcome.fallbackFields === null) throw emitErr;
634
+ emit(outcome.fallbackFields);
603
635
  }
604
- emitFailClosed(
605
- input,
606
- `[SANITIZATION FAILED — original output suppressed for safety. Hook error: ${safeErrMessage(err)}]`,
607
- emit,
608
- remedy,
609
- );
610
636
  }
611
637
 
638
+ /**
639
+ * The suppression placeholder that replaces the tool output under the closed
640
+ * posture. Named so the posture table and {@link emitFailClosed} cannot drift on
641
+ * the wording the model sees.
642
+ * @param {string} cause the scrubbed hook error
643
+ * @returns {string}
644
+ */
645
+ function suppressionMessage(cause) {
646
+ return `[SANITIZATION FAILED — original output suppressed for safety. Hook error: ${cause}]`;
647
+ }
648
+
649
+ // This hook's entry in the one posture table (lib/hook-fault.mjs). OPEN is the
650
+ // shared default (a warning context, the original output left in the model's
651
+ // view); CLOSED replaces every string leaf of the output with the placeholder.
652
+ registerFaultPolicy(HOOK_NAME, {
653
+ event: HookEvent.POST_TOOL_USE,
654
+ guarded: "tool output",
655
+ closed: (ctx) =>
656
+ failClosedParts(ctx.input, suppressionMessage(ctx.message), ctx.remedy),
657
+ });
658
+
612
659
  /**
613
660
  * Run the sanitization pipeline over a tool output and return the contract-
614
661
  * shaped verdict fields — `mutated_output` (the shape-matching sanitized value)
@@ -20,15 +20,17 @@
20
20
  */
21
21
  import {
22
22
  readStdinJson,
23
- safeErrMessage,
24
- failOpenEnabled,
25
- failOpenContext,
26
23
  HookEvent,
27
24
  isMain,
28
25
  lazyImport,
29
26
  missingPackageError,
30
27
  DEFAULT_MISSING_PACKAGE_REMEDY,
31
28
  } from "./lib/hook-io.mjs";
29
+ import {
30
+ registerFaultPolicy,
31
+ hookFaultOutcome,
32
+ writeFaultOutcome,
33
+ } from "./lib/hook-fault.mjs";
32
34
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
33
35
  import { bestEffortTrace, trace, TraceEvent } from "./lib/trace.mjs";
34
36
  // classifyPrompt (the user-prompt verdict) and stripAnsiFully (its ANSI stripper)
@@ -73,6 +75,27 @@ export const USER_PROMPT_MESSAGES = Object.freeze({
73
75
  remedy: DEFAULT_MISSING_PACKAGE_REMEDY,
74
76
  });
75
77
 
78
+ const HOOK_NAME = "sanitize-user-prompt";
79
+
80
+ // This hook's entry in the one posture table (lib/hook-fault.mjs). OPEN is the
81
+ // shared default (a warning context alongside the prompt); CLOSED is a
82
+ // top-level `decision: "block"`, NOT a hookSpecificOutput verdict —
83
+ // UserPromptSubmit has no permissionDecision channel, so this envelope shape is
84
+ // the gate's own and the table records it rather than a reader inferring it.
85
+ registerFaultPolicy(HOOK_NAME, {
86
+ event: HookEvent.USER_PROMPT_SUBMIT,
87
+ guarded: "prompt",
88
+ closed: (ctx) => ({
89
+ envelope: {
90
+ decision: "block",
91
+ reason: {
92
+ ...USER_PROMPT_MESSAGES,
93
+ ...ctx.messages,
94
+ }.hookFailed(ctx.message),
95
+ },
96
+ }),
97
+ });
98
+
76
99
  /* c8 ignore start — module-load boundary: the imports resolve in every real
77
100
  * run, and their failure (the package absent) can't be simulated in-process, so
78
101
  * neither arm is observable to the in-process tests. The judge's typeof guard
@@ -202,13 +225,13 @@ export async function main(read, write, opts = {}) {
202
225
  // exactly what may have failed to load. Either way this is the HOOK failing;
203
226
  // a prompt the working stripper flagged is still blocked in both postures.
204
227
  await runJudgeCli(
205
- "sanitize-user-prompt",
228
+ HOOK_NAME,
206
229
  (event) => {
207
230
  const verdict = judgeSanitizeUserPrompt(event, strip, messages);
208
231
  // Announce engagement on the trace channel like the other stdin hooks —
209
232
  // a prompt gate that silently stopped running is otherwise invisible.
210
233
  emitTrace(TraceEvent.HOOK_RAN, {
211
- hook: "sanitize-user-prompt",
234
+ hook: HOOK_NAME,
212
235
  outcome:
213
236
  verdict.decision === controlPlane().Decision.DENY
214
237
  ? "deny"
@@ -222,24 +245,9 @@ export async function main(read, write, opts = {}) {
222
245
  readInput: read,
223
246
  write,
224
247
  onError: (err) =>
225
- write(
226
- JSON.stringify(
227
- failOpenEnabled(env)
228
- ? {
229
- hookSpecificOutput: {
230
- hookEventName: HookEvent.USER_PROMPT_SUBMIT,
231
- additionalContext: failOpenContext(
232
- "sanitize-user-prompt",
233
- "prompt",
234
- err,
235
- ),
236
- },
237
- }
238
- : {
239
- decision: "block",
240
- reason: messages.hookFailed(safeErrMessage(err)),
241
- },
242
- ),
248
+ writeFaultOutcome(
249
+ hookFaultOutcome(HOOK_NAME, err, { messages, env }),
250
+ write,
243
251
  ),
244
252
  },
245
253
  );