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 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: every layer fails closed, so you see suppressed tool
118
- output (`[output sanitizer unavailable original output suppressed]`), blocked
119
- prompts, or permission asks whose reason names the cause. The exception is a
120
- plugin that never loaded at all Claude Code reads a crashed hook as "no
121
- objection", so confirm with `/plugin` rather than reading a quiet session as a
122
- working one.
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 fail-closed
186
- CLI wiring plus its own policy:
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
- fail-closed catch and the tool output is suppressed, so a broken extension can
198
- never degrade into showing unvetted output. `postText` deliberately does not run
199
- on object field NAMES: a callback sees only the string and the tool, so it cannot
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 turns into whole-output suppression.
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 closed: secret-shaped output is suppressed,
212
- not shown unvetted. Layers 1–3 still run.
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 fail-closed
248
- reason.** Deep call sites (`lib/control-plane`'s missing-package throw) take no
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 fail-closed catch, never at configure time.
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 to fail a single redaction closed; the caller suppresses the
152
- * output.
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}); cannot vet secret-shaped output failing closed`,
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, with this hook's fail-closed posture.
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
- // Fail closed WITHOUT the package: unparsable INPUT (`input` undefined)
451
- // hard-denies (adversary-inducible, no benefit to failing); any throw
452
- // after a clean parse a layer engine down or the control-plane package
453
- // unavailable asks to keep a human in the loop. emitHookResponse renders
454
- // natively, so this posture holds even when the adapter never loaded.
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
- failClosedFields(input !== undefined, err, {
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 fails closed on a redactor throw (rethrows wrapped,
251
- // which the CLI turns into output suppression). Surface the failure to the
252
- // operator's terminal here first: the suppression rides in
253
- // additionalContext, which only the model sees, so a degraded redactor
254
- // would otherwise be invisible to the human.
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
- "Failing closed tool output suppressed. Fix the redactor installation.\n",
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, with this hook's fail-closed posture.
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
- emitFailClosed(
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. The fail-closed onError writes the UserPromptSubmit
194
- // `decision:"block"` envelope by hand because the adapter that would render it
195
- // is exactly what may have failed to load.
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
- decision: "block",
220
- reason: messages.hookFailed(safeErrMessage(err)),
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.14.14",
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 hook's CLI: parse judge render, with this hook's fail-closed posture.
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, with this hook's fail-closed posture.
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;