agent-sanitizer 2.67.2 → 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 +52 -39
- package/claude-hooks/lib/env-config.mjs +2 -2
- package/claude-hooks/lib/hook-fault.mjs +13 -35
- package/claude-hooks/lib/hook-io.mjs +84 -9
- package/claude-hooks/lib/hook-timing.mjs +26 -6
- package/claude-hooks/lib/invisible-alert.mjs +6 -3
- package/claude-hooks/lib/redactor-client.mjs +23 -23
- package/claude-hooks/lib/secret-annotate.mjs +36 -66
- package/claude-hooks/lib/secret-drop-guard.mjs +136 -29
- package/claude-hooks/plugin-hooks.mjs +30 -27
- package/claude-hooks/pretooluse-sanitize.mjs +288 -64
- package/claude-hooks/sanitize-output.mjs +27 -40
- package/claude-hooks/sanitize-user-prompt.mjs +28 -17
- package/claude-hooks/scan-invisible-chars.mjs +86 -35
- package/claude-hooks/scan-loaded-instructions.mjs +20 -16
- package/package.json +1 -1
- package/src/html.mjs +17 -17
- package/src/output.mjs +20 -19
- package/src/rehydrate.mjs +36 -9
- package/types/claude-hooks/lib/hook-fault.d.mts +8 -73
- package/types/claude-hooks/lib/hook-io.d.mts +32 -6
- package/types/claude-hooks/lib/hook-timing.d.mts +9 -4
- package/types/claude-hooks/lib/invisible-alert.d.mts +6 -3
- package/types/claude-hooks/lib/redactor-client.d.mts +3 -3
- package/types/claude-hooks/lib/secret-annotate.d.mts +11 -17
- 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-output.d.mts +1 -1
- package/types/claude-hooks/sanitize-user-prompt.d.mts +3 -2
- package/types/claude-hooks/scan-invisible-chars.d.mts +3 -3
- package/types/output.d.mts +4 -5
- package/types/rehydrate.d.mts +8 -1
|
@@ -41,10 +41,10 @@ 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,
|
|
47
|
-
KNOB_DECIDES,
|
|
48
48
|
hookFaultOutcome,
|
|
49
49
|
defaultOpen,
|
|
50
50
|
} from "./lib/hook-fault.mjs";
|
|
@@ -69,7 +69,13 @@ import {
|
|
|
69
69
|
import { redactViaDaemon } from "./lib/redactor-client.mjs";
|
|
70
70
|
import { secretsEnabled } from "./lib/env-config.mjs";
|
|
71
71
|
import { readKnob } from "./lib/knobs.mjs";
|
|
72
|
-
import {
|
|
72
|
+
import {
|
|
73
|
+
withSecretDropGuard,
|
|
74
|
+
reissueFingerprint,
|
|
75
|
+
recordConfirm,
|
|
76
|
+
confirmFresh,
|
|
77
|
+
consumeConfirm,
|
|
78
|
+
} from "./lib/secret-drop-guard.mjs";
|
|
73
79
|
import {
|
|
74
80
|
containsPlaceholder,
|
|
75
81
|
placeholderNotice,
|
|
@@ -82,6 +88,20 @@ import { hookTrace, TraceEvent } from "./lib/trace.mjs";
|
|
|
82
88
|
|
|
83
89
|
const HOOK_NAME = "pretooluse-sanitize";
|
|
84
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
|
+
|
|
85
105
|
/**
|
|
86
106
|
* A host-supplied deny gate: given the PreToolUse input, the reason this call
|
|
87
107
|
* must be blocked, or null to let the pipeline continue. Hosts use these for
|
|
@@ -105,6 +125,8 @@ const HOOK_NAME = "pretooluse-sanitize";
|
|
|
105
125
|
* }>}
|
|
106
126
|
*/
|
|
107
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.
|
|
108
130
|
unknownEvent:
|
|
109
131
|
"PreToolUse sanitization blocked (fail-closed): unrecognized hook payload.",
|
|
110
132
|
failed: (cause) => `PreToolUse sanitization failed (fail-closed): ${cause}`,
|
|
@@ -189,12 +211,13 @@ const guardedRehydrate = withSecretDropGuard(
|
|
|
189
211
|
/**
|
|
190
212
|
* Substitute every keyed Layer-2 placeholder in `text` with the stored original
|
|
191
213
|
* from the reveal store's span files, in ONE ordered pass (spliceOrdered).
|
|
192
|
-
* A key with NO stored span
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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.
|
|
195
218
|
* @param {string} text
|
|
196
219
|
* @param {string} field the tool-input field being rehydrated, for the deny prose
|
|
197
|
-
* @returns {{ text: string, restored: number } | { deny: string } | null}
|
|
220
|
+
* @returns {{ text: string, restored: number } | { deny: string, fault: string } | null}
|
|
198
221
|
*/
|
|
199
222
|
function substituteLayer2(text, field) {
|
|
200
223
|
const matches = [...text.matchAll(LAYER2_PLACEHOLDER_RE)].map((match) => ({
|
|
@@ -212,16 +235,22 @@ function substituteLayer2(text, field) {
|
|
|
212
235
|
if (stored === null) missing.push(key);
|
|
213
236
|
else byKey.set(key, stored);
|
|
214
237
|
}
|
|
215
|
-
if (missing.length > 0)
|
|
238
|
+
if (missing.length > 0) {
|
|
239
|
+
const files = missing.map((key) => spanPath(key)).join(", ");
|
|
216
240
|
return {
|
|
217
241
|
deny:
|
|
218
242
|
`${field} contains Layer-2 removed-content placeholder(s) whose original is not ` +
|
|
219
243
|
`in the span store (missing key(s): ${missing.join(", ")}; expected file(s): ` +
|
|
220
|
-
`${
|
|
244
|
+
`${files}), so the removed content ` +
|
|
221
245
|
`cannot be restored automatically. Reconstruct that content yourself, ` +
|
|
222
246
|
`deliberately drop the placeholder(s) if the removed content should stay ` +
|
|
223
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`,
|
|
224
252
|
};
|
|
253
|
+
}
|
|
225
254
|
// `i` is the match's index in `matches` (stable across spliceOrdered's
|
|
226
255
|
// overlap skips — impossible here, distinct non-overlapping literals), so the
|
|
227
256
|
// key rides positionally.
|
|
@@ -264,7 +293,7 @@ function substituteLayer2(text, field) {
|
|
|
264
293
|
* @param {boolean} [secretsOn] the run's secret opt-in (decided once by
|
|
265
294
|
* buildPreToolUseResponse; a direct caller reads the env); only with it on
|
|
266
295
|
* does the context claim the restored bytes were redacted
|
|
267
|
-
* @returns {{ updatedInput: any, context: string } | { deny: string } | null}
|
|
296
|
+
* @returns {{ updatedInput: any, context: string } | { deny: string, fault?: string } | null}
|
|
268
297
|
*/
|
|
269
298
|
export function rehydrateLayer2(tool, toolInput, secretsOn = secretsEnabled()) {
|
|
270
299
|
const hasL2 = (/** @type {unknown} */ text) =>
|
|
@@ -338,6 +367,52 @@ function emitTraced(emitTrace, toolName, fields) {
|
|
|
338
367
|
return fields;
|
|
339
368
|
}
|
|
340
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
|
+
|
|
341
416
|
/**
|
|
342
417
|
* The declared stage chain after the gate. Every entry states the two properties
|
|
343
418
|
* the driver reasons about — whether it ERASES code points another layer reads,
|
|
@@ -354,13 +429,33 @@ function emitTraced(emitTrace, toolName, fields) {
|
|
|
354
429
|
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
355
430
|
* @param {boolean} [secretsOn] the run's secret opt-in (see
|
|
356
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
|
|
357
435
|
* @returns {import("./lib/layer-pipeline.mjs").Layer[]}
|
|
358
436
|
*/
|
|
359
437
|
export function preToolUseLayers(
|
|
360
438
|
rehydrate,
|
|
361
439
|
env = process.env,
|
|
362
440
|
secretsOn = secretsEnabled(env),
|
|
441
|
+
ledger,
|
|
363
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
|
+
};
|
|
364
459
|
/** @type {import("./lib/layer-pipeline.mjs").Layer[]} */
|
|
365
460
|
const layers = [
|
|
366
461
|
{
|
|
@@ -411,7 +506,8 @@ export function preToolUseLayers(
|
|
|
411
506
|
rehydrate
|
|
412
507
|
)(tool, toolInput);
|
|
413
508
|
if (!rehydrated) return null;
|
|
414
|
-
if ("deny" in rehydrated)
|
|
509
|
+
if ("deny" in rehydrated)
|
|
510
|
+
return resolveDeny(tool, toolInput, rehydrated, "rehydrate");
|
|
415
511
|
return {
|
|
416
512
|
updatedInput: rehydrated.updatedInput,
|
|
417
513
|
context: rehydrated.context,
|
|
@@ -427,7 +523,12 @@ export function preToolUseLayers(
|
|
|
427
523
|
// legitimately contain [REDACTED…] text — must not be re-fed to the
|
|
428
524
|
// secret resolver or re-stripped by an earlier layer.
|
|
429
525
|
terminal: true,
|
|
430
|
-
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
|
+
},
|
|
431
532
|
},
|
|
432
533
|
];
|
|
433
534
|
return layers.filter(
|
|
@@ -474,13 +575,13 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
|
|
|
474
575
|
// cleaned. It asks ONCE (a hard checkpoint, recorded once emitted) then
|
|
475
576
|
// degrades to a passive reminder, so it doesn't prompt on every tool call.
|
|
476
577
|
const findings = invisibleCharAlert(input.session_id);
|
|
477
|
-
|
|
578
|
+
/** @type {string | null} */
|
|
579
|
+
let gateAsk = null;
|
|
478
580
|
if (findings) {
|
|
479
581
|
if (alertAcknowledged(input.session_id)) {
|
|
480
582
|
contexts.push(gateReminderContext());
|
|
481
583
|
} else {
|
|
482
|
-
|
|
483
|
-
pendingGateAck = true;
|
|
584
|
+
gateAsk = gateAskReason(findings);
|
|
484
585
|
}
|
|
485
586
|
}
|
|
486
587
|
|
|
@@ -497,6 +598,8 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
|
|
|
497
598
|
// The rewriting stages, run by the declared pipeline: the driver — not this
|
|
498
599
|
// call order — is what keeps the confusable fold's soundness precondition
|
|
499
600
|
// true once an erasing stage follows it (see lib/layer-pipeline.mjs).
|
|
601
|
+
/** @type {FaultLedger} */
|
|
602
|
+
const ledger = { warnings: [], released: [] };
|
|
500
603
|
const {
|
|
501
604
|
updatedInput: current,
|
|
502
605
|
changed,
|
|
@@ -511,6 +614,7 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
|
|
|
511
614
|
rehydrate ?? (secretsOn ? guardedRehydrate : null),
|
|
512
615
|
process.env,
|
|
513
616
|
secretsOn,
|
|
617
|
+
ledger,
|
|
514
618
|
),
|
|
515
619
|
);
|
|
516
620
|
if (deny !== undefined)
|
|
@@ -518,7 +622,7 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
|
|
|
518
622
|
permissionDecision: PermissionDecision.DENY,
|
|
519
623
|
permissionDecisionReason: deny,
|
|
520
624
|
});
|
|
521
|
-
contexts.push(...layerContexts);
|
|
625
|
+
contexts.push(...layerContexts, ...ledger.warnings);
|
|
522
626
|
// A body that never settled: the pipeline hands back its LAST pass's sanitized
|
|
523
627
|
// input plus the reason, and the verdict is this hook's registered fault
|
|
524
628
|
// posture — never a verdict decided here. Open proceeds with `current` (the
|
|
@@ -529,11 +633,16 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
|
|
|
529
633
|
const faultFields = hookFailureFields(true, new Error(fault), {
|
|
530
634
|
input: { tool_name: tool, tool_input: current },
|
|
531
635
|
});
|
|
636
|
+
if (faultFields.permissionDecision === PermissionDecision.DENY)
|
|
637
|
+
return emitTraced(emitTrace, input.tool_name, faultFields);
|
|
532
638
|
if (faultFields.permissionDecisionReason !== undefined)
|
|
533
639
|
asks.push(String(faultFields.permissionDecisionReason));
|
|
534
640
|
if (faultFields.additionalContext !== undefined)
|
|
535
641
|
contexts.push(String(faultFields.additionalContext));
|
|
536
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);
|
|
537
646
|
|
|
538
647
|
// Placeholder advisory for tools OUTSIDE the rehydrated set (Bash, MCP,
|
|
539
648
|
// anything unknown): rehydration cannot re-anchor these, so a placeholder in
|
|
@@ -559,20 +668,46 @@ export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
|
|
|
559
668
|
current,
|
|
560
669
|
asks,
|
|
561
670
|
contexts,
|
|
562
|
-
|
|
671
|
+
gateAsk,
|
|
563
672
|
pendingGapNotice: gapNotice !== null,
|
|
564
673
|
sessionId: input.session_id,
|
|
565
674
|
}),
|
|
566
675
|
);
|
|
567
676
|
}
|
|
568
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
|
+
|
|
569
703
|
/**
|
|
570
704
|
* Assemble the hookSpecificOutput fields from the per-layer results, or null
|
|
571
705
|
* for a clean no-op (nothing asked, changed, or annotated). Records the gate
|
|
572
706
|
* acknowledgement and the coverage-gap notice only when they actually land in
|
|
573
|
-
* the response
|
|
707
|
+
* the response — a deny above returns first, so a preempted ask is not marked
|
|
708
|
+
* seen.
|
|
574
709
|
* @param {{ changed: boolean, current: any, asks: string[], contexts: string[],
|
|
575
|
-
*
|
|
710
|
+
* gateAsk: string | null, pendingGapNotice: boolean, sessionId?: string }} parts
|
|
576
711
|
* @returns {Record<string, unknown> | null}
|
|
577
712
|
*/
|
|
578
713
|
function assembleResponse({
|
|
@@ -580,10 +715,15 @@ function assembleResponse({
|
|
|
580
715
|
current,
|
|
581
716
|
asks,
|
|
582
717
|
contexts,
|
|
583
|
-
|
|
718
|
+
gateAsk,
|
|
584
719
|
pendingGapNotice,
|
|
585
720
|
sessionId,
|
|
586
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
|
+
}
|
|
587
727
|
if (asks.length === 0 && !changed && contexts.length === 0) return null;
|
|
588
728
|
|
|
589
729
|
/** @type {Record<string, unknown>} */
|
|
@@ -597,9 +737,6 @@ function assembleResponse({
|
|
|
597
737
|
fields.permissionDecisionReason = asks.join("\n\n");
|
|
598
738
|
}
|
|
599
739
|
if (contexts.length > 0) fields.additionalContext = contexts.join(" ");
|
|
600
|
-
// Record the gate ack only now that the ask is actually in the response — a
|
|
601
|
-
// rehydrate deny above returns first, so a preempted ask is not marked seen.
|
|
602
|
-
if (pendingGateAck) acknowledgeAlert(sessionId);
|
|
603
740
|
if (pendingGapNotice) recordInstructionsLoadedNotice(sessionId);
|
|
604
741
|
return fields;
|
|
605
742
|
}
|
|
@@ -609,9 +746,11 @@ function assembleResponse({
|
|
|
609
746
|
* ToolCallEvent and returns a Verdict, so a non-Claude host can run the same
|
|
610
747
|
* sanitization pipeline through its own adapter. The wired Claude CLI below
|
|
611
748
|
* routes through this judge and renders the Verdict with the Claude adapter; on
|
|
612
|
-
* any throw (a control-plane package-load failure
|
|
613
|
-
*
|
|
614
|
-
* 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.
|
|
615
754
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
616
755
|
* @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
|
|
617
756
|
* @param {{
|
|
@@ -637,13 +776,18 @@ export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
|
|
|
637
776
|
const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
|
|
638
777
|
const { Decision, EventKind } = controlPlane();
|
|
639
778
|
// A payload the adapter cannot classify (a missing/unexpected hook_event_name)
|
|
640
|
-
// would drive the pipeline with an empty event and no-op to
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
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.
|
|
645
784
|
if (event.event === EventKind.UNKNOWN)
|
|
646
|
-
|
|
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
|
+
);
|
|
647
791
|
// The session identity and the permission mode travel in `meta`, not alongside
|
|
648
792
|
// the tool input. A gate keyed on the session (a once-per-session checkpoint)
|
|
649
793
|
// cannot tell two sessions apart without the first, and a gate keyed on the
|
|
@@ -770,8 +914,8 @@ export const REDACTION_HINT = "[REDACTED";
|
|
|
770
914
|
|
|
771
915
|
/**
|
|
772
916
|
* True when a faulting PreToolUse call is the one case the OPEN posture must
|
|
773
|
-
*
|
|
774
|
-
* 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
|
|
775
919
|
* hint-prefixed prose such as `[REDACTED…]` is not mistaken for one. Such a
|
|
776
920
|
* placeholder stands for a secret the sanitizer redacted out of the model's
|
|
777
921
|
* view; with the sanitizer down, rehydration cannot translate it back, so
|
|
@@ -782,7 +926,7 @@ export const REDACTION_HINT = "[REDACTED";
|
|
|
782
926
|
*
|
|
783
927
|
* Deliberately NOT exhaustive over every clobber path: Bash can also write a
|
|
784
928
|
* placeholder to disk (`>`, `tee`, `sed -i`, a heredoc), but command strings
|
|
785
|
-
* mention "[REDACTED" benignly far too often for
|
|
929
|
+
* mention "[REDACTED" benignly far too often for a refusal to hold precision
|
|
786
930
|
* there. That is an accepted gap, named in THREAT-MODEL.md's carve-out.
|
|
787
931
|
* @param {unknown} input raw parsed PreToolUse payload (undefined if unparsed)
|
|
788
932
|
* @returns {boolean}
|
|
@@ -805,8 +949,9 @@ export function hintedWriteFault(input) {
|
|
|
805
949
|
* fail-CLOSED verdict of {@link failClosedFields} when the caller set
|
|
806
950
|
* AGENT_SANITIZER_FAIL_OPEN=0. The open default has ONE carve-out, declared in
|
|
807
951
|
* this hook's fault policy below: a write-shaped call whose input carries a
|
|
808
|
-
* `[REDACTED…]` placeholder
|
|
809
|
-
* {@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.
|
|
810
955
|
*
|
|
811
956
|
* The posture covers this hook's own failures, whatever their cause. What it
|
|
812
957
|
* does NOT cover is the verdict of a sanitizer that ran: a payload
|
|
@@ -833,20 +978,71 @@ export function hookFailureFields(parsedOk, err, opts = {}) {
|
|
|
833
978
|
);
|
|
834
979
|
}
|
|
835
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
|
+
|
|
836
1024
|
// This hook's entry in the one posture table (lib/hook-fault.mjs). The OPEN arm
|
|
837
|
-
// keeps the shared default — a warning context and no verdict —
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
// placeholder would be persisted literally over the secret it stands for (see
|
|
843
|
-
// hintedWriteFault). The ask keeps a human in the loop exactly as the closed
|
|
844
|
-
// 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.
|
|
845
1030
|
registerFaultPolicy(HOOK_NAME, {
|
|
846
1031
|
event: HookEvent.PRE_TOOL_USE,
|
|
847
1032
|
guarded: "tool input",
|
|
848
|
-
alwaysClosed: KNOB_DECIDES,
|
|
849
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);
|
|
850
1046
|
// Non-carve-out faults take the SHARED open rendering, not a copy of it —
|
|
851
1047
|
// hook-fault.mjs owns that body, and a restated one would silently drift.
|
|
852
1048
|
// The carve-out itself rides the secret opt-in: with secrets off no
|
|
@@ -854,32 +1050,60 @@ registerFaultPolicy(HOOK_NAME, {
|
|
|
854
1050
|
// in a write is literal prose and holding it would be a false positive.
|
|
855
1051
|
if (!secretsEnabled(ctx.env) || !hintedWriteFault(ctx.input))
|
|
856
1052
|
return defaultOpen(ctx);
|
|
857
|
-
// parsedOk is hardcoded true
|
|
858
|
-
//
|
|
859
|
-
//
|
|
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.
|
|
860
1056
|
const closed = failClosedFields(true, ctx.err, {
|
|
861
1057
|
messages: ctx.messages,
|
|
862
1058
|
hint: ctx.hint,
|
|
863
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
|
+
};
|
|
864
1100
|
return {
|
|
865
|
-
fields: {
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
`${REDACTION_HINT}…] placeholder text, which stands for a redacted ` +
|
|
870
|
-
`secret the unavailable sanitizer cannot translate back; proceeding ` +
|
|
871
|
-
`would overwrite the real secret with the placeholder, so the call ` +
|
|
872
|
-
`is held even under the fail-open posture. Retry once the sanitizer ` +
|
|
873
|
-
`recovers, or ask the user to make this change.`,
|
|
874
|
-
},
|
|
1101
|
+
fields: failClosedFields(ctx.parsedOk, ctx.err, {
|
|
1102
|
+
messages: ctx.messages,
|
|
1103
|
+
hint: ctx.hint,
|
|
1104
|
+
}),
|
|
875
1105
|
};
|
|
876
1106
|
},
|
|
877
|
-
closed: (ctx) => ({
|
|
878
|
-
fields: failClosedFields(ctx.parsedOk, ctx.err, {
|
|
879
|
-
messages: ctx.messages,
|
|
880
|
-
hint: ctx.hint,
|
|
881
|
-
}),
|
|
882
|
-
}),
|
|
883
1107
|
});
|
|
884
1108
|
|
|
885
1109
|
// Stryker disable all: CLI wiring — it runs only in the spawned hook
|