agent-sanitizer 2.33.1 → 2.34.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
@@ -16,6 +16,7 @@ npm install agent-sanitizer
16
16
  **As a Claude Code plugin:**
17
17
 
18
18
  Enter one at a time:
19
+
19
20
  ```
20
21
  /plugin marketplace add AlexanderMattTurner/agent-sanitizer
21
22
  ```
@@ -23,6 +24,7 @@ Enter one at a time:
23
24
  ```
24
25
  /plugin install agent-sanitizer@agent-sanitizer
25
26
  ```
27
+
26
28
  ```
27
29
  /agent-sanitizer:enable-auto-update
28
30
  ```
@@ -242,6 +242,50 @@ export function failOpenEnabled(env = process.env) {
242
242
  return !FAIL_CLOSED_SET.has(env[FAIL_OPEN_ENV] ?? "");
243
243
  }
244
244
 
245
+ /**
246
+ * The public knob that turns individual hooks off: a comma-separated list of
247
+ * hook names (the `--hook=` modes). The layer opt-outs
248
+ * (`AGENT_SANITIZER_*_DISABLED`) narrow what a hook rewrites; this one is for
249
+ * the deployment that wants a whole event unguarded — a session whose prompts
250
+ * legitimately carry escape sequences, or one where the SessionStart context
251
+ * scan is redundant because the instruction files are already vetted upstream.
252
+ * Without it the only way to drop one hook is to edit the shipped hooks.json,
253
+ * which the next plugin update overwrites.
254
+ */
255
+ export const DISABLED_HOOKS_ENV = "AGENT_SANITIZER_DISABLED_HOOKS";
256
+
257
+ /**
258
+ * The hook names an operator switched off, restricted to `known`.
259
+ *
260
+ * An unrecognized name is REPORTED and dropped rather than thrown on, and the
261
+ * direction of that choice is the point: this variable is set outside the
262
+ * session, so a throw here — or a blocking verdict — would leave every tool
263
+ * call failing on a config value the session cannot edit. Dropping it keeps the
264
+ * named hook running, which is the safe side, and `report` is what stops it
265
+ * being silent.
266
+ * @param {readonly string[]} known every dispatchable hook name
267
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
268
+ * @param {(message: string) => void} [report]
269
+ * @returns {Set<string>}
270
+ */
271
+ export function disabledHooks(
272
+ known,
273
+ env = process.env,
274
+ report = (message) => process.stderr.write(`${message}\n`),
275
+ ) {
276
+ const named = (env[DISABLED_HOOKS_ENV] ?? "")
277
+ .split(",")
278
+ .map((name) => name.trim())
279
+ .filter((name) => name !== "");
280
+ const unknown = named.filter((name) => !known.includes(name));
281
+ if (unknown.length > 0)
282
+ report(
283
+ `agent-sanitizer: ${DISABLED_HOOKS_ENV} names ${unknown.join(", ")}, ` +
284
+ `which is not a hook — it stays ENABLED. Known hooks: ${known.join(", ")}.`,
285
+ );
286
+ return new Set(named.filter((name) => known.includes(name)));
287
+ }
288
+
245
289
  /**
246
290
  * The model-facing warning accompanying a fail-open pass-through. Emitted as
247
291
  * `additionalContext` so the transcript still carries the failure: the posture
@@ -15,6 +15,10 @@
15
15
  */
