agent-sanitizer 2.68.0 → 2.69.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/README.md +8 -6
- package/THREAT-MODEL.md +31 -18
- package/claude-hooks/lib/hook-fault.mjs +4 -2
- package/claude-hooks/lib/hook-io.mjs +84 -9
- package/claude-hooks/lib/invisible-alert.mjs +6 -3
- package/claude-hooks/lib/secret-drop-guard.mjs +136 -29
- package/claude-hooks/plugin-hooks.mjs +29 -24
- package/claude-hooks/pretooluse-sanitize.mjs +288 -62
- package/claude-hooks/sanitize-user-prompt.mjs +28 -15
- package/claude-hooks/scan-invisible-chars.mjs +86 -33
- package/claude-hooks/scan-loaded-instructions.mjs +20 -14
- package/package.json +1 -1
- package/src/html.mjs +17 -17
- package/src/output.mjs +5 -4
- package/src/rehydrate.mjs +36 -9
- package/types/claude-hooks/lib/hook-fault.d.mts +4 -2
- package/types/claude-hooks/lib/hook-io.d.mts +32 -6
- package/types/claude-hooks/lib/invisible-alert.d.mts +6 -3
- package/types/claude-hooks/lib/secret-drop-guard.d.mts +77 -9
- package/types/claude-hooks/pretooluse-sanitize.d.mts +28 -10
- package/types/claude-hooks/sanitize-user-prompt.d.mts +3 -2
- package/types/claude-hooks/scan-invisible-chars.d.mts +3 -3
- package/types/rehydrate.d.mts +8 -1
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
safeErrMessage,
|
|
42
42
|
HookEvent,
|
|
43
43
|
PermissionDecision,
|
|
44
|
+
FAIL_OPEN_ENV,
|
|
44
45
|
} from "./lib/hook-io.mjs";
|
|
45
46
|
import {
|
|
46
47
|
registerFaultPolicy,
|
|
@@ -68,7 +69,13 @@ import {
|
|
|
68
69
|
import { redactViaDaemon } from "./lib/redactor-client.mjs";
|
|
69
70
|
import { secretsEnabled } from "./lib/env-config.mjs";
|
|
70
71
|
import { readKnob } from "./lib/knobs.mjs";
|
|
71
|
-
import {
|
|
72
|
+
import {
|
|
73
|
+
withSecretDropGuard,
|
|
74
|
+
reissueFingerprint,
|
|
75
|
+
recordConfirm,
|
|
76
|
+
confirmFresh,
|
|
77
|
+
consumeConfirm,
|
|
78
|
+
} from "./lib/secret-drop-guard.mjs";
|
|
72
79
|
import {
|
|
73
80
|
containsPlaceholder,
|
|
74
81
|
placeholderNotice,
|
|
@@ -81,6 +88,20 @@ import { hookTrace, TraceEvent } from "./lib/trace.mjs";
|
|
|
81
88
|
|
|
82
89
|
const HOOK_NAME = "pretooluse-sanitize";
|
|
83
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The non-throw sites that route a sanitizer fault through this hook's fault
|
|
93
|
+
* policy, as `ctx.site`. A fault rendered by hookFailureFields (a throw the CLI
|
|
94
|
+
* caught, a pipeline that never settled) carries no site.
|
|
95
|
+
* - STAGE: a stage denied only because it could not do its job (its result
|
|
96
|
+
* carries `fault`); the closed arm keeps that deny.
|
|
97
|
+
* - GATE_ACK: the invisible-char gate's one-time ask could not be recorded as
|
|
98
|
+
* shown; the closed arm keeps the ask.
|
|
99
|
+
*/
|
|
100
|
+
const FaultSite = Object.freeze({ STAGE: "stage", GATE_ACK: "gate-ack" });
|
|
101
|
+
|
|
102
|
+
/** The `code` of the error a payload the adapter cannot classify raises. */
|
|
103
|
+
const UNKNOWN_EVENT_CODE = "UNKNOWN_EVENT";
|
|
104
|
+
|
|
84
105
|
/**
|
|
85
106
|
* A host-supplied deny gate: given the PreToolUse input, the reason this call
|
|
86
107
|
* must be blocked, or null to let the pipeline continue. Hosts use these for
|
|
@@ -104,6 +125,8 @@ const HOOK_NAME = "pretooluse-sanitize";
|
|
|
104
125
|
* }>}
|
|
105
126
|
*/
|
|
106
127
|
export const PRE_TOOL_USE_MESSAGES = Object.freeze({
|
|
128
|
+
// The CLOSED posture's reason for a payload the adapter cannot classify; the
|
|
129
|
+
// open posture lets that payload through with the shared fault warning.
|
|
107
130
|
unknownEvent:
|
|
108
131
|
"PreToolUse sanitization blocked (fail-closed): unrecognized hook payload.",
|
|
109
132
|
failed: (cause) => `PreToolUse sanitization failed (fail-closed): ${cause}`,
|
|
@@ -188,12 +211,13 @@ const guardedRehydrate = withSecretDropGuard(
|
|
|
188
211
|
/**
|
|
189
212
|
* Substitute every keyed Layer-2 placeholder in `text` with the stored original
|
|
190
213
|
* from the reveal store's span files, in ONE ordered pass (spliceOrdered).
|
|
191
|
-
* A key with NO stored span
|
|
192
|
-
*
|
|
193
|
-
*
|
|
214
|
+
* A key with NO stored span (never persisted, or the store is unusable) denies
|
|
215
|
+
* as a `fault` — the placeholder stands for real content this store cannot
|
|
216
|
+
* produce, so writing it through literally persists the loss the keyed grammar
|
|
217
|
+
* exists to prevent, and only the hook's fault posture may let that happen.
|
|
194
218
|
* @param {string} text
|
|
195
219
|
* @param {string} field the tool-input field being rehydrated, for the deny prose
|
|
196
|
-
* @returns {{ text: string, restored: number } | { deny: string } | null}
|
|
220
|
+
* @returns {{ text: string, restored: number } | { deny: string, fault: string } | null}
|
|
197
221
|
*/
|
|
198
222
|
function substituteLayer2(text, field) {
|
|
199
223
|
const matches = [...text.matchAll(LAYER2_PLACEHOLDER_RE)].map((match) => ({
|
|
@@ -211,16 +235,22 @@ function substituteLayer2(text, field) {
|
|
|
211
235
|
if (stored === null) missing.push(key);
|
|
212
236
|
else byKey.set(key, stored);
|
|
213
237
|
}
|
|
214
|
-
if (missing.length > 0)
|
|
238
|
+
if (missing.length > 0) {
|
|
239
|
+
const files = missing.map((key) => spanPath(key)).join(", ");
|
|
215
240
|
return {
|
|
216
241
|
deny:
|
|
217
242
|
`${field} contains Layer-2 removed-content placeholder(s) whose original is not ` +
|
|
218
243
|
`in the span store (missing key(s): ${missing.join(", ")}; expected file(s): ` +
|
|
219
|
-
`${
|
|
244
|
+
`${files}), so the removed content ` +
|
|
220
245
|
`cannot be restored automatically. Reconstruct that content yourself, ` +
|
|
221
246
|
`deliberately drop the placeholder(s) if the removed content should stay ` +
|
|
222
247
|
`removed, or ask the user to make this change`,
|
|
248
|
+
fault:
|
|
249
|
+
`the Layer-2 removed-content placeholder(s) with key(s) ${missing.join(", ")} ` +
|
|
250
|
+
`in ${field} have no stored original (expected file(s): ${files}), so ` +
|
|
251
|
+
`they are written to the file literally and the removed content is not restored`,
|
|
223
252
|
};
|
|
253
|
+
}
|
|
224
254
|
// `i` is the match's index in `matches` (stable across spliceOrdered's
|
|
225
255
|
// overlap skips — impossible here, distinct non-overlapping literals), so the
|
|
226
256
|
// key rides positionally.
|
|
@@ -263,7 +293,7 @@ function substituteLayer2(text, field) {
|
|
|
263
293
|
* @param {boolean} [secretsOn] the run's secret opt-in (decided once by
|
|
264
294
|
* buildPreToolUseResponse; a direct caller reads the env); only with it on
|
|
265
295
|
* does the context claim the restored bytes were redacted
|
|
266
|
-
* @returns {{ updatedInput: any, context: string } | { deny: string } | null}
|
|
296
|
+
* @returns {{ updatedInput: any, context: string } | { deny: string, fault?: string } | null}
|
|
267
297
|
*/
|
|
268
298
|
export function rehydrateLayer2(tool, toolInput, secretsOn = secretsEnabled()) {
|
|
269
299
|
const hasL2 = (/** @type {unknown} */ text) =>
|
|
@@ -337,6 +367,52 @@ function emitTraced(emitTrace, toolName, fields) {
|
|
|
337
367
|
return fields;
|
|
338
368
|
}
|
|
339
369
|
|
|
370
|
+
/**
|
|
371
|
+
* What the stage table hands back besides the pipeline's own result: the
|
|
372
|
+
* warnings of fault denies the open posture let through (the pipeline drops a
|
|
373
|
+
* stage's note when the input is unchanged, and these calls proceed unchanged),
|
|
374
|
+
* and the re-issue confirmations they rode on, consumed only once the call is
|
|
375
|
+
* known to proceed.
|
|
376
|
+
* @typedef {{ warnings: string[], released: string[] }} FaultLedger
|
|
377
|
+
*/
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Resolve one stage's deny. A deny without `fault` is the stage's verdict on
|
|
381
|
+
* the input and stands in every posture. A deny WITH one exists only because
|
|
382
|
+
* the stage could not do its job, so this hook's registered fault policy
|
|
383
|
+
* decides it: it stands under the closed posture, and under the open one it is
|
|
384
|
+
* refused once with a re-issue escape, then let through with a warning —
|
|
385
|
+
* carrying the stage's `release` rewrite when it has one, else unchanged.
|
|
386
|
+
* @param {string} tool
|
|
387
|
+
* @param {any} toolInput
|
|
388
|
+
* @param {{ deny: string, fault?: string, release?: { updatedInput: any, context: string } }} result
|
|
389
|
+
* @param {string} site the refusing stage, keying its re-issue confirmation
|
|
390
|
+
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} env
|
|
391
|
+
* @param {FaultLedger} ledger
|
|
392
|
+
* @returns {{ deny: string } | { updatedInput: any, context: string } | null}
|
|
393
|
+
*/
|
|
394
|
+
function resolveStageDeny(tool, toolInput, result, site, env, ledger) {
|
|
395
|
+
if (result.fault === undefined) return { deny: result.deny };
|
|
396
|
+
const fingerprint = reissueFingerprint(site, tool, toolInput);
|
|
397
|
+
const { fields } = hookFaultOutcome(HOOK_NAME, new Error(result.fault), {
|
|
398
|
+
env,
|
|
399
|
+
site: FaultSite.STAGE,
|
|
400
|
+
fingerprint,
|
|
401
|
+
reason: result.deny,
|
|
402
|
+
warning:
|
|
403
|
+
`WARNING: agent-sanitizer could not complete its ${site} stage on this ` +
|
|
404
|
+
`${tool} call and let it proceed ${result.release ? "rehydrated" : "unchanged"} ` +
|
|
405
|
+
`(fail-open): ${result.fault}. ` +
|
|
406
|
+
`Set ${FAIL_OPEN_ENV}=0 to refuse such calls instead.`,
|
|
407
|
+
});
|
|
408
|
+
const verdict = /** @type {Record<string, unknown>} */ (fields);
|
|
409
|
+
if (verdict.permissionDecision === PermissionDecision.DENY)
|
|
410
|
+
return { deny: String(verdict.permissionDecisionReason) };
|
|
411
|
+
ledger.warnings.push(String(verdict.additionalContext));
|
|
412
|
+
ledger.released.push(fingerprint);
|
|
413
|
+
return result.release ?? null;
|
|
414
|
+
}
|
|
415
|
+
|
|
340
416
|
/**
|
|
341
417
|
* The declared stage chain after the gate. Every entry states the two properties
|
|
342
418
|
* the driver reasons about — whether it ERASES code points another layer reads,
|
|
@@ -353,13 +429,33 @@ function emitTraced(emitTrace, toolName, fields) {
|
|
|
353
429
|
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
354
430
|
* @param {boolean} [secretsOn] the run's secret opt-in (see
|
|
355
431
|
* buildPreToolUseResponse, which decides it once and passes it here)
|
|
432
|
+
* @param {FaultLedger} [ledger] where a fault deny the open posture lets
|
|
433
|
+
* through leaves its warning; required as soon as a stage reports a fault,
|
|
434
|
+
* since a warning with nowhere to go would let the call through silently
|
|
356
435
|
* @returns {import("./lib/layer-pipeline.mjs").Layer[]}
|
|
357
436
|
*/
|
|
358
437
|
export function preToolUseLayers(
|
|
359
438
|
rehydrate,
|
|
360
439
|
env = process.env,
|
|
361
440
|
secretsOn = secretsEnabled(env),
|
|
441
|
+
ledger,
|
|
362
442
|
) {
|
|
443
|
+
/** @type {(tool: string, toolInput: any, result: { deny: string, fault?: string, release?: { updatedInput: any, context: string } }, site: string) => ReturnType<typeof resolveStageDeny>} */
|
|
444
|
+
const resolveDeny = (tool, toolInput, result, site) => {
|
|
445
|
+
if (result.fault !== undefined && ledger === undefined)
|
|
446
|
+
throw new Error(
|
|
447
|
+
`preToolUseLayers: the ${site} stage reported a fault but no ledger ` +
|
|
448
|
+
"was passed to carry its warning",
|
|
449
|
+
);
|
|
450
|
+
return resolveStageDeny(
|
|
451
|
+
tool,
|
|
452
|
+
toolInput,
|
|
453
|
+
result,
|
|
454
|
+
site,
|
|
455
|
+
env,
|
|
456
|
+
/** @type {FaultLedger} */ (ledger),
|
|
457
|
+
);
|
|
458
|
+
};
|
|
363
459
|
/** @type {import("./lib/layer-pipeline.mjs").Layer[]} */
|
|
364
460
|
const layers = [
|
|
365
461
|
{
|
|
@@ -410,7 +506,8 @@ export function preToolUseLayers(
|
|
|
410
506
|
rehydrate
|
|
411
507
|
)(tool, toolInput);
|
|
412
508
|
if (!rehydrated) return null;
|
|
413
|
-
if ("deny" in rehydrated)
|
|
509
|
+
if ("deny" in rehydrated)
|
|
510
|
+
return resolveDeny(tool, toolInput, rehydrated, "rehydrate");
|
|
414
511
|
return {
|
|
415
512
|
updatedInput: rehydrated.updatedInput,
|
|
416
513
|
context: rehydrated.context,
|
|
@@ -426,7 +523,12 @@ export function preToolUseLayers(
|
|
|
426
523
|
// legitimately contain [REDACTED…] text — must not be re-fed to the
|
|
427
524
|
// secret resolver or re-stripped by an earlier layer.
|
|
428
525
|
terminal: true,
|
|
429
|
-
run: (tool, toolInput) =>
|
|
526
|
+
run: (tool, toolInput) => {
|
|
527
|
+
const result = rehydrateLayer2(tool, toolInput, secretsOn);
|
|
528
|
+
return result !== null && "deny" in result
|
|
529
|
+
? resolveDeny(tool, toolInput, result, "layer2-rehydrate")
|
|
530
|
+
: result;
|
|
531
|
+
},
|
|
430
532
|
},
|
|
431
533
|
];
|
|
432
534
|
return layers.filter(
|
|
@@ -473,13 +575,13 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
|
|
|
473
575
|
// cleaned. It asks ONCE (a hard checkpoint, recorded once emitted) then
|
|
474
576
|
// degrades to a passive reminder, so it doesn't prompt on every tool call.
|
|
475
577
|
const findings = invisibleCharAlert(input.session_id);
|
|
476
|
-
|
|
578
|
+
/** @type {string | null} */
|
|
579
|
+
let gateAsk = null;
|
|
477
580
|
if (findings) {
|
|
478
581
|
if (alertAcknowledged(input.session_id)) {
|
|
479
582
|
contexts.push(gateReminderContext());
|
|
480
583
|
} else {
|
|
481
|
-
|
|
482
|
-
pendingGateAck = true;
|
|
584
|
+
gateAsk = gateAskReason(findings);
|
|
483
585
|
}
|
|
484
586
|
}
|
|
485
587
|
|
|
@@ -496,6 +598,8 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
|
|
|
496
598
|
// The rewriting stages, run by the declared pipeline: the driver — not this
|
|
497
599
|
// call order — is what keeps the confusable fold's soundness precondition
|
|
498
600
|
// true once an erasing stage follows it (see lib/layer-pipeline.mjs).
|
|
601
|
+
/** @type {FaultLedger} */
|
|
602
|
+
const ledger = { warnings: [], released: [] };
|
|
499
603
|
const {
|
|
500
604
|
updatedInput: current,
|
|
501
605
|
changed,
|
|
@@ -510,6 +614,7 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
|
|
|
510
614
|
rehydrate ?? (secretsOn ? guardedRehydrate : null),
|
|
511
615
|
process.env,
|
|
512
616
|
secretsOn,
|
|
617
|
+
ledger,
|
|
513
618
|
),
|
|
514
619
|
);
|
|
515
620
|
if (deny !== undefined)
|
|
@@ -517,7 +622,7 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
|
|
|
517
622
|
permissionDecision: PermissionDecision.DENY,
|
|
518
623
|
permissionDecisionReason: deny,
|
|
519
624
|
});
|
|
520
|
-
contexts.push(...layerContexts);
|
|
625
|
+
contexts.push(...layerContexts, ...ledger.warnings);
|
|
521
626
|
// A body that never settled: the pipeline hands back its LAST pass's sanitized
|
|
522
627
|
// input plus the reason, and the verdict is this hook's registered fault
|
|
523
628
|
// posture — never a verdict decided here. Open proceeds with `current` (the
|
|
@@ -528,11 +633,16 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
|
|
|
528
633
|
const faultFields = hookFailureFields(true, new Error(fault), {
|
|
529
634
|
input: { tool_name: tool, tool_input: current },
|
|
530
635
|
});
|
|
636
|
+
if (faultFields.permissionDecision === PermissionDecision.DENY)
|
|
637
|
+
return emitTraced(emitTrace, input.tool_name, faultFields);
|
|
531
638
|
if (faultFields.permissionDecisionReason !== undefined)
|
|
532
639
|
asks.push(String(faultFields.permissionDecisionReason));
|
|
533
640
|
if (faultFields.additionalContext !== undefined)
|
|
534
641
|
contexts.push(String(faultFields.additionalContext));
|
|
535
642
|
}
|
|
643
|
+
// The call proceeds past every refusal, so the confirmations it rode on are
|
|
644
|
+
// spent: each approves exactly one re-issue.
|
|
645
|
+
for (const fingerprint of ledger.released) consumeConfirm(fingerprint);
|
|
536
646
|
|
|
537
647
|
// Placeholder advisory for tools OUTSIDE the rehydrated set (Bash, MCP,
|
|
538
648
|
// anything unknown): rehydration cannot re-anchor these, so a placeholder in
|
|
@@ -558,20 +668,46 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
|
|
|
558
668
|
current,
|
|
559
669
|
asks,
|
|
560
670
|
contexts,
|
|
561
|
-
|
|
671
|
+
gateAsk,
|
|
562
672
|
pendingGapNotice: gapNotice !== null,
|
|
563
673
|
sessionId: input.session_id,
|
|
564
674
|
}),
|
|
565
675
|
);
|
|
566
676
|
}
|
|
567
677
|
|
|
678
|
+
/**
|
|
679
|
+
* Record the gate's acknowledgement and render its one-time ask. When the ack
|
|
680
|
+
* cannot be recorded, the next call would ask again, and the one after — so the
|
|
681
|
+
* fault posture decides: closed keeps the ask, open carries the findings as a
|
|
682
|
+
* warning instead of prompting on every tool call for the rest of the session.
|
|
683
|
+
* @param {string} gateAsk
|
|
684
|
+
* @param {string} [sessionId]
|
|
685
|
+
* @returns {{ ask: string } | { context: string }}
|
|
686
|
+
*/
|
|
687
|
+
function gateVerdict(gateAsk, sessionId) {
|
|
688
|
+
if (acknowledgeAlert(sessionId)) return { ask: gateAsk };
|
|
689
|
+
const fields = /** @type {Record<string, unknown>} */ (
|
|
690
|
+
hookFaultOutcome(
|
|
691
|
+
HOOK_NAME,
|
|
692
|
+
new Error(
|
|
693
|
+
"the invisible-char gate's acknowledgement could not be recorded",
|
|
694
|
+
),
|
|
695
|
+
{ site: FaultSite.GATE_ACK, gateAsk },
|
|
696
|
+
).fields
|
|
697
|
+
);
|
|
698
|
+
return fields.permissionDecision === PermissionDecision.ASK
|
|
699
|
+
? { ask: String(fields.permissionDecisionReason) }
|
|
700
|
+
: { context: String(fields.additionalContext) };
|
|
701
|
+
}
|
|
702
|
+
|
|
568
703
|
/**
|
|
569
704
|
* Assemble the hookSpecificOutput fields from the per-layer results, or null
|
|
570
705
|
* for a clean no-op (nothing asked, changed, or annotated). Records the gate
|
|
571
706
|
* acknowledgement and the coverage-gap notice only when they actually land in
|
|
572
|
-
* the response
|
|
707
|
+
* the response — a deny above returns first, so a preempted ask is not marked
|
|
708
|
+
* seen.
|
|
573
709
|
* @param {{ changed: boolean, current: any, asks: string[], contexts: string[],
|
|
574
|
-
*
|
|
710
|
+
* gateAsk: string | null, pendingGapNotice: boolean, sessionId?: string }} parts
|
|
575
711
|
* @returns {Record<string, unknown> | null}
|
|
576
712
|
*/
|
|
577
713
|
function assembleResponse({
|
|
@@ -579,10 +715,15 @@ function assembleResponse({
|
|
|
579
715
|
current,
|
|
580
716
|
asks,
|
|
581
717
|
contexts,
|
|
582
|
-
|
|
718
|
+
gateAsk,
|
|
583
719
|
pendingGapNotice,
|
|
584
720
|
sessionId,
|
|
585
721
|
}) {
|
|
722
|
+
if (gateAsk !== null) {
|
|
723
|
+
const gate = gateVerdict(gateAsk, sessionId);
|
|
724
|
+
if ("ask" in gate) asks.unshift(gate.ask);
|
|
725
|
+
else contexts.unshift(gate.context);
|
|
726
|
+
}
|
|
586
727
|
if (asks.length === 0 && !changed && contexts.length === 0) return null;
|
|
587
728
|
|
|
588
729
|
/** @type {Record<string, unknown>} */
|
|
@@ -596,9 +737,6 @@ function assembleResponse({
|
|
|
596
737
|
fields.permissionDecisionReason = asks.join("\n\n");
|
|
597
738
|
}
|
|
598
739
|
if (contexts.length > 0) fields.additionalContext = contexts.join(" ");
|
|
599
|
-
// Record the gate ack only now that the ask is actually in the response — a
|
|
600
|
-
// rehydrate deny above returns first, so a preempted ask is not marked seen.
|
|
601
|
-
if (pendingGateAck) acknowledgeAlert(sessionId);
|
|
602
740
|
if (pendingGapNotice) recordInstructionsLoadedNotice(sessionId);
|
|
603
741
|
return fields;
|
|
604
742
|
}
|
|
@@ -608,9 +746,11 @@ function assembleResponse({
|
|
|
608
746
|
* ToolCallEvent and returns a Verdict, so a non-Claude host can run the same
|
|
609
747
|
* sanitization pipeline through its own adapter. The wired Claude CLI below
|
|
610
748
|
* routes through this judge and renders the Verdict with the Claude adapter; on
|
|
611
|
-
* any throw (a control-plane package-load failure
|
|
612
|
-
*
|
|
613
|
-
* caller's fault posture holds even when the
|
|
749
|
+
* any throw (a control-plane package-load failure, or an event the adapter
|
|
750
|
+
* could not classify) it falls back to hookFailureFields — a native response
|
|
751
|
+
* that needs no package — so the caller's fault posture holds even when the
|
|
752
|
+
* adapter never loaded. A host calling this directly renders its throws the
|
|
753
|
+
* same way.
|
|
614
754
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
615
755
|
* @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
|
|
616
756
|
* @param {{
|
|
@@ -636,13 +776,18 @@ export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
|
|
|
636
776
|
const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
|
|
637
777
|
const { Decision, EventKind } = controlPlane();
|
|
638
778
|
// A payload the adapter cannot classify (a missing/unexpected hook_event_name)
|
|
639
|
-
// would drive the pipeline with an empty event and no-op to
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
779
|
+
// would drive the pipeline with an empty event and no-op to a CLEAN allow,
|
|
780
|
+
// reporting a sanitized call nobody vetted. This hook is wired only to
|
|
781
|
+
// PreToolUse, so it is harness-contract drift: a fault, thrown so the fault
|
|
782
|
+
// policy decides — a warning by default, a deny carrying the error's `reason`
|
|
783
|
+
// (this host's `messages.unknownEvent`) under AGENT_SANITIZER_FAIL_OPEN=0.
|
|
644
784
|
if (event.event === EventKind.UNKNOWN)
|
|
645
|
-
|
|
785
|
+
throw Object.assign(
|
|
786
|
+
new Error(
|
|
787
|
+
"pretooluse-sanitize: unrecognized hook payload (not PreToolUse)",
|
|
788
|
+
),
|
|
789
|
+
{ code: UNKNOWN_EVENT_CODE, reason: messages.unknownEvent },
|
|
790
|
+
);
|
|
646
791
|
// The session identity and the permission mode travel in `meta`, not alongside
|
|
647
792
|
// the tool input. A gate keyed on the session (a once-per-session checkpoint)
|
|
648
793
|
// cannot tell two sessions apart without the first, and a gate keyed on the
|
|
@@ -769,8 +914,8 @@ export const REDACTION_HINT = "[REDACTED";
|
|
|
769
914
|
|
|
770
915
|
/**
|
|
771
916
|
* True when a faulting PreToolUse call is the one case the OPEN posture must
|
|
772
|
-
*
|
|
773
|
-
* placeholder, matched by the real grammar (containsPlaceholder) so
|
|
917
|
+
* not pass on the first try: a write-shaped tool whose input carries a
|
|
918
|
+
* redaction placeholder, matched by the real grammar (containsPlaceholder) so
|
|
774
919
|
* hint-prefixed prose such as `[REDACTED…]` is not mistaken for one. Such a
|
|
775
920
|
* placeholder stands for a secret the sanitizer redacted out of the model's
|
|
776
921
|
* view; with the sanitizer down, rehydration cannot translate it back, so
|
|
@@ -781,7 +926,7 @@ export const REDACTION_HINT = "[REDACTED";
|
|
|
781
926
|
*
|
|
782
927
|
* Deliberately NOT exhaustive over every clobber path: Bash can also write a
|
|
783
928
|
* placeholder to disk (`>`, `tee`, `sed -i`, a heredoc), but command strings
|
|
784
|
-
* mention "[REDACTED" benignly far too often for
|
|
929
|
+
* mention "[REDACTED" benignly far too often for a refusal to hold precision
|
|
785
930
|
* there. That is an accepted gap, named in THREAT-MODEL.md's carve-out.
|
|
786
931
|
* @param {unknown} input raw parsed PreToolUse payload (undefined if unparsed)
|
|
787
932
|
* @returns {boolean}
|
|
@@ -804,8 +949,9 @@ export function hintedWriteFault(input) {
|
|
|
804
949
|
* fail-CLOSED verdict of {@link failClosedFields} when the caller set
|
|
805
950
|
* AGENT_SANITIZER_FAIL_OPEN=0. The open default has ONE carve-out, declared in
|
|
806
951
|
* this hook's fault policy below: a write-shaped call whose input carries a
|
|
807
|
-
* `[REDACTED…]` placeholder
|
|
808
|
-
* {@link hintedWriteFault}) — pass `input` so the policy
|
|
952
|
+
* `[REDACTED…]` placeholder is refused once, and its exact re-issue proceeds
|
|
953
|
+
* with a warning (see {@link hintedWriteFault}) — pass `input` so the policy
|
|
954
|
+
* can see it.
|
|
809
955
|
*
|
|
810
956
|
* The posture covers this hook's own failures, whatever their cause. What it
|
|
811
957
|
* does NOT cover is the verdict of a sanitizer that ran: a payload
|
|
@@ -832,19 +978,71 @@ export function hookFailureFields(parsedOk, err, opts = {}) {
|
|
|
832
978
|
);
|
|
833
979
|
}
|
|
834
980
|
|
|
981
|
+
/** Appended to a releasable refusal: the escape that keeps it from looping. */
|
|
982
|
+
const REISSUE_NOTE =
|
|
983
|
+
"If writing this text as-is is intended, re-issue this exact call (same " +
|
|
984
|
+
"tool, same input) to confirm: the re-issue proceeds and writes it literally.";
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* The open posture's answer to a fault that would clobber real file content.
|
|
988
|
+
* A refusal the model cannot get past is a stuck session, so the refusal is
|
|
989
|
+
* issued only once it has a release: the first call is refused naming the
|
|
990
|
+
* re-issue escape, the confirmed re-issue proceeds with `warning`, and when the
|
|
991
|
+
* confirmation cannot be recorded at all the call proceeds with `warning` on
|
|
992
|
+
* the first try, since nothing could ever release that refusal.
|
|
993
|
+
* @param {string} fingerprint the refusal's {@link reissueFingerprint}
|
|
994
|
+
* @param {boolean} consume spend the confirmation now; false for a caller that
|
|
995
|
+
* learns later whether the call proceeds and consumes it then
|
|
996
|
+
* @param {string} reason the refusal, without the re-issue note
|
|
997
|
+
* @param {string} warning what proceeding writes, for the model
|
|
998
|
+
* @returns {import("./lib/hook-fault.mjs").FaultParts}
|
|
999
|
+
*/
|
|
1000
|
+
function releasableRefusal(fingerprint, consume, reason, warning) {
|
|
1001
|
+
if (consume ? consumeConfirm(fingerprint) : confirmFresh(fingerprint))
|
|
1002
|
+
return {
|
|
1003
|
+
fields: {
|
|
1004
|
+
additionalContext: `${warning} It proceeded as the confirmed re-issue of a call refused for this reason.`,
|
|
1005
|
+
},
|
|
1006
|
+
};
|
|
1007
|
+
if (!recordConfirm(fingerprint))
|
|
1008
|
+
return {
|
|
1009
|
+
fields: {
|
|
1010
|
+
additionalContext:
|
|
1011
|
+
`${warning} It proceeded without a refusal: the re-issue ` +
|
|
1012
|
+
`confirmation could not be recorded, so a refusal could never be released.`,
|
|
1013
|
+
},
|
|
1014
|
+
};
|
|
1015
|
+
const sentence = /[.!?]$/u.test(reason) ? reason : `${reason}.`;
|
|
1016
|
+
return {
|
|
1017
|
+
fields: {
|
|
1018
|
+
permissionDecision: PermissionDecision.DENY,
|
|
1019
|
+
permissionDecisionReason: `${sentence} ${REISSUE_NOTE}`,
|
|
1020
|
+
},
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
835
1024
|
// This hook's entry in the one posture table (lib/hook-fault.mjs). The OPEN arm
|
|
836
|
-
// keeps the shared default — a warning context and no verdict —
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
//
|
|
841
|
-
// placeholder would be persisted literally over the secret it stands for (see
|
|
842
|
-
// hintedWriteFault). The ask keeps a human in the loop exactly as the closed
|
|
843
|
-
// posture's clean-parse arm does.
|
|
1025
|
+
// keeps the shared default — a warning context and no verdict — except where
|
|
1026
|
+
// proceeding would clobber real content: a write-shaped input carrying a
|
|
1027
|
+
// [REDACTED… placeholder on a hook fault (see hintedWriteFault), and a STAGE
|
|
1028
|
+
// fault deny. Those take {@link releasableRefusal}. The two `site` arms are the
|
|
1029
|
+
// non-throw faults {@link FaultSite} lists.
|
|
844
1030
|
registerFaultPolicy(HOOK_NAME, {
|
|
845
1031
|
event: HookEvent.PRE_TOOL_USE,
|
|
846
1032
|
guarded: "tool input",
|
|
847
1033
|
open: (ctx) => {
|
|
1034
|
+
if (ctx.site === FaultSite.GATE_ACK)
|
|
1035
|
+
return {
|
|
1036
|
+
fields: {
|
|
1037
|
+
additionalContext:
|
|
1038
|
+
"WARNING: agent-sanitizer could not record that its instruction-file " +
|
|
1039
|
+
"alert was shown this session, so it is reported here rather than as " +
|
|
1040
|
+
`a permission prompt on every tool call (fail-open; set ${FAIL_OPEN_ENV}=0 ` +
|
|
1041
|
+
`to be asked instead).\n\n${ctx.gateAsk}`,
|
|
1042
|
+
},
|
|
1043
|
+
};
|
|
1044
|
+
if (ctx.site === FaultSite.STAGE)
|
|
1045
|
+
return releasableRefusal(ctx.fingerprint, false, ctx.reason, ctx.warning);
|
|
848
1046
|
// Non-carve-out faults take the SHARED open rendering, not a copy of it —
|
|
849
1047
|
// hook-fault.mjs owns that body, and a restated one would silently drift.
|
|
850
1048
|
// The carve-out itself rides the secret opt-in: with secrets off no
|
|
@@ -852,32 +1050,60 @@ registerFaultPolicy(HOOK_NAME, {
|
|
|
852
1050
|
// in a write is literal prose and holding it would be a false positive.
|
|
853
1051
|
if (!secretsEnabled(ctx.env) || !hintedWriteFault(ctx.input))
|
|
854
1052
|
return defaultOpen(ctx);
|
|
855
|
-
// parsedOk is hardcoded true
|
|
856
|
-
//
|
|
857
|
-
//
|
|
1053
|
+
// parsedOk is hardcoded true: hintedWriteFault(undefined) is false, so an
|
|
1054
|
+
// unparsed input can never reach this line. This is the final decision on
|
|
1055
|
+
// the call, so the confirmation is spent here.
|
|
858
1056
|
const closed = failClosedFields(true, ctx.err, {
|
|
859
1057
|
messages: ctx.messages,
|
|
860
1058
|
hint: ctx.hint,
|
|
861
1059
|
});
|
|
1060
|
+
return releasableRefusal(
|
|
1061
|
+
reissueFingerprint(
|
|
1062
|
+
"hook-fault",
|
|
1063
|
+
ctx.input.tool_name,
|
|
1064
|
+
ctx.input.tool_input,
|
|
1065
|
+
),
|
|
1066
|
+
true,
|
|
1067
|
+
`${closed.permissionDecisionReason} This input would write ` +
|
|
1068
|
+
`${REDACTION_HINT}…] placeholder text, which stands for a redacted ` +
|
|
1069
|
+
`secret the unavailable sanitizer cannot translate back; proceeding ` +
|
|
1070
|
+
`overwrites the real secret with the placeholder.`,
|
|
1071
|
+
`${ctx.openContext} This input carries ${REDACTION_HINT}…] placeholder ` +
|
|
1072
|
+
`text, which is written literally over the real secret it stands for.`,
|
|
1073
|
+
);
|
|
1074
|
+
},
|
|
1075
|
+
closed: (ctx) => {
|
|
1076
|
+
if (ctx.site === FaultSite.GATE_ACK)
|
|
1077
|
+
return {
|
|
1078
|
+
fields: {
|
|
1079
|
+
permissionDecision: PermissionDecision.ASK,
|
|
1080
|
+
permissionDecisionReason: ctx.gateAsk,
|
|
1081
|
+
},
|
|
1082
|
+
};
|
|
1083
|
+
if (ctx.site === FaultSite.STAGE)
|
|
1084
|
+
return {
|
|
1085
|
+
fields: {
|
|
1086
|
+
permissionDecision: PermissionDecision.DENY,
|
|
1087
|
+
permissionDecisionReason: ctx.reason,
|
|
1088
|
+
},
|
|
1089
|
+
};
|
|
1090
|
+
const err = /** @type {{ code?: unknown, reason?: unknown } | null} */ (
|
|
1091
|
+
ctx.err
|
|
1092
|
+
);
|
|
1093
|
+
if (err?.code === UNKNOWN_EVENT_CODE)
|
|
1094
|
+
return {
|
|
1095
|
+
fields: {
|
|
1096
|
+
permissionDecision: PermissionDecision.DENY,
|
|
1097
|
+
permissionDecisionReason: err.reason,
|
|
1098
|
+
},
|
|
1099
|
+
};
|
|
862
1100
|
return {
|
|
863
|
-
fields: {
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
`${REDACTION_HINT}…] placeholder text, which stands for a redacted ` +
|
|
868
|
-
`secret the unavailable sanitizer cannot translate back; proceeding ` +
|
|
869
|
-
`would overwrite the real secret with the placeholder, so the call ` +
|
|
870
|
-
`is held even under the fail-open posture. Retry once the sanitizer ` +
|
|
871
|
-
`recovers, or ask the user to make this change.`,
|
|
872
|
-
},
|
|
1101
|
+
fields: failClosedFields(ctx.parsedOk, ctx.err, {
|
|
1102
|
+
messages: ctx.messages,
|
|
1103
|
+
hint: ctx.hint,
|
|
1104
|
+
}),
|
|
873
1105
|
};
|
|
874
1106
|
},
|
|
875
|
-
closed: (ctx) => ({
|
|
876
|
-
fields: failClosedFields(ctx.parsedOk, ctx.err, {
|
|
877
|
-
messages: ctx.messages,
|
|
878
|
-
hint: ctx.hint,
|
|
879
|
-
}),
|
|
880
|
-
}),
|
|
881
1107
|
});
|
|
882
1108
|
|
|
883
1109
|
// Stryker disable all: CLI wiring — it runs only in the spawned hook
|
|
@@ -77,6 +77,9 @@ export const USER_PROMPT_MESSAGES = Object.freeze({
|
|
|
77
77
|
|
|
78
78
|
const HOOK_NAME = "sanitize-user-prompt";
|
|
79
79
|
|
|
80
|
+
/** The `code` of the error a payload the adapter cannot classify raises. */
|
|
81
|
+
const UNKNOWN_EVENT_CODE = "UNKNOWN_EVENT";
|
|
82
|
+
|
|
80
83
|
// This hook's entry in the one posture table (lib/hook-fault.mjs). OPEN is the
|
|
81
84
|
// shared default (a warning context alongside the prompt); CLOSED is a
|
|
82
85
|
// top-level `decision: "block"`, NOT a hookSpecificOutput verdict —
|
|
@@ -85,15 +88,18 @@ const HOOK_NAME = "sanitize-user-prompt";
|
|
|
85
88
|
registerFaultPolicy(HOOK_NAME, {
|
|
86
89
|
event: HookEvent.USER_PROMPT_SUBMIT,
|
|
87
90
|
guarded: "prompt",
|
|
88
|
-
closed: (ctx) =>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
91
|
+
closed: (ctx) => {
|
|
92
|
+
const err = /** @type {{ code?: unknown, reason?: string } | null} */ (
|
|
93
|
+
ctx.err
|
|
94
|
+
);
|
|
95
|
+
// An unclassifiable payload blocks with the host's own `unknownEvent`
|
|
96
|
+
// reason, carried on the error, rather than wrapped as a hook failure.
|
|
97
|
+
const reason =
|
|
98
|
+
err?.code === UNKNOWN_EVENT_CODE
|
|
99
|
+
? /** @type {string} */ (err.reason)
|
|
100
|
+
: { ...USER_PROMPT_MESSAGES, ...ctx.messages }.hookFailed(ctx.message);
|
|
101
|
+
return { envelope: { decision: "block", reason } };
|
|
102
|
+
},
|
|
97
103
|
});
|
|
98
104
|
|
|
99
105
|
/* c8 ignore start — module-load boundary: the imports resolve in every real
|
|
@@ -121,8 +127,9 @@ registerFaultPolicy(HOOK_NAME, {
|
|
|
121
127
|
* Judge a normalized prompt-submit event. Agent-agnostic: consumes the
|
|
122
128
|
* control-plane ToolCallEvent and returns a Verdict, so the same prompt gate
|
|
123
129
|
* renders through any agent adapter, not just Claude's. Throws (into the
|
|
124
|
-
* calling hook's catch) when the sanitizer package never loaded
|
|
125
|
-
* cannot classify takes the hook's
|
|
130
|
+
* calling hook's catch) when the sanitizer package never loaded or the payload
|
|
131
|
+
* is no recognizable event, so a prompt it cannot classify takes the hook's
|
|
132
|
+
* fault posture rather than a silent ALLOW.
|
|
126
133
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
127
134
|
* @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
|
|
128
135
|
* to the package's stripAnsiFully; injectable so the fault path is testable)
|
|
@@ -142,11 +149,17 @@ export function judgeSanitizeUserPrompt(
|
|
|
142
149
|
const messages = { ...USER_PROMPT_MESSAGES, ...overrides };
|
|
143
150
|
const { Decision, EventKind } = controlPlane();
|
|
144
151
|
// A payload the adapter cannot classify carries no readable prompt, so an
|
|
145
|
-
// abstain would
|
|
146
|
-
//
|
|
147
|
-
//
|
|
152
|
+
// abstain would report a CLEAN run on harness contract drift. It is a fault:
|
|
153
|
+
// the throw lands in main()'s onError, and the registered posture decides —
|
|
154
|
+
// a warning by default, a block carrying the error's `reason` (this host's
|
|
155
|
+
// `messages.unknownEvent`) under AGENT_SANITIZER_FAIL_OPEN=0.
|
|
148
156
|
if (event.event === EventKind.UNKNOWN)
|
|
149
|
-
|
|
157
|
+
throw Object.assign(
|
|
158
|
+
new Error(
|
|
159
|
+
"sanitize-user-prompt: unrecognized hook payload (not UserPromptSubmit)",
|
|
160
|
+
),
|
|
161
|
+
{ code: UNKNOWN_EVENT_CODE, reason: messages.unknownEvent },
|
|
162
|
+
);
|
|
150
163
|
if (event.event !== EventKind.PROMPT_SUBMIT)
|
|
151
164
|
return { decision: Decision.ALLOW };
|
|
152
165
|
// The module-load guard, one arm per binding. The two lazyImports are
|