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.
@@ -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
+ }