agent-sanitizer 2.14.14 → 2.16.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 +25 -17
- package/THREAT-MODEL.md +46 -0
- package/claude-hooks/lib/hook-io.mjs +80 -0
- package/claude-hooks/lib/redactor-client.mjs +5 -3
- package/claude-hooks/pretooluse-sanitize.mjs +41 -7
- package/claude-hooks/sanitize-output.mjs +56 -16
- package/claude-hooks/sanitize-user-prompt.mjs +29 -8
- package/package.json +1 -1
- package/types/claude-hooks/lib/hook-io.d.mts +50 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +31 -1
- package/types/claude-hooks/sanitize-output.d.mts +23 -1
- package/types/claude-hooks/sanitize-user-prompt.d.mts +4 -1
package/README.md
CHANGED
|
@@ -114,12 +114,17 @@ buy you:
|
|
|
114
114
|
first web page, and the occasional over-redaction of credential-shaped text —
|
|
115
115
|
`AGENT_SANITIZER_OUTPUT_DISABLED=1` opts out of the rewrites.
|
|
116
116
|
|
|
117
|
-
Failure is loud by design
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
117
|
+
Failure is loud by design. Installed as Claude Code hooks the layers fail
|
|
118
|
+
**open**: a hook that could not run lets the action through rather than halting
|
|
119
|
+
your session on its own breakage — but it says so, in a warning the model and
|
|
120
|
+
the transcript both carry. Set `AGENT_SANITIZER_FAIL_OPEN=0` and the same
|
|
121
|
+
failures block instead: suppressed tool output
|
|
122
|
+
(`[output sanitizer unavailable — original output suppressed]`), blocked
|
|
123
|
+
prompts, permission asks whose reason names the cause. Either way, a plugin that
|
|
124
|
+
never loaded at all is invisible — Claude Code reads a crashed hook as "no
|
|
125
|
+
objection" — so confirm with `/plugin` rather than reading a quiet session as a
|
|
126
|
+
working one. Neither posture touches what a sanitizer that RAN decided (see
|
|
127
|
+
`plugin/README.md`).
|
|
123
128
|
|
|
124
129
|
## Using it with Claude Code
|
|
125
130
|
|
|
@@ -182,8 +187,8 @@ surface is the `--hook=` CLI, so these move between minor versions.
|
|
|
182
187
|
|
|
183
188
|
**`sanitize-output` takes a host-extension bag** — an optional last argument on
|
|
184
189
|
`sanitizeText`, `sanitizeValue`, `evaluateToolOutput`, `judgeSanitizeOutput`, and
|
|
185
|
-
`cliMain`, so a composer that wraps `cliMain` gets the hook's exact
|
|
186
|
-
|
|
190
|
+
`cliMain`, so a composer that wraps `cliMain` gets the hook's exact CLI wiring
|
|
191
|
+
plus its own policy:
|
|
187
192
|
|
|
188
193
|
| Field | Runs | Does |
|
|
189
194
|
| ------------ | -------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
|
@@ -194,11 +199,13 @@ CLI wiring plus its own policy:
|
|
|
194
199
|
|
|
195
200
|
Omit the bag and every seam is inert — the verdicts are byte-identical to this
|
|
196
201
|
module alone. A callback that throws is **not** caught: it lands in the CLI's
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
202
|
+
failure catch, so a broken extension gets the caller's failure posture — the
|
|
203
|
+
warning-and-pass-through default, or suppression under
|
|
204
|
+
`AGENT_SANITIZER_FAIL_OPEN=0`. Wire `emitFailClosed` yourself if a host must
|
|
205
|
+
suppress regardless of the environment. `postText` deliberately does not run on
|
|
206
|
+
object field NAMES: a callback sees only the string and the tool, so it cannot
|
|
200
207
|
tell a schema key from content, and rewriting a key can collapse two fields into
|
|
201
|
-
one name — which this hook
|
|
208
|
+
one name — which this hook answers with that same failure path.
|
|
202
209
|
|
|
203
210
|
Beyond the credential-shaped names it infers, the env-bound redaction set unions
|
|
204
211
|
`_AGENT_SANITIZER_EXTRA_SECRET_VARS` — a comma-separated list of `[A-Z0-9_]`
|
|
@@ -208,8 +215,9 @@ choosing. A malformed entry throws rather than being dropped.
|
|
|
208
215
|
**Layer 4 needs the Python engine.** The plugin ships it and provisions it at
|
|
209
216
|
SessionStart; a hand-wired npm install does not, so install it yourself —
|
|
210
217
|
`pip install 'agent-sanitizer[secrets]'`, version-matched to the npm package.
|
|
211
|
-
Without it `sanitize-output` fails
|
|
212
|
-
|
|
218
|
+
Without it `sanitize-output` fails: secret-shaped output reaches the model
|
|
219
|
+
unredacted with a warning attached, or is suppressed under
|
|
220
|
+
`AGENT_SANITIZER_FAIL_OPEN=0`. Layers 1–3 still run.
|
|
213
221
|
|
|
214
222
|
**Layer 5 (second-model injection filtering) is not included.** These hooks
|
|
215
223
|
never supply the `/output` seam's `filterInjection` callback, so nothing here
|
|
@@ -244,8 +252,8 @@ hook module, and every consumer waits on that path instead. `lib/control-plane`
|
|
|
244
252
|
resolves the marker at module scope, so a call that lands after that import
|
|
245
253
|
warns on stderr — it cannot steer the wait that already started.
|
|
246
254
|
|
|
247
|
-
**A host's own remedy can replace the packaged one in every
|
|
248
|
-
|
|
255
|
+
**A host's own remedy can replace the packaged one in every failure reason**
|
|
256
|
+
(the fail-closed verdicts and the fail-open warning alike). Deep call sites (`lib/control-plane`'s missing-package throw) take no
|
|
249
257
|
remedy argument, so by default they can only say `pnpm install`. A host whose
|
|
250
258
|
install has one entry point calls `configureMissingPackageRemedy(text)` (from
|
|
251
259
|
`lib/hook-io`) — typically at its bundle entry — and every remedy-less
|
|
@@ -262,7 +270,7 @@ credentials (and their length floor) in a registry of its own feeds the packaged
|
|
|
262
270
|
helpers from it instead of forking the module. Unset fields keep the package
|
|
263
271
|
derivation; `null` restores it entirely. A malformed source — a non-object, a
|
|
264
272
|
key the seam does not read, a bad field — throws on first use, inside the
|
|
265
|
-
consuming hook's
|
|
273
|
+
consuming hook's failure catch, never at configure time.
|
|
266
274
|
|
|
267
275
|
Hook internals are tuned by `_AGENT_SANITIZER_*` variables (redactor daemon
|
|
268
276
|
path/socket/timeouts, sanitize budget, trace channel, Layer-2 reveal dir). The
|
package/THREAT-MODEL.md
CHANGED
|
@@ -215,3 +215,49 @@ are load-bearing and **fail closed**:
|
|
|
215
215
|
|
|
216
216
|
File access and the redactor are injected via `io`; the package performs no I/O
|
|
217
217
|
of its own and bundles no secret engine.
|
|
218
|
+
|
|
219
|
+
## Failure posture (`AGENT_SANITIZER_FAIL_OPEN`)
|
|
220
|
+
|
|
221
|
+
Installed as Claude Code hooks, these fail **open**: a hook that could not
|
|
222
|
+
complete lets the guarded action through with a warning in `additionalContext`
|
|
223
|
+
rather than blocking the session. `AGENT_SANITIZER_FAIL_OPEN=0` (or `false`)
|
|
224
|
+
restores the fail-closed verdicts — block, ask, suppress. The posture covers
|
|
225
|
+
every way a hook can fail: the launcher not starting (no `node`, missing or
|
|
226
|
+
corrupt bundle), the package never loading, a payload that never parsed, and a
|
|
227
|
+
layer that ran and threw.
|
|
228
|
+
|
|
229
|
+
**The open default is not enforceable against content.** Several of those
|
|
230
|
+
failures are composable by whoever authored the payload — in the output hook
|
|
231
|
+
alone, the key-collision guard (two field names that collapse to one after
|
|
232
|
+
Layer 1), a nesting depth that overflows the sanitize walk, and a redaction
|
|
233
|
+
budget exhausted by many secret-shaped leaves. Under the open posture a tool
|
|
234
|
+
response crafted to provoke one is shown to the model verbatim, secrets
|
|
235
|
+
included. So an attacker who controls tool output has a route past these layers
|
|
236
|
+
whenever the default is left in place, and the mitigation is the knob, not a
|
|
237
|
+
narrower failure classification: `=0` closes all of it.
|
|
238
|
+
|
|
239
|
+
That trade is deliberate, and it is scoped to the Claude Code plugin. The
|
|
240
|
+
library's own fail-closed entry points are unchanged and knob-blind —
|
|
241
|
+
`failClosedFields` in `claude-hooks/pretooluse-sanitize.mjs` and `emitFailClosed`
|
|
242
|
+
in `claude-hooks/sanitize-output.mjs` — so a host that wires those directly (as
|
|
243
|
+
`test/downstream-parity.test.mjs` shows) keeps strict failure semantics by
|
|
244
|
+
construction, with no env var to remember and none an agent could set for it.
|
|
245
|
+
|
|
246
|
+
Two things the posture does NOT reach, in either direction:
|
|
247
|
+
|
|
248
|
+
- **Detection verdicts.** It speaks only to the hook FAILING; a working
|
|
249
|
+
sanitizer that found an injection blocks under both settings.
|
|
250
|
+
- **An unknown `--hook=` mode**, which still exits 2. That is static wiring
|
|
251
|
+
corruption, and passing it through would mean no hook ever runs — silently,
|
|
252
|
+
for the life of the install.
|
|
253
|
+
|
|
254
|
+
The knob is operator configuration read from the process environment, so
|
|
255
|
+
anything that can set it for a session — `.claude/settings.json`'s `env` block,
|
|
256
|
+
a shell rc file, `direnv` — can also set it the other way, and a prompt-injected
|
|
257
|
+
agent with edit access to those files is such a thing. That is not a regression
|
|
258
|
+
under an open default (there is nothing for it to disarm), but it does mean `=0`
|
|
259
|
+
is only as durable as the files carrying it.
|
|
260
|
+
|
|
261
|
+
The knob adds no layer and changes no layer's semantics. Ambiguous input still
|
|
262
|
+
fails open at the detection level (precision over recall), as it always has —
|
|
263
|
+
that is a separate, and unrelated, sense of the phrase.
|
|
@@ -194,6 +194,86 @@ export const PermissionDecision = Object.freeze({
|
|
|
194
194
|
ASK: "ask",
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
+
/**
|
|
198
|
+
* The public knob over the hooks' INFRASTRUCTURE failure posture. Installed as
|
|
199
|
+
* Claude Code hooks these fail OPEN by default — a hook that could not run lets
|
|
200
|
+
* the guarded action through with a loud warning rather than blocking the
|
|
201
|
+
* session on its own breakage. Setting it to `"0"` restores the fail-CLOSED
|
|
202
|
+
* posture (block/ask/suppress).
|
|
203
|
+
*
|
|
204
|
+
* The knob covers the hooks' own failures ONLY. What a working sanitizer
|
|
205
|
+
* DECIDED is untouched by it, and so is the {@link failClosedFields}-style
|
|
206
|
+
* wiring a downstream host does directly — a host that wants strictness gets it
|
|
207
|
+
* by construction, not by remembering to set an env var.
|
|
208
|
+
*/
|
|
209
|
+
export const FAIL_OPEN_ENV = "AGENT_SANITIZER_FAIL_OPEN";
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Values that turn the default posture back to fail-closed. Matched exactly,
|
|
213
|
+
* so the launcher's shell `case` can state the same two literals — a
|
|
214
|
+
* case-insensitive match here would need `tr`, which the launcher cannot reach
|
|
215
|
+
* (it runs its no-node arm on shell builtins alone) and the two would drift.
|
|
216
|
+
*/
|
|
217
|
+
const FAIL_CLOSED_VALUES = new Set(["0", "false"]);
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Whether hook failures pass the guarded action through. True unless the caller
|
|
221
|
+
* explicitly asked for the closed posture.
|
|
222
|
+
*
|
|
223
|
+
* The accepted opt-out spellings are a SET rather than the single exact `"1"`
|
|
224
|
+
* the AGENT_SANITIZER_*_DISABLED knobs use, because the direction of the
|
|
225
|
+
* mistake is reversed: those default to the safe side, so an unrecognized value
|
|
226
|
+
* there costs nothing, while here it leaves an operator who asked for
|
|
227
|
+
* strictness without it. `"false"` is the one spelling reached for by reflex,
|
|
228
|
+
* so it is honored; everything else (`""`, `"no"`, `"off"`) is the open posture.
|
|
229
|
+
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
230
|
+
* @returns {boolean}
|
|
231
|
+
*/
|
|
232
|
+
export function failOpenEnabled(env = process.env) {
|
|
233
|
+
return !FAIL_CLOSED_VALUES.has(env[FAIL_OPEN_ENV] ?? "");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The model-facing warning accompanying a fail-open pass-through. Emitted as
|
|
238
|
+
* `additionalContext` so the transcript still carries the failure: the posture
|
|
239
|
+
* gives up ENFORCEMENT, not visibility, and stdout is never left empty (which
|
|
240
|
+
* Claude Code would record as a clean run rather than a degraded one).
|
|
241
|
+
*
|
|
242
|
+
* The reinstall remedy rides along when the failure looks like an unloaded
|
|
243
|
+
* binding. A `DEP_UNAVAILABLE` error already carries its remedy in the message
|
|
244
|
+
* `safeErrMessage` splices in below, but the bare TypeError V8 raises for an
|
|
245
|
+
* undefined binding names neither the package nor the fix — and under this
|
|
246
|
+
* posture there is no permissionDecisionReason carrying one either, so the only
|
|
247
|
+
* message telling a reader how to un-break the install would be the one that
|
|
248
|
+
* went missing. `failedPackages`/`packageMessage` are injectable for the same
|
|
249
|
+
* reason `depLoadHint`'s are: the recorded-failure set is process-wide and
|
|
250
|
+
* untestable otherwise.
|
|
251
|
+
* @param {string} hookName
|
|
252
|
+
* @param {string} guarded what passed through, e.g. "tool output"
|
|
253
|
+
* @param {unknown} err
|
|
254
|
+
* @param {() => string[]} [failedPackages]
|
|
255
|
+
* @param {(pkg: string) => string} [packageMessage]
|
|
256
|
+
* @returns {string}
|
|
257
|
+
*/
|
|
258
|
+
export function failOpenContext(
|
|
259
|
+
hookName,
|
|
260
|
+
guarded,
|
|
261
|
+
err,
|
|
262
|
+
failedPackages = failedLazyPackages,
|
|
263
|
+
packageMessage = missingPackageMessage,
|
|
264
|
+
) {
|
|
265
|
+
// Same inference depLoadHint makes, and confined the same way: only a
|
|
266
|
+
// TypeError, only while the loader holds a recorded failure. Naming a package
|
|
267
|
+
// on any other throw would send the reader to a reinstall that fixes nothing.
|
|
268
|
+
const [pkg] = err instanceof TypeError ? failedPackages() : [];
|
|
269
|
+
const hint = pkg === undefined ? "" : ` ${packageMessage(pkg)}`;
|
|
270
|
+
return (
|
|
271
|
+
`WARNING: the ${hookName} hook failed (${safeErrMessage(err)}) — this ` +
|
|
272
|
+
`${guarded} passed through UNSANITIZED. Treat its contents as ` +
|
|
273
|
+
`untrusted.${hint} Set ${FAIL_OPEN_ENV}=0 to fail closed on hook failures.`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
197
277
|
// Unpaired UTF-16 surrogates: a high half with no low follower, or a low half
|
|
198
278
|
// with no high lead. Hook text spliced into the model's context must be
|
|
199
279
|
// well-formed UTF-16 there, so the sanitizers normalize these out before
|
|
@@ -148,15 +148,17 @@ function isRespawnable(err) {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
/**
|
|
151
|
-
* The error thrown
|
|
152
|
-
*
|
|
151
|
+
* The error thrown when a single redaction cannot be completed. What the caller
|
|
152
|
+
* then does is its posture, not this module's — the message therefore states
|
|
153
|
+
* the FACT (the output was never vetted) and no consequence, since a hook
|
|
154
|
+
* running fail-open splices it into a warning that says the opposite.
|
|
153
155
|
* @param {unknown} cause
|
|
154
156
|
* @returns {Error}
|
|
155
157
|
*/
|
|
156
158
|
function failClosed(cause) {
|
|
157
159
|
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
158
160
|
return new Error(
|
|
159
|
-
`secret redaction unavailable (${detail});
|
|
161
|
+
`secret redaction unavailable (${detail}); secret-shaped output could not be vetted`,
|
|
160
162
|
);
|
|
161
163
|
}
|
|
162
164
|
|
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
registeredLazyModule,
|
|
35
35
|
emitHookResponse,
|
|
36
36
|
safeErrMessage,
|
|
37
|
+
failOpenEnabled,
|
|
38
|
+
failOpenContext,
|
|
37
39
|
HookEvent,
|
|
38
40
|
PermissionDecision,
|
|
39
41
|
} from "./lib/hook-io.mjs";
|
|
@@ -398,6 +400,12 @@ export function depLoadHint(
|
|
|
398
400
|
* fatigue, no latency. A LAYER/engine throw after a clean parse (`parsedOk` true
|
|
399
401
|
* — redactor daemon down, package not loaded) is the sanitizer being UNAVAILABLE,
|
|
400
402
|
* so it ASKS to keep a human in the loop rather than hard-block on infrastructure.
|
|
403
|
+
*
|
|
404
|
+
* Deliberately knob-blind: this is the fail-CLOSED posture itself, so it ignores
|
|
405
|
+
* AGENT_SANITIZER_FAIL_OPEN — a host that wires it directly keeps strictness by
|
|
406
|
+
* construction, with no env var to remember. A host that instead wants the
|
|
407
|
+
* caller's posture (fail-open by default) wires {@link hookFailureFields},
|
|
408
|
+
* which delegates here when the posture is closed.
|
|
401
409
|
* @param {boolean} parsedOk whether the input parsed before the failure
|
|
402
410
|
* @param {unknown} err
|
|
403
411
|
* @param {{ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>, hint?: string }} [opts]
|
|
@@ -419,12 +427,37 @@ export function failClosedFields(parsedOk, err, opts = {}) {
|
|
|
419
427
|
};
|
|
420
428
|
}
|
|
421
429
|
|
|
430
|
+
/**
|
|
431
|
+
* The hookSpecificOutput fields for a hook-level failure under the CALLER's
|
|
432
|
+
* chosen posture: fail-OPEN by default — a warning context and no
|
|
433
|
+
* permissionDecision, so the tool call proceeds unsanitized — or the
|
|
434
|
+
* fail-CLOSED verdict of {@link failClosedFields} when the caller set
|
|
435
|
+
* AGENT_SANITIZER_FAIL_OPEN=0.
|
|
436
|
+
*
|
|
437
|
+
* The posture covers this hook's own failures, whatever their cause. What it
|
|
438
|
+
* does NOT cover is the verdict of a sanitizer that ran: a payload
|
|
439
|
+
* judgePreToolUseSanitize denied is denied in both postures.
|
|
440
|
+
* @param {boolean} parsedOk whether the input parsed before the failure
|
|
441
|
+
* @param {unknown} err
|
|
442
|
+
* @param {{
|
|
443
|
+
* messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
|
|
444
|
+
* hint?: string,
|
|
445
|
+
* env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
|
|
446
|
+
* }} [opts]
|
|
447
|
+
* @returns {Record<string, unknown>}
|
|
448
|
+
*/
|
|
449
|
+
export function hookFailureFields(parsedOk, err, opts = {}) {
|
|
450
|
+
if (failOpenEnabled(opts.env))
|
|
451
|
+
return { additionalContext: failOpenContext(HOOK_NAME, "tool input", err) };
|
|
452
|
+
return failClosedFields(parsedOk, err, opts);
|
|
453
|
+
}
|
|
454
|
+
|
|
422
455
|
// Stryker disable all: CLI wiring — it runs only in the spawned hook
|
|
423
456
|
// subprocess, never in-process, so every mutant from here down is NoCoverage.
|
|
424
457
|
// The exported judgePreToolUseSanitize and failClosedFields above carry the
|
|
425
458
|
// real, mutation-tested logic.
|
|
426
459
|
/**
|
|
427
|
-
* The hook's CLI: parse → judge → render,
|
|
460
|
+
* The hook's CLI: parse → judge → render, under the caller's failure posture.
|
|
428
461
|
* Exported so a bundle entry (which must claim the CLI slot before this module
|
|
429
462
|
* loads) can run the exact same wiring instead of duplicating the onError
|
|
430
463
|
* posture.
|
|
@@ -447,15 +480,16 @@ export async function cliMain(opts = {}) {
|
|
|
447
480
|
trace: emitTrace,
|
|
448
481
|
}),
|
|
449
482
|
{
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
// natively, so
|
|
483
|
+
// The caller's posture, WITHOUT the package: pass through with a warning
|
|
484
|
+
// by default, or — under AGENT_SANITIZER_FAIL_OPEN=0 — hard-deny an
|
|
485
|
+
// unparsable INPUT (`input` undefined; adversary-inducible, no benefit to
|
|
486
|
+
// failing) and ASK on any throw after a clean parse, keeping a human in
|
|
487
|
+
// the loop. emitHookResponse renders natively, so either posture holds
|
|
488
|
+
// even when the adapter never loaded.
|
|
455
489
|
onError: (err, input) =>
|
|
456
490
|
emitHookResponse(
|
|
457
491
|
HookEvent.PRE_TOOL_USE,
|
|
458
|
-
|
|
492
|
+
hookFailureFields(input !== undefined, err, {
|
|
459
493
|
messages,
|
|
460
494
|
hint: depLoadHint(err, messages.remedy),
|
|
461
495
|
}),
|
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
emitHookResponse,
|
|
28
28
|
errMessage,
|
|
29
29
|
safeErrMessage,
|
|
30
|
+
failOpenEnabled,
|
|
31
|
+
failOpenContext,
|
|
30
32
|
makeDeadline,
|
|
31
33
|
lazyImportErrorFor,
|
|
32
34
|
missingPackageMessage,
|
|
@@ -247,11 +249,12 @@ export async function sanitizeText(
|
|
|
247
249
|
exfilScan: webIngress,
|
|
248
250
|
sgrCarveOut: !webIngress,
|
|
249
251
|
deadline,
|
|
250
|
-
// Layer 4 — the seam
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
252
|
+
// Layer 4 — the seam rethrows a redactor throw wrapped, and the CLI applies
|
|
253
|
+
// the caller's posture to it. Surface the failure to the operator's
|
|
254
|
+
// terminal here first: whatever the CLI decides rides in additionalContext,
|
|
255
|
+
// which only the model sees, so a degraded redactor would otherwise be
|
|
256
|
+
// invisible to the human — and under the fail-open default this line is the
|
|
257
|
+
// ONLY signal the human gets.
|
|
255
258
|
redact: async (/** @type {string} */ content) => {
|
|
256
259
|
let secrets;
|
|
257
260
|
try {
|
|
@@ -259,7 +262,7 @@ export async function sanitizeText(
|
|
|
259
262
|
} catch (l4err) {
|
|
260
263
|
process.stderr.write(
|
|
261
264
|
`sanitize-output: CRITICAL: secret redaction failed (${errMessage(l4err)}). ` +
|
|
262
|
-
"
|
|
265
|
+
"This output was never vetted for secrets. Fix the redactor installation.\n",
|
|
263
266
|
);
|
|
264
267
|
throw l4err;
|
|
265
268
|
}
|
|
@@ -564,6 +567,48 @@ export function emitFailClosed(
|
|
|
564
567
|
}
|
|
565
568
|
}
|
|
566
569
|
|
|
570
|
+
/**
|
|
571
|
+
* Emit the PostToolUse failure response under the CALLER's chosen posture:
|
|
572
|
+
* fail-OPEN by default — a warning context and NO `updatedToolOutput`, leaving
|
|
573
|
+
* the original tool output in the model's view — or the fail-closed
|
|
574
|
+
* suppression of {@link emitFailClosed} when the caller set
|
|
575
|
+
* AGENT_SANITIZER_FAIL_OPEN=0.
|
|
576
|
+
*
|
|
577
|
+
* This is the hook where the two postures diverge the most, so state the open
|
|
578
|
+
* one plainly: several of these layers throw on inputs an attacker composes
|
|
579
|
+
* (colliding field names, a nesting depth that overflows the walk, a redaction
|
|
580
|
+
* budget spent on a thousand secret-shaped leaves), and each of those throws is
|
|
581
|
+
* guarding content the open posture hands to the model verbatim, secrets
|
|
582
|
+
* included. An operator who cares more about withholding a secret than about
|
|
583
|
+
* keeping the session moving sets the knob to `0`.
|
|
584
|
+
* @param {any} input parsed hook input, or undefined if parsing threw
|
|
585
|
+
* @param {unknown} err
|
|
586
|
+
* @param {(fields: Record<string, unknown>) => void} [emit]
|
|
587
|
+
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
588
|
+
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
589
|
+
* @returns {void}
|
|
590
|
+
*/
|
|
591
|
+
export function emitHookFailure(
|
|
592
|
+
input,
|
|
593
|
+
err,
|
|
594
|
+
emit = (fields) => emitHookResponse(HookEvent.POST_TOOL_USE, fields),
|
|
595
|
+
remedy = DEFAULT_MISSING_PACKAGE_REMEDY,
|
|
596
|
+
env = process.env,
|
|
597
|
+
) {
|
|
598
|
+
if (failOpenEnabled(env)) {
|
|
599
|
+
emit({
|
|
600
|
+
additionalContext: failOpenContext("sanitize-output", "tool output", err),
|
|
601
|
+
});
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
emitFailClosed(
|
|
605
|
+
input,
|
|
606
|
+
`[SANITIZATION FAILED — original output suppressed for safety. Hook error: ${safeErrMessage(err)}]`,
|
|
607
|
+
emit,
|
|
608
|
+
remedy,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
567
612
|
/**
|
|
568
613
|
* Run the sanitization pipeline over a tool output and return the contract-
|
|
569
614
|
* shaped verdict fields — `mutated_output` (the shape-matching sanitized value)
|
|
@@ -771,7 +816,7 @@ export function withPostToolUseDefault(input) {
|
|
|
771
816
|
// failClosedReplacement) is exercised in-process by the unit suite; the
|
|
772
817
|
// end-to-end wire contract is pinned by the subprocess tests.
|
|
773
818
|
/**
|
|
774
|
-
* The hook's CLI: parse → judge → render,
|
|
819
|
+
* The hook's CLI: parse → judge → render, under the caller's failure posture.
|
|
775
820
|
* Exported so a bundle entry (which must claim the CLI slot before this module
|
|
776
821
|
* loads) can run the exact same wiring instead of duplicating the onError
|
|
777
822
|
* posture. That entry is also the only place a host's {@link SanitizeExtensions}
|
|
@@ -792,16 +837,11 @@ export async function cliMain(ext = {}) {
|
|
|
792
837
|
// back the parsed `input` even when the control-plane load failed, so the
|
|
793
838
|
// suppression shape-matches the real tool_response). emitFailClosed itself
|
|
794
839
|
// falls back to a bare string if that shape-matching replacement or its
|
|
795
|
-
// serialization throws, so even a pathological input fails closed.
|
|
840
|
+
// serialization throws, so even a pathological input fails closed. A caller
|
|
841
|
+
// that set AGENT_SANITIZER_FAIL_OPEN=1 gets the warning-only pass-through
|
|
842
|
+
// instead — see emitHookFailure.
|
|
796
843
|
onError: (err, input) =>
|
|
797
|
-
|
|
798
|
-
input,
|
|
799
|
-
"[SANITIZATION FAILED — original output suppressed for safety. Hook error: " +
|
|
800
|
-
safeErrMessage(err) +
|
|
801
|
-
"]",
|
|
802
|
-
undefined,
|
|
803
|
-
ext.remedy,
|
|
804
|
-
),
|
|
844
|
+
emitHookFailure(input, err, undefined, ext.remedy),
|
|
805
845
|
},
|
|
806
846
|
);
|
|
807
847
|
}
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
import {
|
|
22
22
|
readStdinJson,
|
|
23
23
|
safeErrMessage,
|
|
24
|
+
failOpenEnabled,
|
|
25
|
+
failOpenContext,
|
|
26
|
+
HookEvent,
|
|
24
27
|
isMain,
|
|
25
28
|
lazyImport,
|
|
26
29
|
missingPackageError,
|
|
@@ -168,12 +171,14 @@ export function judgeSanitizeUserPrompt(
|
|
|
168
171
|
* strip?: ((s: string) => string) | null,
|
|
169
172
|
* overrides?: Partial<typeof USER_PROMPT_MESSAGES>,
|
|
170
173
|
* trace?: import("./lib/trace.mjs").TraceFn,
|
|
174
|
+
* env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
|
|
171
175
|
* }} [opts]
|
|
172
176
|
* `strip` is the ANSI stripper (defaults to the package's stripAnsiFully;
|
|
173
177
|
* injectable so the fail-closed path is testable); `overrides` are reason
|
|
174
178
|
* overrides, merged over the defaults so a partial table can never leave a field
|
|
175
179
|
* unset; `trace` is where engagement is announced, for a host with its own trace
|
|
176
|
-
* channel (see lib/trace.mjs)
|
|
180
|
+
* channel (see lib/trace.mjs); `env` is the failure-posture source (see
|
|
181
|
+
* failOpenEnabled), injectable so both postures are testable in-process.
|
|
177
182
|
* @returns {Promise<void>}
|
|
178
183
|
*/
|
|
179
184
|
export async function main(read, write, opts = {}) {
|
|
@@ -181,6 +186,7 @@ export async function main(read, write, opts = {}) {
|
|
|
181
186
|
strip = stripAnsiFully,
|
|
182
187
|
overrides = USER_PROMPT_MESSAGES,
|
|
183
188
|
trace: sink = trace,
|
|
189
|
+
env = process.env,
|
|
184
190
|
} = opts;
|
|
185
191
|
const emitTrace = bestEffortTrace(sink);
|
|
186
192
|
// Merged, not substituted — see judgeSanitizeUserPrompt. onError below is the
|
|
@@ -190,9 +196,11 @@ export async function main(read, write, opts = {}) {
|
|
|
190
196
|
// runJudgeCli so this hook doesn't re-implement the control-plane boundary:
|
|
191
197
|
// runJudgeCli reads stdin BEFORE loading the control-plane package, so a
|
|
192
198
|
// load failure fails to this hook's posture (the onError block) instead of
|
|
193
|
-
// leaving stdin unread.
|
|
194
|
-
//
|
|
195
|
-
//
|
|
199
|
+
// leaving stdin unread. onError writes its envelope by hand — the default
|
|
200
|
+
// warning-only pass-through, or the block a caller asked for with
|
|
201
|
+
// AGENT_SANITIZER_FAIL_OPEN=0 — because the adapter that would render it is
|
|
202
|
+
// exactly what may have failed to load. Either way this is the HOOK failing;
|
|
203
|
+
// a prompt the working stripper flagged is still blocked in both postures.
|
|
196
204
|
await runJudgeCli(
|
|
197
205
|
"sanitize-user-prompt",
|
|
198
206
|
(event) => {
|
|
@@ -215,10 +223,23 @@ export async function main(read, write, opts = {}) {
|
|
|
215
223
|
write,
|
|
216
224
|
onError: (err) =>
|
|
217
225
|
write(
|
|
218
|
-
JSON.stringify(
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
226
|
+
JSON.stringify(
|
|
227
|
+
failOpenEnabled(env)
|
|
228
|
+
? {
|
|
229
|
+
hookSpecificOutput: {
|
|
230
|
+
hookEventName: HookEvent.USER_PROMPT_SUBMIT,
|
|
231
|
+
additionalContext: failOpenContext(
|
|
232
|
+
"sanitize-user-prompt",
|
|
233
|
+
"prompt",
|
|
234
|
+
err,
|
|
235
|
+
),
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
: {
|
|
239
|
+
decision: "block",
|
|
240
|
+
reason: messages.hookFailed(safeErrMessage(err)),
|
|
241
|
+
},
|
|
242
|
+
),
|
|
222
243
|
),
|
|
223
244
|
},
|
|
224
245
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.0",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -71,6 +71,43 @@ export function claimCliEntry(): void;
|
|
|
71
71
|
* @returns {string|undefined}
|
|
72
72
|
*/
|
|
73
73
|
export function readFlag(argv: string[], name: string): string | undefined;
|
|
74
|
+
/**
|
|
75
|
+
* Whether hook failures pass the guarded action through. True unless the caller
|
|
76
|
+
* explicitly asked for the closed posture.
|
|
77
|
+
*
|
|
78
|
+
* The accepted opt-out spellings are a SET rather than the single exact `"1"`
|
|
79
|
+
* the AGENT_SANITIZER_*_DISABLED knobs use, because the direction of the
|
|
80
|
+
* mistake is reversed: those default to the safe side, so an unrecognized value
|
|
81
|
+
* there costs nothing, while here it leaves an operator who asked for
|
|
82
|
+
* strictness without it. `"false"` is the one spelling reached for by reflex,
|
|
83
|
+
* so it is honored; everything else (`""`, `"no"`, `"off"`) is the open posture.
|
|
84
|
+
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
85
|
+
* @returns {boolean}
|
|
86
|
+
*/
|
|
87
|
+
export function failOpenEnabled(env?: NodeJS.ProcessEnv | Record<string, string | undefined>): boolean;
|
|
88
|
+
/**
|
|
89
|
+
* The model-facing warning accompanying a fail-open pass-through. Emitted as
|
|
90
|
+
* `additionalContext` so the transcript still carries the failure: the posture
|
|
91
|
+
* gives up ENFORCEMENT, not visibility, and stdout is never left empty (which
|
|
92
|
+
* Claude Code would record as a clean run rather than a degraded one).
|
|
93
|
+
*
|
|
94
|
+
* The reinstall remedy rides along when the failure looks like an unloaded
|
|
95
|
+
* binding. A `DEP_UNAVAILABLE` error already carries its remedy in the message
|
|
96
|
+
* `safeErrMessage` splices in below, but the bare TypeError V8 raises for an
|
|
97
|
+
* undefined binding names neither the package nor the fix — and under this
|
|
98
|
+
* posture there is no permissionDecisionReason carrying one either, so the only
|
|
99
|
+
* message telling a reader how to un-break the install would be the one that
|
|
100
|
+
* went missing. `failedPackages`/`packageMessage` are injectable for the same
|
|
101
|
+
* reason `depLoadHint`'s are: the recorded-failure set is process-wide and
|
|
102
|
+
* untestable otherwise.
|
|
103
|
+
* @param {string} hookName
|
|
104
|
+
* @param {string} guarded what passed through, e.g. "tool output"
|
|
105
|
+
* @param {unknown} err
|
|
106
|
+
* @param {() => string[]} [failedPackages]
|
|
107
|
+
* @param {(pkg: string) => string} [packageMessage]
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
export function failOpenContext(hookName: string, guarded: string, err: unknown, failedPackages?: () => string[], packageMessage?: (pkg: string) => string): string;
|
|
74
111
|
/**
|
|
75
112
|
* @param {number} [maxBytes] cap before aborting (overridable for tests)
|
|
76
113
|
* @returns {Promise<any>}
|
|
@@ -380,6 +417,19 @@ export const PermissionDecision: Readonly<{
|
|
|
380
417
|
DENY: "deny";
|
|
381
418
|
ASK: "ask";
|
|
382
419
|
}>;
|
|
420
|
+
/**
|
|
421
|
+
* The public knob over the hooks' INFRASTRUCTURE failure posture. Installed as
|
|
422
|
+
* Claude Code hooks these fail OPEN by default — a hook that could not run lets
|
|
423
|
+
* the guarded action through with a loud warning rather than blocking the
|
|
424
|
+
* session on its own breakage. Setting it to `"0"` restores the fail-CLOSED
|
|
425
|
+
* posture (block/ask/suppress).
|
|
426
|
+
*
|
|
427
|
+
* The knob covers the hooks' own failures ONLY. What a working sanitizer
|
|
428
|
+
* DECIDED is untouched by it, and so is the {@link failClosedFields}-style
|
|
429
|
+
* wiring a downstream host does directly — a host that wants strictness gets it
|
|
430
|
+
* by construction, not by remembering to set an env var.
|
|
431
|
+
*/
|
|
432
|
+
export const FAIL_OPEN_ENV: "AGENT_SANITIZER_FAIL_OPEN";
|
|
383
433
|
/**
|
|
384
434
|
* Hard cap on hook stdin. A well-formed Claude Code hook payload is at most a
|
|
385
435
|
* few MB (tool input plus the harness-truncated tool output); 64 MiB leaves
|
|
@@ -57,6 +57,12 @@ export function depLoadHint(err: unknown, remedy?: string, failedPackages?: () =
|
|
|
57
57
|
* fatigue, no latency. A LAYER/engine throw after a clean parse (`parsedOk` true
|
|
58
58
|
* — redactor daemon down, package not loaded) is the sanitizer being UNAVAILABLE,
|
|
59
59
|
* so it ASKS to keep a human in the loop rather than hard-block on infrastructure.
|
|
60
|
+
*
|
|
61
|
+
* Deliberately knob-blind: this is the fail-CLOSED posture itself, so it ignores
|
|
62
|
+
* AGENT_SANITIZER_FAIL_OPEN — a host that wires it directly keeps strictness by
|
|
63
|
+
* construction, with no env var to remember. A host that instead wants the
|
|
64
|
+
* caller's posture (fail-open by default) wires {@link hookFailureFields},
|
|
65
|
+
* which delegates here when the posture is closed.
|
|
60
66
|
* @param {boolean} parsedOk whether the input parsed before the failure
|
|
61
67
|
* @param {unknown} err
|
|
62
68
|
* @param {{ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>, hint?: string }} [opts]
|
|
@@ -67,7 +73,31 @@ export function failClosedFields(parsedOk: boolean, err: unknown, opts?: {
|
|
|
67
73
|
hint?: string;
|
|
68
74
|
}): Record<string, unknown>;
|
|
69
75
|
/**
|
|
70
|
-
* The
|
|
76
|
+
* The hookSpecificOutput fields for a hook-level failure under the CALLER's
|
|
77
|
+
* chosen posture: fail-OPEN by default — a warning context and no
|
|
78
|
+
* permissionDecision, so the tool call proceeds unsanitized — or the
|
|
79
|
+
* fail-CLOSED verdict of {@link failClosedFields} when the caller set
|
|
80
|
+
* AGENT_SANITIZER_FAIL_OPEN=0.
|
|
81
|
+
*
|
|
82
|
+
* The posture covers this hook's own failures, whatever their cause. What it
|
|
83
|
+
* does NOT cover is the verdict of a sanitizer that ran: a payload
|
|
84
|
+
* judgePreToolUseSanitize denied is denied in both postures.
|
|
85
|
+
* @param {boolean} parsedOk whether the input parsed before the failure
|
|
86
|
+
* @param {unknown} err
|
|
87
|
+
* @param {{
|
|
88
|
+
* messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
|
|
89
|
+
* hint?: string,
|
|
90
|
+
* env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
|
|
91
|
+
* }} [opts]
|
|
92
|
+
* @returns {Record<string, unknown>}
|
|
93
|
+
*/
|
|
94
|
+
export function hookFailureFields(parsedOk: boolean, err: unknown, opts?: {
|
|
95
|
+
messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
|
|
96
|
+
hint?: string;
|
|
97
|
+
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
|
|
98
|
+
}): Record<string, unknown>;
|
|
99
|
+
/**
|
|
100
|
+
* The hook's CLI: parse → judge → render, under the caller's failure posture.
|
|
71
101
|
* Exported so a bundle entry (which must claim the CLI slot before this module
|
|
72
102
|
* loads) can run the exact same wiring instead of duplicating the onError
|
|
73
103
|
* posture.
|
|
@@ -159,6 +159,28 @@ export function failClosedContext(depsLoaded?: () => boolean, remedy?: string):
|
|
|
159
159
|
* @returns {void}
|
|
160
160
|
*/
|
|
161
161
|
export function emitFailClosed(input: any, message: string, emit?: (fields: Record<string, unknown>) => void, remedy?: string): void;
|
|
162
|
+
/**
|
|
163
|
+
* Emit the PostToolUse failure response under the CALLER's chosen posture:
|
|
164
|
+
* fail-OPEN by default — a warning context and NO `updatedToolOutput`, leaving
|
|
165
|
+
* the original tool output in the model's view — or the fail-closed
|
|
166
|
+
* suppression of {@link emitFailClosed} when the caller set
|
|
167
|
+
* AGENT_SANITIZER_FAIL_OPEN=0.
|
|
168
|
+
*
|
|
169
|
+
* This is the hook where the two postures diverge the most, so state the open
|
|
170
|
+
* one plainly: several of these layers throw on inputs an attacker composes
|
|
171
|
+
* (colliding field names, a nesting depth that overflows the walk, a redaction
|
|
172
|
+
* budget spent on a thousand secret-shaped leaves), and each of those throws is
|
|
173
|
+
* guarding content the open posture hands to the model verbatim, secrets
|
|
174
|
+
* included. An operator who cares more about withholding a secret than about
|
|
175
|
+
* keeping the session moving sets the knob to `0`.
|
|
176
|
+
* @param {any} input parsed hook input, or undefined if parsing threw
|
|
177
|
+
* @param {unknown} err
|
|
178
|
+
* @param {(fields: Record<string, unknown>) => void} [emit]
|
|
179
|
+
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
180
|
+
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
181
|
+
* @returns {void}
|
|
182
|
+
*/
|
|
183
|
+
export function emitHookFailure(input: any, err: unknown, emit?: (fields: Record<string, unknown>) => void, remedy?: string, env?: NodeJS.ProcessEnv | Record<string, string | undefined>): void;
|
|
162
184
|
/**
|
|
163
185
|
* Run the sanitization pipeline over a tool output and return the contract-
|
|
164
186
|
* shaped verdict fields — `mutated_output` (the shape-matching sanitized value)
|
|
@@ -208,7 +230,7 @@ export function judgeSanitizeOutput(event: import("agent-control-plane-core").To
|
|
|
208
230
|
*/
|
|
209
231
|
export function withPostToolUseDefault(input: unknown): unknown;
|
|
210
232
|
/**
|
|
211
|
-
* The hook's CLI: parse → judge → render,
|
|
233
|
+
* The hook's CLI: parse → judge → render, under the caller's failure posture.
|
|
212
234
|
* Exported so a bundle entry (which must claim the CLI slot before this module
|
|
213
235
|
* loads) can run the exact same wiring instead of duplicating the onError
|
|
214
236
|
* posture. That entry is also the only place a host's {@link SanitizeExtensions}
|
|
@@ -23,18 +23,21 @@ export function judgeSanitizeUserPrompt(event: import("agent-control-plane-core"
|
|
|
23
23
|
* strip?: ((s: string) => string) | null,
|
|
24
24
|
* overrides?: Partial<typeof USER_PROMPT_MESSAGES>,
|
|
25
25
|
* trace?: import("./lib/trace.mjs").TraceFn,
|
|
26
|
+
* env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
|
|
26
27
|
* }} [opts]
|
|
27
28
|
* `strip` is the ANSI stripper (defaults to the package's stripAnsiFully;
|
|
28
29
|
* injectable so the fail-closed path is testable); `overrides` are reason
|
|
29
30
|
* overrides, merged over the defaults so a partial table can never leave a field
|
|
30
31
|
* unset; `trace` is where engagement is announced, for a host with its own trace
|
|
31
|
-
* channel (see lib/trace.mjs)
|
|
32
|
+
* channel (see lib/trace.mjs); `env` is the failure-posture source (see
|
|
33
|
+
* failOpenEnabled), injectable so both postures are testable in-process.
|
|
32
34
|
* @returns {Promise<void>}
|
|
33
35
|
*/
|
|
34
36
|
export function main(read: () => Promise<any> | any, write: (chunk: string) => void, opts?: {
|
|
35
37
|
strip?: ((s: string) => string) | null;
|
|
36
38
|
overrides?: Partial<typeof USER_PROMPT_MESSAGES>;
|
|
37
39
|
trace?: import("./lib/trace.mjs").TraceFn;
|
|
40
|
+
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
|
|
38
41
|
}): Promise<void>;
|
|
39
42
|
/** @type {typeof import("agent-sanitizer/prompt").classifyPrompt} */
|
|
40
43
|
export let classifyPrompt: typeof import("agent-sanitizer/prompt").classifyPrompt;
|