agent-sanitizer 2.7.1 → 2.8.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
@@ -127,13 +127,42 @@ import {
127
127
  } from "agent-sanitizer/claude-hooks/lib/hook-io";
128
128
  ```
129
129
 
130
- `agent-sanitizer/claude-hooks/<module>` for the four hooks
130
+ The exported set is deliberately small — the four hooks
131
131
  (`sanitize-output`, `pretooluse-sanitize`, `sanitize-user-prompt`,
132
- `scan-invisible-chars`) and `agent-sanitizer/claude-hooks/lib/<module>` for the
133
- shared libs. Importing one runs no CLI and reads no stdin. Same stability
134
- posture as the `_AGENT_SANITIZER_*` variables below: reachable and typed, but
135
- the supported surface is the `--hook=` CLI, so these move between minor
136
- versions.
132
+ `scan-invisible-chars`) plus `lib/hook-io` and `lib/control-plane`. Everything
133
+ else under `claude-hooks/` stays internal and is refused by the exports map, so
134
+ it never becomes a surface this package owes compatibility on. `lib/hook-io` is
135
+ exported because it must be _shared_ rather than copied: it owns the
136
+ lazy-module registry and the CLI-slot singleton, and two copies in one bundle
137
+ double-fire the inlined CLIs.
138
+
139
+ Importing one runs no CLI and reads no stdin. Same stability posture as the
140
+ `_AGENT_SANITIZER_*` variables below: reachable and typed, but the supported
141
+ surface is the `--hook=` CLI, so these move between minor versions.
142
+
143
+ **`sanitize-output` takes a host-extension bag** — an optional last argument on
144
+ `sanitizeText`, `sanitizeValue`, `evaluateToolOutput`, `judgeSanitizeOutput`, and
145
+ `cliMain`, so a composer that wraps `cliMain` gets the hook's exact fail-closed
146
+ CLI wiring plus its own policy:
147
+
148
+ | Field | Runs | Does |
149
+ | ------------ | -------------------------------------------------------- | ------------------------------------------------------------------------ |
150
+ | `postText` | once per string **value** leaf, after Layers 1–4 | returns `{cleaned?, warning?}`; `cleaned` replaces the model-facing text |
151
+ | `redactNote` | on the pre-redaction text of a leaf that tripped Layer 4 | returns a note appended to that leaf's redaction warning |
152
+ | `audit` | once per judged event carrying a tool response | is handed the output the model will actually see |
153
+
154
+ Omit the bag and every seam is inert — the verdicts are byte-identical to this
155
+ module alone. A callback that throws is **not** caught: it lands in the CLI's
156
+ fail-closed catch and the tool output is suppressed, so a broken extension can
157
+ never degrade into showing unvetted output. `postText` deliberately does not run
158
+ on object field NAMES: a callback sees only the string and the tool, so it cannot
159
+ tell a schema key from content, and rewriting a key can collapse two fields into
160
+ one name — which this hook turns into whole-output suppression.
161
+
162
+ Beyond the credential-shaped names it infers, the env-bound redaction set unions
163
+ `_AGENT_SANITIZER_EXTRA_SECRET_VARS` — a comma-separated list of `[A-Z0-9_]`
164
+ variable names whose values a deployment forwards under names of its own
165
+ choosing. A malformed entry throws rather than being dropped.
137
166
 
138
167
  **Layer 4 needs the Python engine** — `pip install 'agent-sanitizer[secrets]'`,
139
168
  version-matched to the npm package. Without it `sanitize-output` fails closed:
@@ -3,16 +3,21 @@
3
3
  * share, so the set of variable names whose VALUES get masked has one definition
4
4
  * instead of a copy per hook that can silently drift.
5
5
  *
6
- * The three JSON configs are imported as modules, not read from disk at call
7
- * time: esbuild inlines them into the plugin bundle (which ships with no
8
- * config directory beside it) and Node resolves them natively from the package
9
- * when the hooks run from source. Their VALIDATION stays lazy a malformed
10
- * credential vocabulary throws on first use, inside the consuming hook's
11
- * fail-closed catch, rather than at module load where a throw would abort before
12
- * that catch installs and let the harness pass the tool output through
13
- * UNSANITIZED (fail OPEN).
6
+ * The credential-NAME vocabulary comes from the published
7
+ * `credential-names.json` the same file `agent_sanitizer.secrets` renders in
8
+ * Python so the JavaScript pre-gate and the Python redactor recognize the same
9
+ * set of credential-bearing variable names. A rendered second copy is what let
10
+ * this matcher fall twelve segments behind the engine.
11
+ *
12
+ * The JSON configs are imported as modules, not read from disk at call time:
13
+ * esbuild inlines them into the plugin bundle (which ships with no config
14
+ * directory beside it) and Node resolves them natively from the package when the
15
+ * hooks run from source. Their VALIDATION stays lazy — a malformed credential
16
+ * vocabulary throws on first use, inside the consuming hook's fail-closed catch,
17
+ * rather than at module load where a throw would abort before that catch installs
18
+ * and let the harness pass the tool output through UNSANITIZED (fail OPEN).
14
19
  */
15
- import credentialVarNames from "../config/credential-var-names.json" with { type: "json" };
20
+ import credentialNames from "../../python/agent_sanitizer/secrets/data/credential-names.json" with { type: "json" };
16
21
  import inferenceKeys from "../config/inference-key-vars.json" with { type: "json" };
17
22
  import scrubbed from "../config/scrubbed-env-vars.json" with { type: "json" };
18
23
 
@@ -34,11 +39,91 @@ export function minEnvSecretLen() {
34
39
  return inferenceKeys.min_secret_len;
35
40
  }
36
41
 
37
- // A token from credential-var-names.json. Restricting it to A-Z/_ is what lets
38
- // the regexes below interpolate it unescaped: a stray metacharacter (or an empty
42
+ // A rendered vocabulary token. Restricting it to A-Z/0-9/_ is what lets the
43
+ // regexes below interpolate it unescaped: a stray metacharacter (or an empty
39
44
  // list, which would make the match regex accept nothing and leak every forwarded
40
- // credential) fails closed here instead of silently under-matching.
41
- const CRED_TOKEN_RE = /^[A-Z_]+$/;
45
+ // credential) fails closed here instead of silently under-matching. Digits are
46
+ // admitted because a noun part may legitimately carry one (the vocabulary's parts
47
+ // are `[a-z0-9]+`); a digit cannot be a metacharacter, so it is safe to embed.
48
+ const CRED_TOKEN_RE = /^[A-Z0-9_]+$/;
49
+
50
+ const VOCAB_LABEL = "credential-names.json";
51
+
52
+ // Names ending in a credential noun whose value is not a secret. SSH_AUTH_SOCK
53
+ // holds a filesystem path, and redacting it would strip the agent socket out of
54
+ // tool output. Site-independent (anyone running an ssh-agent has it), so it lives
55
+ // with the consumer rather than in the published vocabulary, which describes
56
+ // which WORDS name a secret and cannot know about a specific variable.
57
+ const EXCLUDE_NAMES = ["SSH_AUTH_SOCK"];
58
+
59
+ // Which noun renderings apply to a variable NAME (the other use, `field-value`,
60
+ // feeds the redactor's `field = value` matcher and is not a name matcher).
61
+ const ENV_NAME_USE = "env-name";
62
+
63
+ /**
64
+ * The whole-name forms of `parts`: underscore-joined and bare-joined, upper-cased.
65
+ * Both are emitted because both spellings occur in the wild (`API_KEY`, `APIKEY`)
66
+ * and a matcher anchored on underscore-delimited segments sees them as different
67
+ * tokens; a single-part noun collapses to one form. Mirrors the Python renderer
68
+ * (`_segment_forms` in agent_sanitizer/secrets/credential_names.py) so the two
69
+ * ecosystems derive the same vocabulary from the same file.
70
+ * @param {string[]} parts
71
+ * @returns {string[]}
72
+ */
73
+ function segmentForms(parts) {
74
+ return [
75
+ ...new Set([parts.join("_").toUpperCase(), parts.join("").toUpperCase()]),
76
+ ];
77
+ }
78
+
79
+ /**
80
+ * Render the published credential-noun vocabulary into the name-matcher spec:
81
+ * the credential segments, and the trailing suffixes that mark a
82
+ * credential-shaped name as holding a non-secret.
83
+ *
84
+ * The vocabulary is the SINGLE source both ecosystems read — a curated second
85
+ * copy of these renderings is what let this matcher fall twelve segments behind
86
+ * the engine, so a variable named `…_ACCESS_TOKEN` was never recognized as
87
+ * credential-bearing and its value was never handed to the redactor.
88
+ * @param {Record<string, any>} spec
89
+ * @returns {{ segments: string[], excludeSuffixes: string[], excludeNames: string[] }}
90
+ */
91
+ export function deriveCredentialVocabulary(spec) {
92
+ const nouns = spec?.nouns;
93
+ const nonSecret = spec?.nonSecretSuffixes;
94
+ if (!Array.isArray(nouns) || !Array.isArray(nonSecret))
95
+ throw new Error(`${VOCAB_LABEL}: nouns/nonSecretSuffixes missing`);
96
+ const segments = [];
97
+ for (const noun of nouns)
98
+ if (Array.isArray(noun?.uses) && noun.uses.includes(ENV_NAME_USE))
99
+ segments.push(...segmentForms(parts(noun.parts, "nouns[].parts")));
100
+ const excludeSuffixes = nonSecret.flatMap((suffix) =>
101
+ segmentForms(parts(suffix, "nonSecretSuffixes[]")).map(
102
+ (form) => `_${form}`,
103
+ ),
104
+ );
105
+ return {
106
+ segments: [...new Set(segments)],
107
+ excludeSuffixes: [...new Set(excludeSuffixes)],
108
+ excludeNames: EXCLUDE_NAMES,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * `value` as a validated non-empty array of lower-case noun parts, or throw. A
114
+ * malformed part must not render into a token that silently under-matches.
115
+ * @param {unknown} value
116
+ * @param {string} field
117
+ * @returns {string[]}
118
+ */
119
+ function parts(value, field) {
120
+ if (!Array.isArray(value) || value.length === 0)
121
+ throw new Error(`${VOCAB_LABEL}: ${field} is empty or missing`);
122
+ for (const part of value)
123
+ if (typeof part !== "string" || !/^[a-z0-9]+$/u.test(part))
124
+ throw new Error(`${VOCAB_LABEL}: bad part ${part} in ${field}`);
125
+ return value;
126
+ }
42
127
 
43
128
  /**
44
129
  * The validated token list under `field`, or throw. An absent, empty, or
@@ -51,17 +136,15 @@ const CRED_TOKEN_RE = /^[A-Z_]+$/;
51
136
  function credentialTokens(spec, field) {
52
137
  const group = spec[field];
53
138
  if (!Array.isArray(group) || group.length === 0)
54
- throw new Error(`credential-var-names.json: ${field} is empty or missing`);
139
+ throw new Error(`${VOCAB_LABEL}: ${field} is empty or missing`);
55
140
  for (const token of group)
56
141
  if (typeof token !== "string" || !CRED_TOKEN_RE.test(token))
57
- throw new Error(
58
- `credential-var-names.json: bad token ${token} in ${field}`,
59
- );
142
+ throw new Error(`${VOCAB_LABEL}: bad token ${token} in ${field}`);
60
143
  return group;
61
144
  }
62
145
 
63
146
  /**
64
- * Validate a credential-var-names spec and build its match/exclude regexes. Pure
147
+ * Validate a rendered name-matcher spec and build its match/exclude regexes. Pure
65
148
  * and exported so the fail-closed paths can be driven directly with a bad spec.
66
149
  * @param {Record<string, unknown>} spec
67
150
  * @returns {{ match: RegExp, exclude: RegExp }}
@@ -91,7 +174,9 @@ let _credentialNameRes;
91
174
  */
92
175
  function credentialNameRes() {
93
176
  if (_credentialNameRes !== undefined) return _credentialNameRes;
94
- return (_credentialNameRes = buildCredentialNameRes(credentialVarNames));
177
+ return (_credentialNameRes = buildCredentialNameRes(
178
+ deriveCredentialVocabulary(credentialNames),
179
+ ));
95
180
  }
96
181
 
97
182
  /**
@@ -120,12 +205,44 @@ export function dynamicSecretVars(env = process.env) {
120
205
  );
121
206
  }
122
207
 
208
+ // Operator-supplied additions to the env-bound redaction set, comma-separated.
209
+ // The name-shape heuristic above only catches vars whose trailing segment reads
210
+ // as a credential (`…_TOKEN`, `…_KEY`); a deployment that forwards a secret under
211
+ // a name of its own choosing (`GLOVEBOX_ATTESTATION_SEED`) has no way to reach
212
+ // the set without this.
213
+ const EXTRA_SECRET_VARS_ENV = "_AGENT_SANITIZER_EXTRA_SECRET_VARS";
214
+
215
+ // Digits allowed here but not in CRED_TOKEN_RE: that one gates regex-interpolated
216
+ // name SEGMENTS, while these are whole variable names an operator typed, and real
217
+ // ones carry digits (`AWS_S3_KEY2`). Both exclude metacharacters.
218
+ const EXTRA_TOKEN_RE = /^[A-Z0-9_]+$/;
219
+
220
+ /**
221
+ * The operator-declared extra secret variable names, or throw. A malformed entry
222
+ * fails CLOSED — dropping it silently would leave the operator believing a
223
+ * forwarded credential is masked while its value flows to the model verbatim.
224
+ * @param {Record<string, string | undefined>} [env]
225
+ * @returns {string[]}
226
+ */
227
+ export function extraSecretVars(env = process.env) {
228
+ const raw = env[EXTRA_SECRET_VARS_ENV];
229
+ if (raw === undefined || raw.trim() === "") return [];
230
+ const tokens = raw.split(",").map((token) => token.trim());
231
+ for (const token of tokens)
232
+ if (!EXTRA_TOKEN_RE.test(token))
233
+ throw new Error(
234
+ `${EXTRA_SECRET_VARS_ENV}: ${JSON.stringify(token)} is not a variable name ` +
235
+ "(expected comma-separated [A-Z0-9_] names)",
236
+ );
237
+ return tokens;
238
+ }
239
+
123
240
  /**
124
241
  * The env-bound redaction set: the UNION of the inference keys, the curated host
125
- * credentials, and any credential-shaped var present in the environment. The
126
- * redactor binds the same union; every consumer (the sanitize-output pre-gate,
127
- * the redactor client's per-request env snapshot) must mirror it exactly, else a
128
- * credential value would never trip the daemon.
242
+ * credentials, any credential-shaped var present in the environment, and the
243
+ * operator's declared extras. The redactor binds the same union; every consumer
244
+ * (the sanitize-output pre-gate, the redactor client's per-request env snapshot)
245
+ * must mirror it exactly, else a credential value would never trip the daemon.
129
246
  * @param {Record<string, string | undefined>} [env]
130
247
  * @returns {string[]}
131
248
  */
@@ -135,6 +252,7 @@ export function envBoundSecretVars(env = process.env) {
135
252
  ...inferenceKeyVars(),
136
253
  ...scrubbed.vars,
137
254
  ...dynamicSecretVars(env),
255
+ ...extraSecretVars(env),
138
256
  ]),
139
257
  ];
140
258
  }
@@ -160,6 +160,40 @@ async function redactSecrets(text, webIngress = false, deadline) {
160
160
  );
161
161
  }
162
162
 
163
+ /**
164
+ * Host-supplied extensions to this hook, threaded from {@link cliMain} down to
165
+ * each string leaf. Every field is optional and the bag defaults to `{}`, so a
166
+ * composer that supplies none gets exactly the behavior of this module alone —
167
+ * which is what lets the extension points ship without changing any shipped
168
+ * verdict. The callbacks own all policy: this module decides only WHERE they run,
169
+ * never WHETHER their result is applied.
170
+ *
171
+ * A callback that throws is not caught here. That is deliberate: the throw lands
172
+ * in the CLI's fail-closed catch and the tool output is suppressed, so a broken
173
+ * extension cannot degrade into showing unvetted output.
174
+ *
175
+ * These are NOT the Layer-5 injection-filter seam and do not inherit its
176
+ * delete-only, closed-enum restriction. (Its identifier is deliberately unspoken
177
+ * here: the plugin-bundle suite pins Layer 5 absent by asserting the name appears
178
+ * nowhere in this file, and that absolute check is worth more than the precision
179
+ * of one comment.) That restriction exists because such a
180
+ * filter is a MODEL, so its output is attacker-reachable and must not be able to
181
+ * inject text into the model-facing context. These callbacks are code the
182
+ * composer wrote and linked at build time — the same trust level as the injected
183
+ * redactor — so free-text warnings and arbitrary rewrites are theirs to own.
184
+ *
185
+ * @typedef {object} SanitizeExtensions
186
+ * @property {(cleaned: string, ctx: { toolName: string, webIngress: boolean, deadline: { remainingMs: () => number } }) => Promise<{ cleaned?: string, warning?: string } | null | undefined> | { cleaned?: string, warning?: string } | null | undefined} [postText]
187
+ * Runs once per string leaf, AFTER Layers 1-4. Returning `cleaned` replaces the
188
+ * model-facing text; `warning` joins this leaf's warnings.
189
+ * @property {(raw: string) => string | undefined} [redactNote]
190
+ * Given the pre-redaction text of a leaf that tripped Layer 4, returns a note
191
+ * appended to that leaf's "API keys/secrets redacted: …" warning.
192
+ * @property {(record: { tool: string | null, modified: boolean, output: unknown, context?: string }) => Promise<void> | void} [audit]
193
+ * Awaited once per judged event that carried a tool response, with the output
194
+ * the model will actually see.
195
+ */
196
+
163
197
  /**
164
198
  * Run Layers 1-4 over a single text blob, delegated to the package's output seam
165
199
  * (sanitizeTextSeam) bound here to this hook's per-tool policy: which tools get
@@ -171,12 +205,14 @@ async function redactSecrets(text, webIngress = false, deadline) {
171
205
  * @param {string} toolName gates the SGR carve-out and the untrusted-ingress passes
172
206
  * @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
173
207
  * all leaves of one hook run; a direct caller gets a fresh full budget
208
+ * @param {SanitizeExtensions} [ext]
174
209
  * @returns {Promise<{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
175
210
  */
176
211
  export async function sanitizeText(
177
212
  text,
178
213
  toolName,
179
214
  deadline = makeDeadline(SANITIZE_BUDGET_MS),
215
+ ext = {},
180
216
  ) {
181
217
  const webIngress = isUntrustedIngress(toolName);
182
218
  // Layer 2 (HTML rewrite) runs on WebFetch/WebSearch always, and on MCP output
@@ -211,12 +247,56 @@ export async function sanitizeText(
211
247
  );
212
248
  throw l4err;
213
249
  }
214
- return secrets ? { text: secrets.text, found: secrets.found } : null;
250
+ if (!secrets) return null;
251
+ // The note is derived from the PRE-redaction text: the caller's reason for
252
+ // annotating (which variable, which provenance) is exactly what redaction
253
+ // is about to remove.
254
+ const note = ext.redactNote?.(content);
255
+ return note
256
+ ? { text: secrets.text, found: secrets.found, note }
257
+ : { text: secrets.text, found: secrets.found };
215
258
  },
216
259
  };
217
- return /** @type {{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }} */ (
218
- await sanitizeTextSeam(text, seamOptions)
219
- );
260
+ const result =
261
+ /** @type {{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }} */ (
262
+ await sanitizeTextSeam(text, seamOptions)
263
+ );
264
+ return ext.postText
265
+ ? applyPostText(
266
+ result,
267
+ await ext.postText(result.cleaned, {
268
+ toolName,
269
+ webIngress,
270
+ deadline,
271
+ }),
272
+ )
273
+ : result;
274
+ }
275
+
276
+ /**
277
+ * Fold a `postText` callback's result into the seam's, leaving the seam's result
278
+ * untouched when the callback declined (null/undefined) or returned no `cleaned`.
279
+ * `modified` widens to cover the callback's rewrite, and `sgrNote` is dropped
280
+ * when it does: that flag downgrades the model-facing banner to "display-only
281
+ * ANSI color stripped", which would be a false statement about bytes a callback
282
+ * has since rewritten for its own reasons.
283
+ * @param {{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }} result
284
+ * @param {{ cleaned?: string, warning?: string } | null | undefined} post
285
+ * @returns {{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }}
286
+ */
287
+ function applyPostText(result, post) {
288
+ if (post === null || post === undefined) return result;
289
+ const cleaned = post.cleaned ?? result.cleaned;
290
+ const rewrote = cleaned !== result.cleaned;
291
+ return {
292
+ ...result,
293
+ cleaned,
294
+ warnings: post.warning
295
+ ? [...result.warnings, post.warning]
296
+ : result.warnings,
297
+ modified: result.modified || rewrote,
298
+ sgrNote: result.sgrNote && !rewrote,
299
+ };
220
300
  }
221
301
 
222
302
  /**
@@ -239,6 +319,7 @@ export async function sanitizeText(
239
319
  * @param {string[]} [reveals]
240
320
  * @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
241
321
  * every leaf of this value (created once by the top-level caller)
322
+ * @param {SanitizeExtensions} [ext]
242
323
  * @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
243
324
  */
244
325
  export async function sanitizeValue(
@@ -247,9 +328,10 @@ export async function sanitizeValue(
247
328
  warnings,
248
329
  reveals = [],
249
330
  deadline = makeDeadline(SANITIZE_BUDGET_MS),
331
+ ext = {},
250
332
  ) {
251
333
  if (typeof value === "string") {
252
- const result = await sanitizeText(value, toolName, deadline);
334
+ const result = await sanitizeText(value, toolName, deadline, ext);
253
335
  warnings.push(...result.warnings);
254
336
  if (result.reveal !== undefined) reveals.push(result.reveal);
255
337
  return {
@@ -269,6 +351,7 @@ export async function sanitizeValue(
269
351
  warnings,
270
352
  reveals,
271
353
  deadline,
354
+ ext,
272
355
  );
273
356
  out.push(result.value);
274
357
  if (result.modified) modified = true;
@@ -277,7 +360,7 @@ export async function sanitizeValue(
277
360
  return { value: out, modified, sgrNote };
278
361
  }
279
362
  if (value !== null && typeof value === "object")
280
- return sanitizeObject(value, toolName, warnings, reveals, deadline);
363
+ return sanitizeObject(value, toolName, warnings, reveals, deadline, ext);
281
364
  return { value, modified: false, sgrNote: false };
282
365
  }
283
366
 
@@ -291,14 +374,27 @@ export async function sanitizeValue(
291
374
  * @param {string[]} warnings
292
375
  * @param {string[]} reveals
293
376
  * @param {{remainingMs: () => number}} deadline shared wall-clock budget
377
+ * @param {SanitizeExtensions} ext
294
378
  * @returns {Promise<{ value: Record<string, any>, modified: boolean, sgrNote: boolean }>}
295
379
  */
296
- async function sanitizeObject(value, toolName, warnings, reveals, deadline) {
380
+ async function sanitizeObject(
381
+ value,
382
+ toolName,
383
+ warnings,
384
+ reveals,
385
+ deadline,
386
+ ext,
387
+ ) {
297
388
  /** @type {Record<string, any>} */
298
389
  const out = {};
299
390
  let modified = false;
300
391
  let sgrNote = false;
301
392
  for (const [key, item] of Object.entries(value)) {
393
+ // Layers 1-4 only — `ext` is deliberately NOT passed for a field NAME. A
394
+ // callback sees just the string and the tool, so it cannot tell a schema key
395
+ // from content; a rewrite here can collapse two fields into one name, which
396
+ // the guard below turns into whole-output suppression. Extensions run on
397
+ // value leaves.
302
398
  const keyResult = await sanitizeText(key, toolName, deadline);
303
399
  warnings.push(...keyResult.warnings);
304
400
  if (keyResult.reveal !== undefined) reveals.push(keyResult.reveal);
@@ -310,6 +406,7 @@ async function sanitizeObject(value, toolName, warnings, reveals, deadline) {
310
406
  warnings,
311
407
  reveals,
312
408
  deadline,
409
+ ext,
313
410
  );
314
411
  // Two distinct raw keys can sanitize to the same name (e.g. `token` and a
315
412
  // `token` carrying a zero-width space stripped by Layer 1). Overwriting would
@@ -458,9 +555,10 @@ export function emitFailClosed(
458
555
  * fields unchanged. The trace lives here, not in the CLI block below, so it
459
556
  * rides the in-process, mutation-tested path.
460
557
  * @param {any} input the tool_name / tool_input / tool_response to sanitize
558
+ * @param {SanitizeExtensions} [ext]
461
559
  * @returns {Promise<{ mutated_output?: unknown, additional_context?: string } | null>}
462
560
  */
463
- export async function evaluateToolOutput(input) {
561
+ export async function evaluateToolOutput(input, ext = {}) {
464
562
  /**
465
563
  * @param {string} outcome noop | clean | flagged | modified
466
564
  * @param {{ mutated_output?: unknown, additional_context?: string } | null} fields
@@ -504,6 +602,7 @@ export async function evaluateToolOutput(input) {
504
602
  warnings,
505
603
  reveals,
506
604
  deadline,
605
+ ext,
507
606
  );
508
607
  // Persist each leaf's pre-Layer-2 text (deduped by content) so the model can
509
608
  // Read back what the HTML splice removed; a successful write appends a hint
@@ -569,9 +668,10 @@ export async function evaluateToolOutput(input) {
569
668
  * translation. Throws only if a layer engine throws (or on an UNKNOWN event);
570
669
  * the CLI fails closed on any throw.
571
670
  * @param {import("agent-control-plane-core").ToolCallEvent} event
671
+ * @param {SanitizeExtensions} [ext]
572
672
  * @returns {Promise<import("agent-control-plane-core").Verdict>}
573
673
  */
574
- export async function judgeSanitizeOutput(event) {
674
+ export async function judgeSanitizeOutput(event, ext = {}) {
575
675
  const { Decision, EventKind } = controlPlane();
576
676
  // Fail closed on a payload the adapter cannot classify (contract/harness
577
677
  // drift): this hook only ever receives PostToolUse, so an UNKNOWN event is an
@@ -584,11 +684,30 @@ export async function judgeSanitizeOutput(event) {
584
684
  // evaluateToolOutput keys its tool checks on the CANONICAL names (`Read`, the
585
685
  // WEB_INGRESS_TOOLS set, `mcp__…`), so it takes `event.tool` — the normalized
586
686
  // name — not the raw `meta.native_tool`.
587
- const fields = await evaluateToolOutput({
588
- tool_name: event.tool,
589
- tool_input: event.input,
590
- tool_response: event.response,
591
- });
687
+ const fields = await evaluateToolOutput(
688
+ {
689
+ tool_name: event.tool,
690
+ tool_input: event.input,
691
+ tool_response: event.response,
692
+ },
693
+ ext,
694
+ );
695
+ // Audit sees what the MODEL will see, which is the whole point of putting the
696
+ // call here rather than beside the walk: `mutated_output` is present only when
697
+ // the sanitizer actually rewrote bytes, so its absence means the original
698
+ // response IS the model-facing output. Gated on a response existing, so an
699
+ // event with nothing to sanitize records nothing. Awaited (not fired and
700
+ // forgotten) so a recorder that throws fails the hook CLOSED — an audit trail
701
+ // with silent holes is worse than a suppressed tool output.
702
+ if (ext.audit && event.response !== null && event.response !== undefined) {
703
+ const modified = fields !== null && Object.hasOwn(fields, "mutated_output");
704
+ await ext.audit({
705
+ tool: event.tool,
706
+ modified,
707
+ output: modified ? fields?.mutated_output : event.response,
708
+ context: fields?.additional_context,
709
+ });
710
+ }
592
711
  /** @type {import("agent-control-plane-core").Verdict} */
593
712
  const verdict = { decision: Decision.ALLOW };
594
713
  return fields === null ? verdict : { ...verdict, ...fields };
@@ -627,27 +746,34 @@ export function withPostToolUseDefault(input) {
627
746
  * The hook's CLI: parse → judge → render, with this hook's fail-closed posture.
628
747
  * Exported so a bundle entry (which must claim the CLI slot before this module
629
748
  * loads) can run the exact same wiring instead of duplicating the onError
630
- * posture.
749
+ * posture. That entry is also the only place a host's {@link SanitizeExtensions}
750
+ * can be injected, which is why the bag enters here and not through the
751
+ * environment: a callback is code, and code belongs to the composer.
752
+ * @param {SanitizeExtensions} [ext]
631
753
  * @returns {Promise<void>}
632
754
  */
633
- export async function cliMain() {
634
- await runJudgeCli("sanitize-output", judgeSanitizeOutput, {
635
- transformInput: withPostToolUseDefault,
636
- // Fail closed: replace every string leaf of the original output with the
637
- // placeholder, preserving shape so the harness honors the suppression
638
- // instead of falling back to the raw, unvetted output (runJudgeCli hands
639
- // back the parsed `input` even when the control-plane load failed, so the
640
- // suppression shape-matches the real tool_response). emitFailClosed itself
641
- // falls back to a bare string if that shape-matching replacement or its
642
- // serialization throws, so even a pathological input fails closed.
643
- onError: (err, input) =>
644
- emitFailClosed(
645
- input,
646
- "[SANITIZATION FAILED original output suppressed for safety. Hook error: " +
647
- safeErrMessage(err) +
648
- "]",
649
- ),
650
- });
755
+ export async function cliMain(ext = {}) {
756
+ await runJudgeCli(
757
+ "sanitize-output",
758
+ (event) => judgeSanitizeOutput(event, ext),
759
+ {
760
+ transformInput: withPostToolUseDefault,
761
+ // Fail closed: replace every string leaf of the original output with the
762
+ // placeholder, preserving shape so the harness honors the suppression
763
+ // instead of falling back to the raw, unvetted output (runJudgeCli hands
764
+ // back the parsed `input` even when the control-plane load failed, so the
765
+ // suppression shape-matches the real tool_response). emitFailClosed itself
766
+ // falls back to a bare string if that shape-matching replacement or its
767
+ // serialization throws, so even a pathological input fails closed.
768
+ onError: (err, input) =>
769
+ emitFailClosed(
770
+ input,
771
+ "[SANITIZATION FAILED — original output suppressed for safety. Hook error: " +
772
+ safeErrMessage(err) +
773
+ "]",
774
+ ),
775
+ },
776
+ );
651
777
  }
652
778
 
653
779
  // Guard so importing (e.g. property tests) doesn't block on stdin.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.7.1",
3
+ "version": "2.8.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": {
@@ -130,9 +130,29 @@
130
130
  "types": "./types/claude-hooks/plugin-hooks.d.mts",
131
131
  "default": "./claude-hooks/plugin-hooks.mjs"
132
132
  },
133
- "./claude-hooks/*": {
134
- "types": "./types/claude-hooks/*.d.mts",
135
- "default": "./claude-hooks/*.mjs"
133
+ "./claude-hooks/pretooluse-sanitize": {
134
+ "types": "./types/claude-hooks/pretooluse-sanitize.d.mts",
135
+ "default": "./claude-hooks/pretooluse-sanitize.mjs"
136
+ },
137
+ "./claude-hooks/sanitize-output": {
138
+ "types": "./types/claude-hooks/sanitize-output.d.mts",
139
+ "default": "./claude-hooks/sanitize-output.mjs"
140
+ },
141
+ "./claude-hooks/sanitize-user-prompt": {
142
+ "types": "./types/claude-hooks/sanitize-user-prompt.d.mts",
143
+ "default": "./claude-hooks/sanitize-user-prompt.mjs"
144
+ },
145
+ "./claude-hooks/scan-invisible-chars": {
146
+ "types": "./types/claude-hooks/scan-invisible-chars.d.mts",
147
+ "default": "./claude-hooks/scan-invisible-chars.mjs"
148
+ },
149
+ "./claude-hooks/lib/hook-io": {
150
+ "types": "./types/claude-hooks/lib/hook-io.d.mts",
151
+ "default": "./claude-hooks/lib/hook-io.mjs"
152
+ },
153
+ "./claude-hooks/lib/control-plane": {
154
+ "types": "./types/claude-hooks/lib/control-plane.d.mts",
155
+ "default": "./claude-hooks/lib/control-plane.mjs"
136
156
  }
137
157
  },
138
158
  "files": [
@@ -11,7 +11,24 @@ export function inferenceKeyVars(): string[];
11
11
  */
12
12
  export function minEnvSecretLen(): number;
13
13
  /**
14
- * Validate a credential-var-names spec and build its match/exclude regexes. Pure
14
+ * Render the published credential-noun vocabulary into the name-matcher spec:
15
+ * the credential segments, and the trailing suffixes that mark a
16
+ * credential-shaped name as holding a non-secret.
17
+ *
18
+ * The vocabulary is the SINGLE source both ecosystems read — a curated second
19
+ * copy of these renderings is what let this matcher fall twelve segments behind
20
+ * the engine, so a variable named `…_ACCESS_TOKEN` was never recognized as
21
+ * credential-bearing and its value was never handed to the redactor.
22
+ * @param {Record<string, any>} spec
23
+ * @returns {{ segments: string[], excludeSuffixes: string[], excludeNames: string[] }}
24
+ */
25
+ export function deriveCredentialVocabulary(spec: Record<string, any>): {
26
+ segments: string[];
27
+ excludeSuffixes: string[];
28
+ excludeNames: string[];
29
+ };
30
+ /**
31
+ * Validate a rendered name-matcher spec and build its match/exclude regexes. Pure
15
32
  * and exported so the fail-closed paths can be driven directly with a bad spec.
16
33
  * @param {Record<string, unknown>} spec
17
34
  * @returns {{ match: RegExp, exclude: RegExp }}
@@ -36,12 +53,20 @@ export function looksLikeCredentialVar(name: string): boolean;
36
53
  * @returns {string[]}
37
54
  */
38
55
  export function dynamicSecretVars(env?: Record<string, string | undefined>): string[];
56
+ /**
57
+ * The operator-declared extra secret variable names, or throw. A malformed entry
58
+ * fails CLOSED — dropping it silently would leave the operator believing a
59
+ * forwarded credential is masked while its value flows to the model verbatim.
60
+ * @param {Record<string, string | undefined>} [env]
61
+ * @returns {string[]}
62
+ */
63
+ export function extraSecretVars(env?: Record<string, string | undefined>): string[];
39
64
  /**
40
65
  * The env-bound redaction set: the UNION of the inference keys, the curated host
41
- * credentials, and any credential-shaped var present in the environment. The
42
- * redactor binds the same union; every consumer (the sanitize-output pre-gate,
43
- * the redactor client's per-request env snapshot) must mirror it exactly, else a
44
- * credential value would never trip the daemon.
66
+ * credentials, any credential-shaped var present in the environment, and the
67
+ * operator's declared extras. The redactor binds the same union; every consumer
68
+ * (the sanitize-output pre-gate, the redactor client's per-request env snapshot)
69
+ * must mirror it exactly, else a credential value would never trip the daemon.
45
70
  * @param {Record<string, string | undefined>} [env]
46
71
  * @returns {string[]}
47
72
  */
@@ -1,3 +1,36 @@
1
+ /**
2
+ * Host-supplied extensions to this hook, threaded from {@link cliMain} down to
3
+ * each string leaf. Every field is optional and the bag defaults to `{}`, so a
4
+ * composer that supplies none gets exactly the behavior of this module alone —
5
+ * which is what lets the extension points ship without changing any shipped
6
+ * verdict. The callbacks own all policy: this module decides only WHERE they run,
7
+ * never WHETHER their result is applied.
8
+ *
9
+ * A callback that throws is not caught here. That is deliberate: the throw lands
10
+ * in the CLI's fail-closed catch and the tool output is suppressed, so a broken
11
+ * extension cannot degrade into showing unvetted output.
12
+ *
13
+ * These are NOT the Layer-5 injection-filter seam and do not inherit its
14
+ * delete-only, closed-enum restriction. (Its identifier is deliberately unspoken
15
+ * here: the plugin-bundle suite pins Layer 5 absent by asserting the name appears
16
+ * nowhere in this file, and that absolute check is worth more than the precision
17
+ * of one comment.) That restriction exists because such a
18
+ * filter is a MODEL, so its output is attacker-reachable and must not be able to
19
+ * inject text into the model-facing context. These callbacks are code the
20
+ * composer wrote and linked at build time — the same trust level as the injected
21
+ * redactor — so free-text warnings and arbitrary rewrites are theirs to own.
22
+ *
23
+ * @typedef {object} SanitizeExtensions
24
+ * @property {(cleaned: string, ctx: { toolName: string, webIngress: boolean, deadline: { remainingMs: () => number } }) => Promise<{ cleaned?: string, warning?: string } | null | undefined> | { cleaned?: string, warning?: string } | null | undefined} [postText]
25
+ * Runs once per string leaf, AFTER Layers 1-4. Returning `cleaned` replaces the
26
+ * model-facing text; `warning` joins this leaf's warnings.
27
+ * @property {(raw: string) => string | undefined} [redactNote]
28
+ * Given the pre-redaction text of a leaf that tripped Layer 4, returns a note
29
+ * appended to that leaf's "API keys/secrets redacted: …" warning.
30
+ * @property {(record: { tool: string | null, modified: boolean, output: unknown, context?: string }) => Promise<void> | void} [audit]
31
+ * Awaited once per judged event that carried a tool response, with the output
32
+ * the model will actually see.
33
+ */
1
34
  /**
2
35
  * Run Layers 1-4 over a single text blob, delegated to the package's output seam
3
36
  * (sanitizeTextSeam) bound here to this hook's per-tool policy: which tools get
@@ -9,11 +42,12 @@
9
42
  * @param {string} toolName gates the SGR carve-out and the untrusted-ingress passes
10
43
  * @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
11
44
  * all leaves of one hook run; a direct caller gets a fresh full budget
45
+ * @param {SanitizeExtensions} [ext]
12
46
  * @returns {Promise<{ cleaned: string, warnings: string[], modified: boolean, sgrNote: boolean, reveal?: string }>}
13
47
  */
14
48
  export function sanitizeText(text: string, toolName: string, deadline?: {
15
49
  remainingMs: () => number;
16
- }): Promise<{
50
+ }, ext?: SanitizeExtensions): Promise<{
17
51
  cleaned: string;
18
52
  warnings: string[];
19
53
  modified: boolean;
@@ -40,11 +74,12 @@ export function sanitizeText(text: string, toolName: string, deadline?: {
40
74
  * @param {string[]} [reveals]
41
75
  * @param {{remainingMs: () => number}} [deadline] shared wall-clock budget across
42
76
  * every leaf of this value (created once by the top-level caller)
77
+ * @param {SanitizeExtensions} [ext]
43
78
  * @returns {Promise<{ value: any, modified: boolean, sgrNote: boolean }>}
44
79
  */
45
80
  export function sanitizeValue(value: any, toolName: string, warnings: string[], reveals?: string[], deadline?: {
46
81
  remainingMs: () => number;
47
- }): Promise<{
82
+ }, ext?: SanitizeExtensions): Promise<{
48
83
  value: any;
49
84
  modified: boolean;
50
85
  sgrNote: boolean;
@@ -117,9 +152,10 @@ export function emitFailClosed(input: any, message: string, emit?: (fields: Reco
117
152
  * fields unchanged. The trace lives here, not in the CLI block below, so it
118
153
  * rides the in-process, mutation-tested path.
119
154
  * @param {any} input the tool_name / tool_input / tool_response to sanitize
155
+ * @param {SanitizeExtensions} [ext]
120
156
  * @returns {Promise<{ mutated_output?: unknown, additional_context?: string } | null>}
121
157
  */
122
- export function evaluateToolOutput(input: any): Promise<{
158
+ export function evaluateToolOutput(input: any, ext?: SanitizeExtensions): Promise<{
123
159
  mutated_output?: unknown;
124
160
  additional_context?: string;
125
161
  } | null>;
@@ -134,9 +170,10 @@ export function evaluateToolOutput(input: any): Promise<{
134
170
  * translation. Throws only if a layer engine throws (or on an UNKNOWN event);
135
171
  * the CLI fails closed on any throw.
136
172
  * @param {import("agent-control-plane-core").ToolCallEvent} event
173
+ * @param {SanitizeExtensions} [ext]
137
174
  * @returns {Promise<import("agent-control-plane-core").Verdict>}
138
175
  */
139
- export function judgeSanitizeOutput(event: import("agent-control-plane-core").ToolCallEvent): Promise<import("agent-control-plane-core").Verdict>;
176
+ export function judgeSanitizeOutput(event: import("agent-control-plane-core").ToolCallEvent, ext?: SanitizeExtensions): Promise<import("agent-control-plane-core").Verdict>;
140
177
  /**
141
178
  * Default a raw payload's `hook_event_name` to PostToolUse when it is absent.
142
179
  * sanitize-output is wired ONLY to the PostToolUse event, so a payload that
@@ -155,10 +192,13 @@ export function withPostToolUseDefault(input: unknown): unknown;
155
192
  * The hook's CLI: parse → judge → render, with this hook's fail-closed posture.
156
193
  * Exported so a bundle entry (which must claim the CLI slot before this module
157
194
  * loads) can run the exact same wiring instead of duplicating the onError
158
- * posture.
195
+ * posture. That entry is also the only place a host's {@link SanitizeExtensions}
196
+ * can be injected, which is why the bag enters here and not through the
197
+ * environment: a callback is code, and code belongs to the composer.
198
+ * @param {SanitizeExtensions} [ext]
159
199
  * @returns {Promise<void>}
160
200
  */
161
- export function cliMain(): Promise<void>;
201
+ export function cliMain(ext?: SanitizeExtensions): Promise<void>;
162
202
  export const applyLayer1: typeof import("agent-sanitizer").applyLayer1;
163
203
  export const matchesSecretHint: typeof import("agent-sanitizer").matchesSecretHint;
164
204
  export const SECRET_HINT: RegExp;
@@ -166,3 +206,59 @@ export const SECRET_HINT_EXT: RegExp;
166
206
  export const describeRemoved: typeof import("agent-sanitizer/output").describeRemoved;
167
207
  export const describeWarned: typeof import("agent-sanitizer/output").describeWarned;
168
208
  export const suppressToolOutput: typeof import("agent-sanitizer/output").suppressToolOutput;
209
+ /**
210
+ * Host-supplied extensions to this hook, threaded from {@link cliMain} down to
211
+ * each string leaf. Every field is optional and the bag defaults to `{}`, so a
212
+ * composer that supplies none gets exactly the behavior of this module alone —
213
+ * which is what lets the extension points ship without changing any shipped
214
+ * verdict. The callbacks own all policy: this module decides only WHERE they run,
215
+ * never WHETHER their result is applied.
216
+ *
217
+ * A callback that throws is not caught here. That is deliberate: the throw lands
218
+ * in the CLI's fail-closed catch and the tool output is suppressed, so a broken
219
+ * extension cannot degrade into showing unvetted output.
220
+ *
221
+ * These are NOT the Layer-5 injection-filter seam and do not inherit its
222
+ * delete-only, closed-enum restriction. (Its identifier is deliberately unspoken
223
+ * here: the plugin-bundle suite pins Layer 5 absent by asserting the name appears
224
+ * nowhere in this file, and that absolute check is worth more than the precision
225
+ * of one comment.) That restriction exists because such a
226
+ * filter is a MODEL, so its output is attacker-reachable and must not be able to
227
+ * inject text into the model-facing context. These callbacks are code the
228
+ * composer wrote and linked at build time — the same trust level as the injected
229
+ * redactor — so free-text warnings and arbitrary rewrites are theirs to own.
230
+ */
231
+ export type SanitizeExtensions = {
232
+ /**
233
+ * Runs once per string leaf, AFTER Layers 1-4. Returning `cleaned` replaces the
234
+ * model-facing text; `warning` joins this leaf's warnings.
235
+ */
236
+ postText?: ((cleaned: string, ctx: {
237
+ toolName: string;
238
+ webIngress: boolean;
239
+ deadline: {
240
+ remainingMs: () => number;
241
+ };
242
+ }) => Promise<{
243
+ cleaned?: string;
244
+ warning?: string;
245
+ } | null | undefined> | {
246
+ cleaned?: string;
247
+ warning?: string;
248
+ } | null | undefined) | undefined;
249
+ /**
250
+ * Given the pre-redaction text of a leaf that tripped Layer 4, returns a note
251
+ * appended to that leaf's "API keys/secrets redacted: …" warning.
252
+ */
253
+ redactNote?: ((raw: string) => string | undefined) | undefined;
254
+ /**
255
+ * Awaited once per judged event that carried a tool response, with the output
256
+ * the model will actually see.
257
+ */
258
+ audit?: ((record: {
259
+ tool: string | null;
260
+ modified: boolean;
261
+ output: unknown;
262
+ context?: string;
263
+ }) => Promise<void> | void) | undefined;
264
+ };
@@ -1,23 +0,0 @@
1
- {
2
- "comment": "The credential-shaped ENV-VAR NAME vocabulary the hook-side pre-gate builds its regexes from (looksLikeCredentialVar in lib/env-config.mjs). `segments`: a var whose trailing underscore-delimited segment is one of these is treated as credential-bearing (matched as `(?:^|_)(?:<segment>)$`, case-insensitive). `excludeSuffixes` / `excludeNames`: names that end like a credential but hold a non-secret (an identifier, a public key, the ssh-agent socket path) and must NOT be redacted out of tool output. Every token is restricted to A-Z and _ so it carries no regex metacharacter; the consumer enforces that and fails closed on a violation, an empty list, or a missing field.",
3
- "segments": [
4
- "TOKEN",
5
- "SECRET",
6
- "SECRETS",
7
- "PASSWORD",
8
- "PASSWD",
9
- "PASSPHRASE",
10
- "APIKEY",
11
- "API_KEY",
12
- "ACCESS_KEY",
13
- "SECRET_KEY",
14
- "PRIVATE_KEY",
15
- "AUTH_TOKEN",
16
- "PAT",
17
- "CREDENTIAL",
18
- "CREDENTIALS",
19
- "KEY"
20
- ],
21
- "excludeSuffixes": ["_KEY_ID", "_PUBLIC_KEY"],
22
- "excludeNames": ["SSH_AUTH_SOCK"]
23
- }