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
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* @
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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.
|
|
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
|