16
16
  import {
17
17
  claimCliEntry,
18
+ disabledHooks,
19
+ DISABLED_HOOKS_ENV,
20
+ emitHookResponse,
21
+ HookEvent,
18
22
  isMain,
19
23
  registerLazyModules,
20
24
  readFlag,
@@ -102,67 +106,115 @@ async function registerAvailableModules() {
102
106
  }
103
107
 
104
108
  /**
105
- * Dispatch to the hook named by `--hook=<name>` in argv. Exported and guarded by
106
- * isMain below so importing this module (the published entry point) is a no-op:
107
- * only a direct `node plugin-hooks.mjs --hook=…` run consumes stdin and exits.
108
- * @returns {Promise<void>}
109
+ * Every dispatchable hook: its Claude Code event, and the loader that runs it.
110
+ *
111
+ * A table rather than a switch because the mode NAMES are read three ways — the
112
+ * dispatch, the {@link DISABLED_HOOKS_ENV} validation, and the tests that check
113
+ * hooks.json wires exactly these — and each hand-written copy is one that can
114
+ * drift into naming a hook nothing dispatches. The event is here because a
115
+ * disabled hook still has to answer in its own event's envelope.
116
+ *
117
+ * Each module is loaded through a LITERAL dynamic import: esbuild inlines only
118
+ * what it can read statically, so a computed specifier would survive into the
119
+ * bundle as a runtime dial against a node_modules the plugin does not ship.
120
+ * @type {Record<string, { event: string, run: () => Promise<void> }>}
109
121
  */
110
- export async function main() {
111
- // This binder owns the process's CLI entry: inside the bundle every inlined
112
- // module shares this file's import.meta.url, so without the claim the inlined
113
- // hooks' own isMain-guarded CLIs would also fire and consume stdin.
114
- claimCliEntry();
115
- await registerAvailableModules();
116
-
117
- const mode = readFlag(process.argv, "hook");
118
- switch (mode) {
119
- case "pretooluse-sanitize": {
122
+ const HOOKS = {
123
+ "pretooluse-sanitize": {
124
+ event: HookEvent.PRE_TOOL_USE,
125
+ run: async () => {
120
126
  const { cliMain } =
121
127
  /** @type {typeof import("./pretooluse-sanitize.mjs")} */ (
122
128
  await import("./pretooluse-sanitize.mjs")
123
129
  );
124
130
  await cliMain();
125
- break;
126
- }
127
- case "sanitize-output": {
131
+ },
132
+ },
133
+ "sanitize-output": {
134
+ event: HookEvent.POST_TOOL_USE,
135
+ run: async () => {
128
136
  const { cliMain } =
129
137
  /** @type {typeof import("./sanitize-output.mjs")} */ (
130
138
  await import("./sanitize-output.mjs")
131
139
  );
132
140
  await cliMain();
133
- break;
134
- }
135
- case "sanitize-user-prompt": {
141
+ },
142
+ },
143
+ "sanitize-user-prompt": {
144
+ event: HookEvent.USER_PROMPT_SUBMIT,
145
+ run: async () => {
136
146
  const { main: promptMain } =
137
147
  /** @type {typeof import("./sanitize-user-prompt.mjs")} */ (
138
148
  await import("./sanitize-user-prompt.mjs")
139
149
  );
140
150
  await promptMain(readStdinJson, (chunk) => process.stdout.write(chunk));
141
- break;
142
- }
143
- case "scan-invisible-chars": {
151
+ },
152
+ },
153
+ "scan-invisible-chars": {
154
+ event: HookEvent.SESSION_START,
155
+ run: async () => {
144
156
  const { cliMain } =
145
157
  /** @type {typeof import("./scan-invisible-chars.mjs")} */ (
146
158
  await import("./scan-invisible-chars.mjs")
147
159
  );
148
160
  await cliMain();
149
- break;
150
- }
151
- default:
152
- // An unknown mode means broken hooks.json wiring — never fall through to
153
- // some default hook and vet the wrong payload class. WHICH way it fails is
154
- // the operator's call, taken through the one posture table like every
155
- // other hook fault (this arm used to hard-exit 2 unconditionally, so the
156
- // knob an operator set was silently overruled here alone).
157
- process.exit(
158
- writeFaultOutcome(
159
- hookFaultOutcome(
160
- HOOK_NAME,
161
- new Error(`unknown hook mode ${JSON.stringify(mode)}`),
162
- ),
161
+ },
162
+ },
163
+ };
164
+
165
+ /** The dispatchable hook names, in hooks.json's spelling. */
166
+ export const HOOK_MODES = Object.freeze(Object.keys(HOOKS));
167
+
168
+ /**
169
+ * Dispatch to the hook named by `--hook=<name>` in argv. Exported and guarded by
170
+ * isMain below so importing this module (the published entry point) is a no-op:
171
+ * only a direct `node plugin-hooks.mjs --hook=…` run consumes stdin and exits.
172
+ * @returns {Promise<void>}
173
+ */
174
+ export async function main() {
175
+ // This binder owns the process's CLI entry: inside the bundle every inlined
176
+ // module shares this file's import.meta.url, so without the claim the inlined
177
+ // hooks' own isMain-guarded CLIs would also fire and consume stdin.
178
+ claimCliEntry();
179
+ await registerAvailableModules();
180
+
181
+ const mode = readFlag(process.argv, "hook");
182
+ const hook = mode === undefined ? undefined : HOOKS[mode];
183
+ if (mode === undefined || hook === undefined) {
184
+ // An unknown mode means broken hooks.json wiring — never fall through to
185
+ // some default hook and vet the wrong payload class. WHICH way it fails is
186
+ // the operator's call, taken through the one posture table like every other
187
+ // hook fault.
188
+ process.exit(
189
+ writeFaultOutcome(
190
+ hookFaultOutcome(
191
+ HOOK_NAME,
192
+ new Error(`unknown hook mode ${JSON.stringify(mode)}`),
163
193
  ),
164
- );
194
+ ),
195
+ );
196
+ }
197
+
198
+ // An empty envelope is a verdict, not a crash: stdout stays non-empty, so the
199
+ // launcher's post-condition sees an answer and Claude Code records a clean
200
+ // run rather than a hook error. The operator asked for this hook not to
201
+ // guard, so there is nothing to warn the MODEL about — the stderr line is
202
+ // where an operator finds out which hooks are off.
203
+ if (disabledHooks(HOOK_MODES).has(mode)) {
204
+ process.stderr.write(
205
+ `agent-sanitizer: ${mode} is off via ${DISABLED_HOOKS_ENV}; this ` +
206
+ `${hook.event} event is UNGUARDED.\n`,
207
+ );
208
+ emitHookResponse(hook.event, {});
209
+ // Every other hook reads the payload to EOF. Exiting without doing so
210
+ // leaves Claude Code writing into a closed pipe — an EPIPE on the harness
211
+ // side for any payload past the pipe buffer, which is most of them.
212
+ // `resume()` with no data listener consumes and discards.
213
+ process.stdin.resume();
214
+ return;
165
215
  }
216
+
217
+ await hook.run();
166
218
  }
167
219
 
168
220
  // isMain is read BEFORE main() claims the CLI slot (the claim makes every later
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.33.1",
3
+ "version": "2.34.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": {
@@ -85,6 +85,21 @@ export function readFlag(argv: string[], name: string): string | undefined;
85
85
  * @returns {boolean}
86
86
  */
87
87
  export function failOpenEnabled(env?: NodeJS.ProcessEnv | Record<string, string | undefined>): boolean;
88
+ /**
89
+ * The hook names an operator switched off, restricted to `known`.
90
+ *
91
+ * An unrecognized name is REPORTED and dropped rather than thrown on, and the
92
+ * direction of that choice is the point: this variable is set outside the
93
+ * session, so a throw here — or a blocking verdict — would leave every tool
94
+ * call failing on a config value the session cannot edit. Dropping it keeps the
95
+ * named hook running, which is the safe side, and `report` is what stops it
96
+ * being silent.
97
+ * @param {readonly string[]} known every dispatchable hook name
98
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
99
+ * @param {(message: string) => void} [report]
100
+ * @returns {Set<string>}
101
+ */
102
+ export function disabledHooks(known: readonly string[], env?: NodeJS.ProcessEnv | Record<string, string | undefined>, report?: (message: string) => void): Set<string>;
88
103
  /**
89
104
  * The model-facing warning accompanying a fail-open pass-through. Emitted as
90
105
  * `additionalContext` so the transcript still carries the failure: the posture
@@ -443,6 +458,17 @@ export const FAIL_OPEN_ENV: "AGENT_SANITIZER_FAIL_OPEN";
443
458
  * tests/test_safe_launch.py.
444
459
  */
445
460
  export const FAIL_CLOSED_VALUES: readonly string[];
461
+ /**
462
+ * The public knob that turns individual hooks off: a comma-separated list of
463
+ * hook names (the `--hook=` modes). The layer opt-outs
464
+ * (`AGENT_SANITIZER_*_DISABLED`) narrow what a hook rewrites; this one is for
465
+ * the deployment that wants a whole event unguarded — a session whose prompts
466
+ * legitimately carry escape sequences, or one where the SessionStart context
467
+ * scan is redundant because the instruction files are already vetted upstream.
468
+ * Without it the only way to drop one hook is to edit the shipped hooks.json,
469
+ * which the next plugin update overwrites.
470
+ */
471
+ export const DISABLED_HOOKS_ENV: "AGENT_SANITIZER_DISABLED_HOOKS";
446
472
  /**
447
473
  * Hard cap on hook stdin. A well-formed Claude Code hook payload is at most a
448
474
  * few MB (tool input plus the harness-truncated tool output); 64 MiB leaves
@@ -5,3 +5,5 @@
5
5
  * @returns {Promise<void>}
6
6
  */
7
7
  export function main(): Promise<void>;
8
+ /** The dispatchable hook names, in hooks.json's spelling. */
9
+ export const HOOK_MODES: readonly string[];