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.
- package/README.md +5 -0
- package/THREAT-MODEL.md +24 -8
- package/claude-hooks/lib/hook-fault.mjs +224 -0
- package/claude-hooks/lib/layer-pipeline.mjs +147 -0
- package/claude-hooks/plugin-hooks.mjs +45 -9
- package/claude-hooks/pretooluse-sanitize.mjs +116 -41
- package/claude-hooks/sanitize-output.mjs +66 -19
- package/claude-hooks/sanitize-user-prompt.mjs +31 -23
- package/claude-hooks/scan-invisible-chars.mjs +235 -47
- package/package.json +1 -1
- package/src/ansi.mjs +207 -0
- package/src/confusables.mjs +6 -2
- package/src/html.mjs +130 -48
- package/src/invisible.mjs +202 -159
- package/src/layer1.mjs +101 -116
- package/src/output.mjs +28 -11
- package/src/prompt.mjs +9 -6
- package/src/rehydrate.mjs +13 -20
- package/src/view-map.mjs +59 -22
- package/types/ansi.d.mts +66 -0
- package/types/claude-hooks/lib/hook-fault.d.mts +104 -0
- package/types/claude-hooks/lib/layer-pipeline.d.mts +113 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +16 -0
- package/types/claude-hooks/scan-invisible-chars.d.mts +74 -14
- package/types/confusables.d.mts +6 -2
- package/types/invisible.d.mts +11 -2
- package/types/layer1.d.mts +19 -10
- package/types/output.d.mts +15 -2
- package/types/view-map.d.mts +47 -3
|
@@ -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
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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:
|
|
301
|
+
permissionDecisionReason: deny,
|
|
242
302
|
});
|
|
243
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
562
|
-
updatedToolOutput: failClosedReplacement(input, message),
|
|
563
|
-
additionalContext,
|
|
564
|
-
});
|
|
585
|
+
updatedToolOutput = failClosedReplacement(input, message);
|
|
565
586
|
} catch {
|
|
566
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
);
|