agent-sanitizer 2.58.10 → 2.59.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/THREAT-MODEL.md +12 -7
- package/bin/sanitize-cli.mjs +4 -17
- package/claude-hooks/lib/authored-content.mjs +3 -2
- package/claude-hooks/lib/env-config.mjs +12 -10
- package/claude-hooks/lib/hook-fault.mjs +38 -5
- package/claude-hooks/lib/hook-io.mjs +133 -54
- package/claude-hooks/lib/invisible-alert.mjs +9 -7
- package/claude-hooks/lib/knobs.mjs +286 -0
- package/claude-hooks/lib/redactor-client.mjs +161 -126
- package/claude-hooks/lib/redactor-wire.mjs +145 -0
- package/claude-hooks/lib/reveal.mjs +5 -10
- package/claude-hooks/lib/trace.mjs +38 -9
- package/claude-hooks/plugin-hooks.mjs +2 -0
- package/claude-hooks/pretooluse-sanitize.mjs +8 -3
- package/claude-hooks/sanitize-output.mjs +39 -23
- package/claude-hooks/sanitize-user-prompt.mjs +2 -0
- package/claude-hooks/scan-invisible-chars.mjs +2 -0
- package/claude-hooks/scan-loaded-instructions.mjs +2 -0
- package/package.json +2 -1
- package/python/README.md +6 -3
- package/python/agent_sanitizer/secrets/data/redactor-protocol.json +17 -0
- package/src/instructions.mjs +6 -1
- package/types/claude-hooks/lib/env-config.d.mts +8 -7
- package/types/claude-hooks/lib/hook-fault.d.mts +74 -4
- package/types/claude-hooks/lib/hook-io.d.mts +55 -14
- package/types/claude-hooks/lib/knobs.d.mts +138 -0
- package/types/claude-hooks/lib/redactor-client.d.mts +58 -37
- package/types/claude-hooks/lib/redactor-wire.d.mts +46 -0
- package/types/claude-hooks/lib/trace.d.mts +19 -4
- package/types/claude-hooks/sanitize-output.d.mts +5 -6
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE environment knobs the hooks and the CLI read, as one table.
|
|
3
|
+
*
|
|
4
|
+
* Every `AGENT_SANITIZER_*` / `_AGENT_SANITIZER_*` read goes through
|
|
5
|
+
* {@link readKnob}, which looks the name up here, parses the raw value with the
|
|
6
|
+
* entry's own grammar, and REPORTS a value the grammar does not recognize
|
|
7
|
+
* instead of silently treating it as unset. Seven hand-rolled parsers each
|
|
8
|
+
* decided that alone, and each failed the same way: `SECRETS_ENABLED=true`
|
|
9
|
+
* left the secret layer off, `FAIL_OPEN=FALSE` left the hooks open, and
|
|
10
|
+
* `MAX_INPUT_BYTES=0.5` became a zero-byte cap — all without a word.
|
|
11
|
+
*
|
|
12
|
+
* A bad value is reported, never thrown: these variables are set outside the
|
|
13
|
+
* session, so a throw (or a blocking verdict) would fail every tool call on a
|
|
14
|
+
* config value the session cannot edit. The entry's `fallback` is what the
|
|
15
|
+
* value then reads as, and `consequence` says so in the report.
|
|
16
|
+
*
|
|
17
|
+
* `test/claude-hooks-knobs.test.mjs` parses every hook module and the CLI and
|
|
18
|
+
* asserts the knobs they read are exactly this table, and that nothing reads
|
|
19
|
+
* one past {@link readKnob}.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Values of AGENT_SANITIZER_FAIL_OPEN that select the fail-closed posture.
|
|
24
|
+
* Matched exactly: a case-insensitive match would need `tr`, which the launcher
|
|
25
|
+
* cannot reach (it runs its no-node arm on shell builtins alone).
|
|
26
|
+
*
|
|
27
|
+
* THE single source of truth for the closed set. The shell shims cannot import
|
|
28
|
+
* it, so `plugin/scripts/lib/fail-open.sh` is GENERATED from it by
|
|
29
|
+
* `scripts/gen-fail-open-lib.mjs` and committed; the round trip is asserted in
|
|
30
|
+
* plugin/test/fail-open-parity.test.mjs. Everything else that spells the set
|
|
31
|
+
* out by hand is an implementation that must appear in the parity table in
|
|
32
|
+
* tests/test_safe_launch.py.
|
|
33
|
+
*/
|
|
34
|
+
export const FAIL_CLOSED_VALUES = Object.freeze(["0", "false"]);
|
|
35
|
+
|
|
36
|
+
/** What a parser returns for a value its grammar does not recognize. */
|
|
37
|
+
const INVALID = Symbol("invalid knob value");
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One knob: its grammar, the value an unrecognized input reads as, and the two
|
|
41
|
+
* halves of the report naming it.
|
|
42
|
+
* @template T
|
|
43
|
+
* @typedef {{
|
|
44
|
+
* parse: (raw: string | undefined) => T | typeof INVALID,
|
|
45
|
+
* fallback: T,
|
|
46
|
+
* expect: string,
|
|
47
|
+
* consequence: string,
|
|
48
|
+
* }} Knob
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `spec`, checked against {@link Knob} so a grammar and its fallback agree in
|
|
53
|
+
* type; {@link on} freezes it.
|
|
54
|
+
* @template T
|
|
55
|
+
* @param {Knob<T>} spec
|
|
56
|
+
* @returns {Knob<T>}
|
|
57
|
+
*/
|
|
58
|
+
const knob = (spec) => spec;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Who sets a knob, which is where it must be documented (asserted in
|
|
62
|
+
* test/claude-hooks-knobs.test.mjs):
|
|
63
|
+
* - `hooks`: a Claude Code plugin operator — plugin/README.md's knob tables;
|
|
64
|
+
* - `cli`: a caller of bin/sanitize-cli.mjs — README.md;
|
|
65
|
+
* - `internal`: `_`-prefixed plumbing hooks.json and tests set — no stable
|
|
66
|
+
* interface, so documented nowhere by name.
|
|
67
|
+
* @typedef {"hooks" | "cli" | "internal"} Surface
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `spec`, tagged with the surface that sets it.
|
|
72
|
+
* @template {Knob<any>} S
|
|
73
|
+
* @param {Surface} surface
|
|
74
|
+
* @param {S} spec
|
|
75
|
+
* @returns {Readonly<S & { surface: Surface }>}
|
|
76
|
+
*/
|
|
77
|
+
const on = (surface, spec) => Object.freeze({ ...spec, surface });
|
|
78
|
+
|
|
79
|
+
/** Unset and empty are the same request: the default. */
|
|
80
|
+
const unset = (/** @type {string | undefined} */ raw) =>
|
|
81
|
+
raw === undefined || raw === "";
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* An on/off switch spelled `1`. Only `1` turns it on, so no typo can switch a
|
|
85
|
+
* layer on or off by accident; `0` and unset are the recognized off spellings,
|
|
86
|
+
* and anything else (`true`, `yes`) is reported and reads as off.
|
|
87
|
+
* @param {string} consequence what an unrecognized value leaves in place
|
|
88
|
+
* @returns {Knob<boolean>}
|
|
89
|
+
*/
|
|
90
|
+
const flag = (consequence) =>
|
|
91
|
+
knob({
|
|
92
|
+
parse: (raw) => {
|
|
93
|
+
if (raw === "1") return true;
|
|
94
|
+
return unset(raw) || raw === "0" ? false : INVALID;
|
|
95
|
+
},
|
|
96
|
+
fallback: false,
|
|
97
|
+
expect: "1 to set it, 0 or unset to leave it off",
|
|
98
|
+
consequence,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `raw` as a finite positive number of milliseconds, else `fallback`. A bare
|
|
103
|
+
* `Number(env) || fallback` silently accepts a NEGATIVE override (`-5 || 8000`
|
|
104
|
+
* is -5), and a non-positive deadline makes a fail-closed wait return at once.
|
|
105
|
+
* @param {string | undefined} raw
|
|
106
|
+
* @param {number} fallback
|
|
107
|
+
* @returns {number}
|
|
108
|
+
*/
|
|
109
|
+
export function positiveMsOr(raw, fallback) {
|
|
110
|
+
const ms = Number(raw);
|
|
111
|
+
return Number.isFinite(ms) && ms > 0 ? ms : fallback;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A duration override in milliseconds.
|
|
116
|
+
* @param {number} fallback
|
|
117
|
+
* @returns {Knob<number>}
|
|
118
|
+
*/
|
|
119
|
+
const milliseconds = (fallback) =>
|
|
120
|
+
knob({
|
|
121
|
+
parse: (raw) => {
|
|
122
|
+
if (unset(raw)) return fallback;
|
|
123
|
+
const ms = positiveMsOr(raw, Number.NaN);
|
|
124
|
+
return Number.isNaN(ms) ? INVALID : ms;
|
|
125
|
+
},
|
|
126
|
+
fallback,
|
|
127
|
+
expect: "a positive number of milliseconds",
|
|
128
|
+
consequence: `the ${fallback} ms default applies`,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Free text the consumer interprets itself — a path, or a list whose entries
|
|
133
|
+
* it validates against its own vocabulary. Empty reads as unset.
|
|
134
|
+
* @returns {Knob<string | undefined>}
|
|
135
|
+
*/
|
|
136
|
+
const text = () =>
|
|
137
|
+
knob({
|
|
138
|
+
parse: (raw) => (unset(raw) ? undefined : raw),
|
|
139
|
+
fallback: undefined,
|
|
140
|
+
expect: "any text",
|
|
141
|
+
consequence: "it is ignored",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/** Trace verbosity spellings, matched case-insensitively. */
|
|
145
|
+
const TRACE_LEVELS = Object.freeze({
|
|
146
|
+
0: ["", "0", "off", "false"],
|
|
147
|
+
1: ["1", "info", "true", "on"],
|
|
148
|
+
2: ["2", "debug"],
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/** Largest single CLI request, in UTF-8 bytes, when the knob is unset. */
|
|
152
|
+
const DEFAULT_MAX_INPUT_BYTES = 10 * 1024 * 1024;
|
|
153
|
+
|
|
154
|
+
/** Every knob, by variable name. */
|
|
155
|
+
export const KNOBS = Object.freeze({
|
|
156
|
+
// Fail-open unless one of FAIL_CLOSED_VALUES; the documented open spellings
|
|
157
|
+
// are recognized too, so only a genuinely foreign value is reported.
|
|
158
|
+
AGENT_SANITIZER_FAIL_OPEN: on(
|
|
159
|
+
"hooks",
|
|
160
|
+
knob({
|
|
161
|
+
parse: (raw) => {
|
|
162
|
+
if (raw !== undefined && FAIL_CLOSED_VALUES.includes(raw)) return false;
|
|
163
|
+
return unset(raw) || raw === "1" || raw === "true" ? true : INVALID;
|
|
164
|
+
},
|
|
165
|
+
fallback: true,
|
|
166
|
+
expect: `${FAIL_CLOSED_VALUES.join(" or ")} to fail closed, 1/true/unset for the default`,
|
|
167
|
+
consequence: "the hooks stay fail-OPEN",
|
|
168
|
+
}),
|
|
169
|
+
),
|
|
170
|
+
AGENT_SANITIZER_DISABLED_HOOKS: on("hooks", text()),
|
|
171
|
+
AGENT_SANITIZER_SECRETS_ENABLED: on(
|
|
172
|
+
"hooks",
|
|
173
|
+
flag("the secret layer stays OFF"),
|
|
174
|
+
),
|
|
175
|
+
AGENT_SANITIZER_FLAG_DIGEST_VALUES: on(
|
|
176
|
+
"hooks",
|
|
177
|
+
flag("digest-shaped values stay exempt"),
|
|
178
|
+
),
|
|
179
|
+
AGENT_SANITIZER_INVISIBLE_DISABLED: on(
|
|
180
|
+
"hooks",
|
|
181
|
+
flag("invisible-character stripping stays ON"),
|
|
182
|
+
),
|
|
183
|
+
AGENT_SANITIZER_TERMINAL_DISABLED: on(
|
|
184
|
+
"hooks",
|
|
185
|
+
flag("terminal-control stripping stays ON"),
|
|
186
|
+
),
|
|
187
|
+
AGENT_SANITIZER_OUTPUT_DISABLED: on(
|
|
188
|
+
"hooks",
|
|
189
|
+
flag("authored-content sanitizing stays ON"),
|
|
190
|
+
),
|
|
191
|
+
AGENT_SANITIZER_MAX_INPUT_BYTES: on(
|
|
192
|
+
"cli",
|
|
193
|
+
knob({
|
|
194
|
+
parse: (raw) => {
|
|
195
|
+
if (unset(raw)) return DEFAULT_MAX_INPUT_BYTES;
|
|
196
|
+
const bytes = Number(raw);
|
|
197
|
+
return Number.isInteger(bytes) && bytes >= 1 ? bytes : INVALID;
|
|
198
|
+
},
|
|
199
|
+
fallback: DEFAULT_MAX_INPUT_BYTES,
|
|
200
|
+
expect: "a positive whole number of bytes",
|
|
201
|
+
consequence: `the ${DEFAULT_MAX_INPUT_BYTES}-byte default applies`,
|
|
202
|
+
}),
|
|
203
|
+
),
|
|
204
|
+
_AGENT_SANITIZER_EXTRA_SECRET_VARS: on("internal", text()),
|
|
205
|
+
_AGENT_SANITIZER_TRACE: on(
|
|
206
|
+
"internal",
|
|
207
|
+
knob({
|
|
208
|
+
parse: (raw) => {
|
|
209
|
+
const value = (raw ?? "").toLowerCase();
|
|
210
|
+
const level = Object.entries(TRACE_LEVELS).find(([, spellings]) =>
|
|
211
|
+
spellings.includes(value),
|
|
212
|
+
);
|
|
213
|
+
return level === undefined ? INVALID : Number(level[0]);
|
|
214
|
+
},
|
|
215
|
+
fallback: 0,
|
|
216
|
+
expect: "off/0, info/1/on/true, or debug/2",
|
|
217
|
+
consequence: "tracing stays off",
|
|
218
|
+
}),
|
|
219
|
+
),
|
|
220
|
+
_AGENT_SANITIZER_TRACE_FILE: on("internal", text()),
|
|
221
|
+
_AGENT_SANITIZER_REDACTOR_SOCKET: on("internal", text()),
|
|
222
|
+
_AGENT_SANITIZER_REDACTOR_DAEMON: on("internal", text()),
|
|
223
|
+
_AGENT_SANITIZER_REDACTOR_WAIT_MS: on("internal", milliseconds(8000)),
|
|
224
|
+
_AGENT_SANITIZER_REDACTOR_REQUEST_MS: on("internal", milliseconds(20000)),
|
|
225
|
+
_AGENT_SANITIZER_SANITIZE_BUDGET_MS: on("internal", milliseconds(120000)),
|
|
226
|
+
_AGENT_SANITIZER_REVEAL_DIR: on("internal", text()),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The operator knobs the plugin's shell launcher (plugin/scripts/) reads before
|
|
231
|
+
* any JS runs. They share plugin/README.md's knob tables with the `hooks`
|
|
232
|
+
* surface, so the tables document exactly these plus that surface; JS never
|
|
233
|
+
* reads them (the partition test holds JS reads to KNOBS).
|
|
234
|
+
*/
|
|
235
|
+
export const LAUNCHER_KNOBS = Object.freeze([
|
|
236
|
+
"AGENT_SANITIZER_NODE",
|
|
237
|
+
"AGENT_SANITIZER_HOOK_BINARY",
|
|
238
|
+
"AGENT_SANITIZER_COMPILE_CACHE",
|
|
239
|
+
"AGENT_SANITIZER_REPEAT_DEGRADED_CONTEXT",
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
/** @typedef {typeof KNOBS} Knobs */
|
|
243
|
+
|
|
244
|
+
/** `name=value` pairs already reported, so a per-leaf read reports once. */
|
|
245
|
+
const reported = new Set();
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Write `message` to stderr the first time it is seen in this process.
|
|
249
|
+
* @param {string} message
|
|
250
|
+
* @returns {void}
|
|
251
|
+
*/
|
|
252
|
+
function reportOnce(message) {
|
|
253
|
+
if (reported.has(message)) return;
|
|
254
|
+
reported.add(message);
|
|
255
|
+
process.stderr.write(`${message}\n`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The parsed value of knob `name` in `env`. An unrecognized value is reported
|
|
260
|
+
* through `report` and reads as the knob's fallback; a name missing from
|
|
261
|
+
* {@link KNOBS} throws, since it is a wiring bug in this package rather than
|
|
262
|
+
* operator input.
|
|
263
|
+
* @template {keyof Knobs} K
|
|
264
|
+
* @param {K} name
|
|
265
|
+
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
266
|
+
* @param {(message: string) => void} [report]
|
|
267
|
+
* @returns {Knobs[K]["fallback"]}
|
|
268
|
+
*/
|
|
269
|
+
export function readKnob(name, env = process.env, report = reportOnce) {
|
|
270
|
+
if (!Object.hasOwn(KNOBS, name))
|
|
271
|
+
throw new Error(
|
|
272
|
+
`unknown knob ${JSON.stringify(name)}: declare it in KNOBS ` +
|
|
273
|
+
"(claude-hooks/lib/knobs.mjs) before reading it",
|
|
274
|
+
);
|
|
275
|
+
/** @type {Knob<any>} */
|
|
276
|
+
const spec = KNOBS[name];
|
|
277
|
+
const raw = env[name];
|
|
278
|
+
const value = spec.parse(raw);
|
|
279
|
+
if (value !== INVALID) return value;
|
|
280
|
+
const shown = JSON.stringify(/** @type {string} */ (raw).slice(0, 80));
|
|
281
|
+
report(
|
|
282
|
+
`agent-sanitizer: ${name}=${shown} is not a recognized value ` +
|
|
283
|
+
`(${spec.expect}); ${spec.consequence}.`,
|
|
284
|
+
);
|
|
285
|
+
return spec.fallback;
|
|
286
|
+
}
|