agent-sanitizer 2.60.0 → 2.60.2
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 +12 -12
- package/THREAT-MODEL.md +21 -9
- package/claude-hooks/lib/layer-pipeline.mjs +87 -59
- package/claude-hooks/lib/placeholder-grammar.mjs +24 -46
- package/claude-hooks/lib/reveal.mjs +39 -12
- package/claude-hooks/lib/secret-annotate.mjs +23 -10
- package/claude-hooks/lib/secret-drop-guard.mjs +27 -9
- package/claude-hooks/pretooluse-sanitize.mjs +141 -108
- package/claude-hooks/sanitize-output.mjs +63 -37
- package/claude-hooks/sanitize-user-prompt.mjs +11 -13
- package/package.json +1 -1
- package/src/html.mjs +9 -52
- package/src/layer2-placeholder.mjs +60 -0
- package/types/claude-hooks/lib/layer-pipeline.d.mts +26 -45
- package/types/claude-hooks/lib/placeholder-grammar.d.mts +3 -23
- package/types/claude-hooks/lib/reveal.d.mts +15 -3
- package/types/claude-hooks/lib/secret-drop-guard.d.mts +9 -5
- package/types/claude-hooks/pretooluse-sanitize.d.mts +41 -28
- package/types/claude-hooks/sanitize-output.d.mts +13 -10
- package/types/claude-hooks/sanitize-user-prompt.d.mts +4 -5
- package/types/html.d.mts +4 -22
- package/types/layer2-placeholder.d.mts +21 -0
- package/types/src/layer2-placeholder.d.mts +21 -0
|
@@ -22,10 +22,12 @@
|
|
|
22
22
|
* {@link CONFIRM_TTL_MS}, so one confirmation approves exactly one prompt
|
|
23
23
|
* Write — never a stale or standing auto-approval.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
25
|
+
* Skip-on-ambiguity (per the package's precision-over-recall doctrine): a
|
|
26
26
|
* failed git probe, an unmappable redaction view, or a secret-free file all
|
|
27
27
|
* skip the guard — a false denial here blocks legitimate work, while a false
|
|
28
|
-
* pass costs only what
|
|
28
|
+
* pass costs only what the call would do without this guard. This is a
|
|
29
|
+
* guard-local precision choice, not the hook fault posture: an unexpected
|
|
30
|
+
* throw still propagates to the hook's registered policy (lib/hook-fault.mjs).
|
|
29
31
|
*/
|
|
30
32
|
import { createHash } from "node:crypto";
|
|
31
33
|
import { lstatSync, readdirSync, unlinkSync } from "node:fs";
|
|
@@ -111,22 +113,38 @@ export function sweepStaleConfirms() {
|
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
/**
|
|
114
|
-
* Whether git tracks `filePath
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
116
|
+
* Whether git tracks `filePath`, with every probe failure on the skip side the
|
|
117
|
+
* module doc declares. Exit 0 is tracked and exit 1 untracked. Exit 128 is
|
|
118
|
+
* either "not a repository" — no git recovery exists, so the guard applies — or
|
|
119
|
+
* a repository git could not read (a corrupt index, a permission error), which
|
|
120
|
+
* says nothing about the file; a second probe asks git which, reading its
|
|
121
|
+
* printed answer (`false` inside a bare repository or a `.git` directory is
|
|
122
|
+
* also exit 0, and also has no recovery path). A spawn-level
|
|
123
|
+
* failure (no git binary) or any other status also means we cannot tell —
|
|
124
|
+
* report tracked so the guard skips rather than denying on broken tooling.
|
|
119
125
|
* @param {string} filePath
|
|
120
126
|
* @param {typeof spawnSync} [spawn]
|
|
121
127
|
* @returns {boolean}
|
|
122
128
|
*/
|
|
123
129
|
export function gitTracked(filePath, spawn = spawnSync) {
|
|
130
|
+
const cwd = dirname(filePath);
|
|
124
131
|
const res = spawn("git", ["ls-files", "--error-unmatch", "--", filePath], {
|
|
125
|
-
cwd
|
|
132
|
+
cwd,
|
|
126
133
|
stdio: "ignore",
|
|
127
134
|
});
|
|
128
135
|
if (res.error) return true;
|
|
129
|
-
return res.status === 0;
|
|
136
|
+
if (res.status === 0 || res.status === 1) return res.status === 0;
|
|
137
|
+
if (res.status !== 128) return true;
|
|
138
|
+
const repo = spawn("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
139
|
+
cwd,
|
|
140
|
+
encoding: "utf8",
|
|
141
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
142
|
+
});
|
|
143
|
+
if (repo.error) return true;
|
|
144
|
+
// Exit 0 does not mean "in a work tree": inside a bare repository or a
|
|
145
|
+
// repository's own `.git` directory it prints `false`. Such a path has no
|
|
146
|
+
// work-tree/index recovery, so only the printed `true` skips the guard.
|
|
147
|
+
return repo.status === 0 && String(repo.stdout).trim() === "true";
|
|
130
148
|
}
|
|
131
149
|
|
|
132
150
|
/**
|
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PreToolUse content-protection orchestrator. Runs five
|
|
2
|
+
* PreToolUse content-protection orchestrator. Runs five stages in ONE process
|
|
3
|
+
* (named, not numbered — the public "Layer N" numbering is a different axis):
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
5
|
+
* - gate: invisible-char injection gate (lib/invisible-alert.mjs)
|
|
6
|
+
* - confusables: homoglyph normalization of paths & commands
|
|
7
|
+
* (agent-sanitizer/confusables, namespace-guard scanner injected)
|
|
8
|
+
* - authored-content: stego / terminal-control stripping of model-authored
|
|
9
|
+
* fields (lib/authored-content.mjs)
|
|
10
|
+
* - rehydrate: secret-redaction (Layer-4) placeholders in Edit/Write inputs
|
|
11
|
+
* (agent-sanitizer/rehydrate, redactor-daemon io injected)
|
|
12
|
+
* - layer2-rehydrate: keyed Layer-2 splice placeholders in Edit/Write inputs
|
|
13
|
+
* ({@link rehydrateLayer2}, span store in lib/reveal.mjs) — a grammar
|
|
14
|
+
* disjoint from the secret one, run after it
|
|
14
15
|
*
|
|
15
16
|
* WHY ONE PROCESS: Claude Code runs PreToolUse hooks in parallel and does NOT
|
|
16
17
|
* chain their `updatedInput` — each hook sees the original input and the last to
|
|
17
|
-
* finish wins.
|
|
18
|
-
* the
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* skip decisions sound once the erasing strip follows it.
|
|
18
|
+
* finish wins. As separate hooks, the confusable and authored-content stages
|
|
19
|
+
* both rewrite the Bash `command` field from the original text, so a command
|
|
20
|
+
* carrying both payloads would have one fix clobbered by the other. Composing
|
|
21
|
+
* them here makes the rewrite deterministic and pays one Node start instead of
|
|
22
|
+
* three on the hottest path. Every stage after the gate runs through the
|
|
23
|
+
* declared pipeline in lib/layer-pipeline.mjs, which keeps the confusable
|
|
24
|
+
* fold's skip decisions sound once the erasing strip follows it.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
* package; this file binds its peers (namespace-guard, the
|
|
28
|
-
* filesystem) into them.
|
|
26
|
+
* The confusables and rehydrate stages are provider-agnostic transforms in the
|
|
27
|
+
* agent-sanitizer package; this file binds its peers (namespace-guard, the
|
|
28
|
+
* redactor daemon, the filesystem) into them.
|
|
29
29
|
*/
|
|
30
30
|
import { createRequire } from "node:module";
|
|
31
31
|
import { readFileSync } from "node:fs";
|
|
@@ -69,6 +69,7 @@ import { secretsEnabled } from "./lib/env-config.mjs";
|
|
|
69
69
|
import { readKnob } from "./lib/knobs.mjs";
|
|
70
70
|
import { withSecretDropGuard } from "./lib/secret-drop-guard.mjs";
|
|
71
71
|
import {
|
|
72
|
+
containsPlaceholder,
|
|
72
73
|
placeholderNotice,
|
|
73
74
|
layer2PlaceholderNotice,
|
|
74
75
|
layer2Keys,
|
|
@@ -113,10 +114,11 @@ export const PRE_TOOL_USE_MESSAGES = Object.freeze({
|
|
|
113
114
|
remedy: DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
114
115
|
});
|
|
115
116
|
|
|
116
|
-
//
|
|
117
|
-
// its doc for the fail-OPEN hazard of a bare static
|
|
118
|
-
// leaves these bindings undefined, so the
|
|
119
|
-
//
|
|
117
|
+
// The confusables and rehydrate stages come from the agent-sanitizer package,
|
|
118
|
+
// bound via lazyImport (see its doc for the fail-OPEN hazard of a bare static
|
|
119
|
+
// npm import); a failed load leaves these bindings undefined, so the stage calls
|
|
120
|
+
// below throw into the CLI's catch, which applies this hook's fault posture
|
|
121
|
+
// (hookFailureFields).
|
|
120
122
|
const { normalizeConfusables, normalizeContext } =
|
|
121
123
|
/** @type {typeof import("agent-sanitizer/confusables")} */ (
|
|
122
124
|
await lazyImport("agent-sanitizer/confusables")
|
|
@@ -235,11 +237,12 @@ function substituteLayer2(text, field) {
|
|
|
235
237
|
* removed #<key>]` placeholders (copied from sanitized tool output) has each
|
|
236
238
|
* one restored to the stored original bytes from the reveal store's span files.
|
|
237
239
|
*
|
|
238
|
-
* SECURITY invariant: the stored span content was
|
|
239
|
-
* (sanitize-output runs strict web-ingress
|
|
240
|
-
* before persistSpan), so this rehydration
|
|
241
|
-
* worst it can restore is `[REDACTED…]`
|
|
242
|
-
*
|
|
240
|
+
* SECURITY invariant, with the secret layer on: the stored span content was
|
|
241
|
+
* REDACTED before persistence (sanitize-output runs strict web-ingress
|
|
242
|
+
* redaction on each splice original before persistSpan), so this rehydration
|
|
243
|
+
* can never write a raw secret — the worst it can restore is `[REDACTED…]`
|
|
244
|
+
* placeholder text, which the on-disk tripwire then flags on later Reads. With
|
|
245
|
+
* the layer off, spans are stored verbatim and the context claims nothing.
|
|
243
246
|
*
|
|
244
247
|
* Composition with the secret rehydrator: this runs AFTER it (a later terminal
|
|
245
248
|
* layer), and the two grammars are disjoint — a Layer-2 placeholder never
|
|
@@ -256,9 +259,12 @@ function substituteLayer2(text, field) {
|
|
|
256
259
|
* edits / notebook JSON cannot be rehydrated).
|
|
257
260
|
* @param {string} tool
|
|
258
261
|
* @param {any} toolInput
|
|
262
|
+
* @param {boolean} [secretsOn] the run's secret opt-in (decided once by
|
|
263
|
+
* buildPreToolUseResponse; a direct caller reads the env); only with it on
|
|
264
|
+
* does the context claim the restored bytes were redacted
|
|
259
265
|
* @returns {{ updatedInput: any, context: string } | { deny: string } | null}
|
|
260
266
|
*/
|
|
261
|
-
export function rehydrateLayer2(tool, toolInput) {
|
|
267
|
+
export function rehydrateLayer2(tool, toolInput, secretsOn = secretsEnabled()) {
|
|
262
268
|
const hasL2 = (/** @type {unknown} */ text) =>
|
|
263
269
|
typeof text === "string" && layer2Keys(text).length > 0;
|
|
264
270
|
if (
|
|
@@ -301,24 +307,13 @@ export function rehydrateLayer2(tool, toolInput) {
|
|
|
301
307
|
updatedInput: { ...toolInput, [field]: result.text },
|
|
302
308
|
context:
|
|
303
309
|
`${result.restored} Layer-2 removed-content placeholder(s) in ${field} ` +
|
|
304
|
-
`were restored to the stored original content
|
|
305
|
-
|
|
310
|
+
`were restored to the stored original content` +
|
|
311
|
+
(secretsOn
|
|
312
|
+
? ` (secrets inside were redacted before storage, so no raw secret is written).`
|
|
313
|
+
: "."),
|
|
306
314
|
};
|
|
307
315
|
}
|
|
308
316
|
|
|
309
|
-
/**
|
|
310
|
-
* The wired default gates the whole rehydration layer on the secret opt-in:
|
|
311
|
-
* with secrets off the output hook never inserts placeholders, so there is
|
|
312
|
-
* nothing to re-anchor — and skipping here (rather than inside the layer)
|
|
313
|
-
* means an Edit on a plain file never touches the file system twice or spawns
|
|
314
|
-
* the daemon. Consulted per call, not at module load, so a knob set after the
|
|
315
|
-
* bundle loads still governs the next tool call.
|
|
316
|
-
* @param {string} tool
|
|
317
|
-
* @param {any} toolInput
|
|
318
|
-
*/
|
|
319
|
-
const defaultRehydrate = async (tool, toolInput) =>
|
|
320
|
-
secretsEnabled() ? guardedRehydrate(tool, toolInput) : null;
|
|
321
|
-
|
|
322
317
|
/**
|
|
323
318
|
* Trace the response on the way out — "noop" (clean pass-through), "deny",
|
|
324
319
|
* "ask", or "modified" (input rewritten and/or context attached) — and return
|
|
@@ -342,21 +337,28 @@ function emitTraced(emitTrace, toolName, fields) {
|
|
|
342
337
|
}
|
|
343
338
|
|
|
344
339
|
/**
|
|
345
|
-
* The declared
|
|
340
|
+
* The declared stage chain after the gate. Every entry states the two properties
|
|
346
341
|
* the driver reasons about — whether it ERASES code points another layer reads,
|
|
347
342
|
* and whether its own decision is SKIP-BASED and therefore invalidated by such
|
|
348
343
|
* an erasure — so the confusable fold's ordering precondition is enforced by the
|
|
349
344
|
* table instead of restated as a comment.
|
|
350
345
|
*
|
|
351
|
-
*
|
|
346
|
+
* The authored-content stage is dropped from the table (not merely skipped) when
|
|
352
347
|
* AGENT_SANITIZER_OUTPUT_DISABLED=1, so the driver sees the chain that will
|
|
353
348
|
* actually run: with no erasing layer left after the fold there is no fixed
|
|
354
|
-
* point to reach and no extra pass to pay for.
|
|
355
|
-
*
|
|
349
|
+
* point to reach and no extra pass to pay for. A null `rehydrate` drops the
|
|
350
|
+
* secret rehydrator the same way.
|
|
351
|
+
* @param {((tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>) | null} rehydrate
|
|
356
352
|
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
353
|
+
* @param {boolean} [secretsOn] the run's secret opt-in (see
|
|
354
|
+
* buildPreToolUseResponse, which decides it once and passes it here)
|
|
357
355
|
* @returns {import("./lib/layer-pipeline.mjs").Layer[]}
|
|
358
356
|
*/
|
|
359
|
-
export function preToolUseLayers(
|
|
357
|
+
export function preToolUseLayers(
|
|
358
|
+
rehydrate,
|
|
359
|
+
env = process.env,
|
|
360
|
+
secretsOn = secretsEnabled(env),
|
|
361
|
+
) {
|
|
360
362
|
/** @type {import("./lib/layer-pipeline.mjs").Layer[]} */
|
|
361
363
|
const layers = [
|
|
362
364
|
{
|
|
@@ -400,10 +402,12 @@ export function preToolUseLayers(rehydrate, env = process.env) {
|
|
|
400
402
|
skipBased: false,
|
|
401
403
|
// Terminal by contract, not by luck: it re-anchors Edit/Write inputs onto
|
|
402
404
|
// the on-disk bytes, and the secrets it restores must NOT be re-stripped
|
|
403
|
-
// by
|
|
405
|
+
// by the authored-content stage or re-folded on a later pass.
|
|
404
406
|
terminal: true,
|
|
405
407
|
run: async (tool, toolInput) => {
|
|
406
|
-
const rehydrated = await rehydrate(
|
|
408
|
+
const rehydrated = await /** @type {NonNullable<typeof rehydrate>} */ (
|
|
409
|
+
rehydrate
|
|
410
|
+
)(tool, toolInput);
|
|
407
411
|
if (!rehydrated) return null;
|
|
408
412
|
if ("deny" in rehydrated) return { deny: rehydrated.deny };
|
|
409
413
|
return {
|
|
@@ -421,12 +425,17 @@ export function preToolUseLayers(rehydrate, env = process.env) {
|
|
|
421
425
|
// legitimately contain [REDACTED…] text — must not be re-fed to the
|
|
422
426
|
// secret resolver or re-stripped by an earlier layer.
|
|
423
427
|
terminal: true,
|
|
424
|
-
run: (tool, toolInput) => rehydrateLayer2(tool, toolInput),
|
|
428
|
+
run: (tool, toolInput) => rehydrateLayer2(tool, toolInput, secretsOn),
|
|
425
429
|
},
|
|
426
430
|
];
|
|
427
|
-
return
|
|
428
|
-
|
|
429
|
-
|
|
431
|
+
return layers.filter(
|
|
432
|
+
(layer) =>
|
|
433
|
+
!(layer.name === "rehydrate" && rehydrate === null) &&
|
|
434
|
+
!(
|
|
435
|
+
layer.name === "authored-content" &&
|
|
436
|
+
readKnob("AGENT_SANITIZER_OUTPUT_DISABLED", env)
|
|
437
|
+
),
|
|
438
|
+
);
|
|
430
439
|
}
|
|
431
440
|
|
|
432
441
|
// The tools whose path field Claude Code sends as an absolute path by
|
|
@@ -464,29 +473,36 @@ function toolTargetDir(tool, toolInput) {
|
|
|
464
473
|
}
|
|
465
474
|
|
|
466
475
|
/**
|
|
467
|
-
* Compose the
|
|
468
|
-
*
|
|
469
|
-
*
|
|
476
|
+
* Compose the five stages. Returns the `hookSpecificOutput` fields to emit, or
|
|
477
|
+
* null for a clean no-op. Throws only if a stage's engine throws; the caller
|
|
478
|
+
* then applies this hook's fault posture (open by default — see
|
|
479
|
+
* hookFailureFields). Every exit routes through emitTraced.
|
|
470
480
|
* @param {any} input parsed PreToolUse event
|
|
471
481
|
* @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
|
|
472
|
-
* injectable for tests
|
|
473
|
-
*
|
|
482
|
+
* injectable for tests, and then run whatever the secret opt-in says; the
|
|
483
|
+
* default binds the real redactor-daemon io (the layer reads the target file
|
|
484
|
+
* and maps secrets through the daemon) and runs only with the opt-in on
|
|
474
485
|
* @param {import("./lib/trace.mjs").TraceFn} [sink] where engagement is
|
|
475
486
|
* announced; a host with its own trace channel passes its sink (see lib/trace.mjs)
|
|
487
|
+
* @param {import("./lib/layer-pipeline.mjs").Layer[]} [layers] the stage table,
|
|
488
|
+
* injectable so a test can drive one the shipped stages never produce (an
|
|
489
|
+
* oscillating stage); defaults to {@link preToolUseLayers} for this call
|
|
476
490
|
* @returns {Promise<Record<string, unknown> | null>}
|
|
477
491
|
*/
|
|
478
|
-
export async function buildPreToolUseResponse(
|
|
479
|
-
input,
|
|
480
|
-
rehydrate = defaultRehydrate,
|
|
481
|
-
sink,
|
|
482
|
-
) {
|
|
492
|
+
export async function buildPreToolUseResponse(input, rehydrate, sink, layers) {
|
|
483
493
|
// Every path into the announcement runs through here, so this is the one
|
|
484
494
|
// place a host sink has to be bound (see hookTrace).
|
|
485
495
|
const emitTrace = hookTrace(sink);
|
|
496
|
+
// THE secret opt-in decision for this call: the default rehydrator, the
|
|
497
|
+
// Layer-2 rehydration prose and the placeholder advisory all read this one
|
|
498
|
+
// value. With it off the output hook never inserts placeholders, so there is
|
|
499
|
+
// nothing to re-anchor and an Edit never spawns the daemon. Read per call,
|
|
500
|
+
// not at module load, so a knob set after the bundle loads still governs.
|
|
501
|
+
const secretsOn = secretsEnabled();
|
|
486
502
|
const asks = [];
|
|
487
503
|
const contexts = [];
|
|
488
504
|
|
|
489
|
-
//
|
|
505
|
+
// The gate. Persists across the session until the injected files are
|
|
490
506
|
// cleaned. It asks ONCE (a hard checkpoint, recorded once emitted) then
|
|
491
507
|
// degrades to a passive reminder, so it doesn't prompt on every tool call.
|
|
492
508
|
const findings = invisibleCharAlert(input.session_id);
|
|
@@ -514,21 +530,46 @@ export async function buildPreToolUseResponse(
|
|
|
514
530
|
);
|
|
515
531
|
if (gapNotice !== null) contexts.push(gapNotice);
|
|
516
532
|
|
|
517
|
-
//
|
|
518
|
-
// is what keeps the confusable fold's soundness precondition
|
|
519
|
-
// erasing
|
|
533
|
+
// The rewriting stages, run by the declared pipeline: the driver — not this
|
|
534
|
+
// call order — is what keeps the confusable fold's soundness precondition
|
|
535
|
+
// true once an erasing stage follows it (see lib/layer-pipeline.mjs).
|
|
520
536
|
const {
|
|
521
537
|
updatedInput: current,
|
|
522
538
|
changed,
|
|
523
539
|
contexts: layerContexts,
|
|
524
540
|
deny,
|
|
525
|
-
|
|
541
|
+
fault,
|
|
542
|
+
} = await runLayerPipeline(
|
|
543
|
+
tool,
|
|
544
|
+
toolInput,
|
|
545
|
+
layers ??
|
|
546
|
+
preToolUseLayers(
|
|
547
|
+
rehydrate ?? (secretsOn ? guardedRehydrate : null),
|
|
548
|
+
process.env,
|
|
549
|
+
secretsOn,
|
|
550
|
+
),
|
|
551
|
+
);
|
|
526
552
|
if (deny !== undefined)
|
|
527
553
|
return emitTraced(emitTrace, input.tool_name, {
|
|
528
554
|
permissionDecision: PermissionDecision.DENY,
|
|
529
555
|
permissionDecisionReason: deny,
|
|
530
556
|
});
|
|
531
557
|
contexts.push(...layerContexts);
|
|
558
|
+
// A body that never settled: the pipeline hands back its LAST pass's sanitized
|
|
559
|
+
// input plus the reason, and the verdict is this hook's registered fault
|
|
560
|
+
// posture — never a verdict decided here. Open proceeds with `current` (the
|
|
561
|
+
// sanitized value, not the raw input) and the reason as context; closed
|
|
562
|
+
// returns the policy's closed verdict. `input` lets the policy's write-shaped
|
|
563
|
+
// placeholder carve-out judge the value that would actually be written.
|
|
564
|
+
if (fault !== undefined) {
|
|
565
|
+
const faultFields = hookFailureFields(true, new Error(fault), {
|
|
566
|
+
input: { tool_name: tool, tool_input: current },
|
|
567
|
+
});
|
|
568
|
+
if (faultFields.permissionDecisionReason !== undefined)
|
|
569
|
+
asks.push(String(faultFields.permissionDecisionReason));
|
|
570
|
+
if (faultFields.additionalContext !== undefined)
|
|
571
|
+
contexts.push(String(faultFields.additionalContext));
|
|
572
|
+
}
|
|
532
573
|
|
|
533
574
|
// Placeholder advisory for tools OUTSIDE the rehydrated set (Bash, MCP,
|
|
534
575
|
// anything unknown): rehydration cannot re-anchor these, so a placeholder in
|
|
@@ -538,7 +579,7 @@ export async function buildPreToolUseResponse(
|
|
|
538
579
|
// what the tool will actually receive.
|
|
539
580
|
// Gated on the secret opt-in like the layer itself: with secrets off,
|
|
540
581
|
// placeholder-shaped text is ordinary prose and the advisory is noise.
|
|
541
|
-
const notice =
|
|
582
|
+
const notice = secretsOn ? placeholderNotice(tool, current) : null;
|
|
542
583
|
if (notice !== null) contexts.push(notice);
|
|
543
584
|
// Same advisory for keyed Layer-2 splice placeholders (disjoint grammar, own
|
|
544
585
|
// store): a Bash/MCP write path would persist them literally too, and the
|
|
@@ -589,10 +630,6 @@ function assembleResponse({
|
|
|
589
630
|
if (changed) fields.updatedInput = current;
|
|
590
631
|
if (asks.length > 0) {
|
|
591
632
|
fields.permissionDecision = PermissionDecision.ASK;
|
|
592
|
-
// Stryker disable next-line StringLiteral: the gate is the only source that
|
|
593
|
-
// pushes onto `asks`, so the array never holds more than one reason and the
|
|
594
|
-
// separator is unobservable — join("") is equivalent. The paragraph break is
|
|
595
|
-
// kept for the day a second ask source is added.
|
|
596
633
|
fields.permissionDecisionReason = asks.join("\n\n");
|
|
597
634
|
}
|
|
598
635
|
if (contexts.length > 0) fields.additionalContext = contexts.join(" ");
|
|
@@ -604,13 +641,13 @@ function assembleResponse({
|
|
|
604
641
|
}
|
|
605
642
|
|
|
606
643
|
/**
|
|
607
|
-
* Agent-agnostic judge over the
|
|
644
|
+
* Agent-agnostic judge over the five stages: consumes a control-plane
|
|
608
645
|
* ToolCallEvent and returns a Verdict, so a non-Claude host can run the same
|
|
609
646
|
* sanitization pipeline through its own adapter. The wired Claude CLI below
|
|
610
647
|
* routes through this judge and renders the Verdict with the Claude adapter; on
|
|
611
648
|
* any throw (a control-plane package-load failure included) it falls back to
|
|
612
|
-
*
|
|
613
|
-
*
|
|
649
|
+
* hookFailureFields — a native response that needs no package — so the
|
|
650
|
+
* caller's fault posture holds even when the adapter never loaded.
|
|
614
651
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
615
652
|
* @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
|
|
616
653
|
* @param {{
|
|
@@ -629,10 +666,10 @@ export async function judgePreToolUseSanitize(event, rehydrate, opts = {}) {
|
|
|
629
666
|
const { gates = [], trace: sink } = opts;
|
|
630
667
|
// MERGED over the defaults, never substituted for them. A host that overrides
|
|
631
668
|
// one field would otherwise leave the rest undefined, and the miss lands in
|
|
632
|
-
// the
|
|
669
|
+
// the fault path: hookFailureFields runs inside runJudgeCli's catch, so a
|
|
633
670
|
// TypeError on a missing field escapes the handler, the hook exits with no
|
|
634
671
|
// stdout, and Claude reads a PreToolUse hook that produced no response as
|
|
635
|
-
// non-blocking — the
|
|
672
|
+
// non-blocking — the posture's ask or warning becomes a silent pass.
|
|
636
673
|
const messages = { ...PRE_TOOL_USE_MESSAGES, ...opts.messages };
|
|
637
674
|
const { Decision, EventKind } = controlPlane();
|
|
638
675
|
// A payload the adapter cannot classify (a missing/unexpected hook_event_name)
|
|
@@ -760,12 +797,11 @@ export function failClosedFields(parsedOk, err, opts = {}) {
|
|
|
760
797
|
};
|
|
761
798
|
}
|
|
762
799
|
|
|
763
|
-
// The redaction-placeholder prefix, restated as a
|
|
764
|
-
//
|
|
765
|
-
// agent-sanitizer package (which exports it as
|
|
766
|
-
// Exported so test/claude-hooks-fail-open.test.mjs
|
|
767
|
-
//
|
|
768
|
-
// passing if this literal drifted shorter and the carve-out over-triggered.
|
|
800
|
+
// The redaction-placeholder prefix the carve-out's reason names, restated as a
|
|
801
|
+
// literal rather than imported: the reason is built on the FAILURE path, which
|
|
802
|
+
// must work exactly when the agent-sanitizer package (which exports it as
|
|
803
|
+
// DEFAULT_HINT) failed to load. Exported so test/claude-hooks-fail-open.test.mjs
|
|
804
|
+
// can pin the two spellings together.
|
|
769
805
|
export const REDACTION_HINT = "[REDACTED";
|
|
770
806
|
// The file-editing tools whose input fields ARE the bytes persisted to disk.
|
|
771
807
|
// Deliberately NOT exhaustive over every clobber path: Bash can also write a
|
|
@@ -782,14 +818,15 @@ export const WRITE_SHAPED_TOOLS = new Set([
|
|
|
782
818
|
|
|
783
819
|
/**
|
|
784
820
|
* True when a faulting PreToolUse call is the one case the OPEN posture must
|
|
785
|
-
* still not pass: a write-shaped tool whose input carries
|
|
786
|
-
*
|
|
787
|
-
*
|
|
788
|
-
*
|
|
789
|
-
*
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
821
|
+
* still not pass: a write-shaped tool whose input carries a redaction
|
|
822
|
+
* placeholder, matched by the real grammar (containsPlaceholder) so
|
|
823
|
+
* hint-prefixed prose such as `[REDACTED…]` is not mistaken for one. Such a
|
|
824
|
+
* placeholder stands for a secret the sanitizer redacted out of the model's
|
|
825
|
+
* view; with the sanitizer down, rehydration cannot translate it back, so
|
|
826
|
+
* letting the call through would persist the literal placeholder text over the
|
|
827
|
+
* real secret on disk — a destructive clobber, not a missed scan.
|
|
828
|
+
* Package-free by construction (a Set lookup and a walk of the already-parsed
|
|
829
|
+
* payload), so it holds when the failure IS the missing package.
|
|
793
830
|
* @param {unknown} input raw parsed PreToolUse payload (undefined if unparsed)
|
|
794
831
|
* @returns {boolean}
|
|
795
832
|
*/
|
|
@@ -798,11 +835,10 @@ export function hintedWriteFault(input) {
|
|
|
798
835
|
/** @type {{tool_name?: unknown, tool_input?: unknown} | undefined} */ (
|
|
799
836
|
input
|
|
800
837
|
);
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
return JSON.stringify(payload?.tool_input ?? null).includes(REDACTION_HINT);
|
|
838
|
+
return (
|
|
839
|
+
WRITE_SHAPED_TOOLS.has(/** @type {string} */ (payload?.tool_name)) &&
|
|
840
|
+
containsPlaceholder(payload?.tool_input)
|
|
841
|
+
);
|
|
806
842
|
}
|
|
807
843
|
|
|
808
844
|
/**
|
|
@@ -918,12 +954,9 @@ export async function cliMain(opts = {}) {
|
|
|
918
954
|
trace: sink,
|
|
919
955
|
}),
|
|
920
956
|
{
|
|
921
|
-
// The
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
// failing) and ASK on any throw after a clean parse, keeping a human in
|
|
925
|
-
// the loop. emitHookResponse renders natively, so either posture holds
|
|
926
|
-
// even when the adapter never loaded.
|
|
957
|
+
// The registered fault policy (hookFailureFields → lib/hook-fault.mjs),
|
|
958
|
+
// WITHOUT the package: emitHookResponse renders natively, so the policy
|
|
959
|
+
// holds even when the adapter never loaded.
|
|
927
960
|
onError: (err, input) =>
|
|
928
961
|
emitHookResponse(
|
|
929
962
|
HookEvent.PRE_TOOL_USE,
|