agent-sanitizer 2.58.10 → 2.58.11

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
+ }
@@ -24,35 +24,24 @@ import {
24
24
  } from "./hook-timing.mjs";
25
25
  import { existsSync, lstatSync } from "node:fs";
26
26
  import { createConnection } from "node:net";
27
- import { tmpdir, userInfo } from "node:os";
27
+ import { tmpdir } from "node:os";
28
28
  import { dirname, join } from "node:path";
29
29
  import { fileURLToPath } from "node:url";
30
30
  import { envBoundSecretVars } from "./env-config.mjs";
31
+ import { currentUid, isPrivateDir, ownedByCurrentUser } from "./hook-io.mjs";
32
+ import { readKnob } from "./knobs.mjs";
31
33
 
32
34
  // Refuse absurd frames rather than buffer unbounded (mirrors the daemon's cap).
33
35
  export const FRAME_CAP = 16 * 1024 * 1024;
34
36
 
35
- /**
36
- * Parse a millisecond deadline from an env override, falling back to `fallback`
37
- * unless the value is a finite positive number. A bare `Number(env) || fallback`
38
- * silently accepts a NEGATIVE override (`-5 || 8000` is -5) — a non-positive
39
- * deadline makes the fail-closed wait/request return immediately, defeating the
40
- * deadline. Unset/blank/NaN/<=0 all take the sane positive fallback; a load-time
41
- * throw is deliberately avoided so a misconfigured env can never crash these
42
- * fail-closed hooks into a fail-OPEN non-load.
43
- * @param {string|undefined} raw the env override value
44
- * @param {number} fallback the sane positive default
45
- * @returns {number}
46
- */
47
- export function positiveMsOr(raw, fallback) {
48
- const ms = Number(raw);
49
- return Number.isFinite(ms) && ms > 0 ? ms : fallback;
50
- }
37
+ // Published here, where hosts already import it; the parser behind every
38
+ // millisecond knob lives with the knob table.
39
+ export { positiveMsOr } from "./knobs.mjs";
51
40
 
52
41
  // Per-session private socket. The daemon binds it 0600 under a 0700 dir AFTER
53
42
  // priming, so its mere existence means "ready". Overridable for tests.
54
43
  export const DEFAULT_SOCKET_PATH =
55
- process.env._AGENT_SANITIZER_REDACTOR_SOCKET ||
44
+ readKnob("_AGENT_SANITIZER_REDACTOR_SOCKET") ??
56
45
  join(tmpdir(), "agent-sanitizer-redactor", "redactor.sock");
57
46
 
58
47
  /**
@@ -71,7 +60,7 @@ export const DEFAULT_SOCKET_PATH =
71
60
  * @returns {string[]}
72
61
  */
73
62
  function daemonCommand() {
74
- const configured = process.env._AGENT_SANITIZER_REDACTOR_DAEMON;
63
+ const configured = readKnob("_AGENT_SANITIZER_REDACTOR_DAEMON");
75
64
  if (configured) return [configured];
76
65
  // Inside the built bundle import.meta.url is .../plugin/dist/hooks/<bundle>,
77
66
  // so the committed zipapp sits one level up; from the raw sources no such
@@ -83,10 +72,7 @@ function daemonCommand() {
83
72
  // How long to wait for a freshly-spawned daemon to start accepting. A cold start
84
73
  // pays the detect-secrets import + plugin prime (~1-3s), so the default leaves
85
74
  // margin; tests shorten it to exercise the give-up-and-fail-closed path quickly.
86
- const WAIT_DEADLINE_MS = positiveMsOr(
87
- process.env._AGENT_SANITIZER_REDACTOR_WAIT_MS,
88
- 8000,
89
- );
75
+ const WAIT_DEADLINE_MS = readKnob("_AGENT_SANITIZER_REDACTOR_WAIT_MS");
90
76
 
91
77
  // A daemon that ACCEPTS the connection but then stalls — a deadlock, a
92
78
  // pathological detect-secrets input, or a half-written length prefix that never
@@ -100,7 +86,7 @@ const WAIT_DEADLINE_MS = positiveMsOr(
100
86
  // timeout (two connects + the spawn wait must fit); tests shorten it via the env
101
87
  // override (read per call) or the deadlineMs parameter to exercise the path fast.
102
88
  function requestDeadlineMs() {
103
- return positiveMsOr(process.env._AGENT_SANITIZER_REDACTOR_REQUEST_MS, 20000);
89
+ return readKnob("_AGENT_SANITIZER_REDACTOR_REQUEST_MS");
104
90
  }
105
91
 
106
92
  /**
@@ -196,18 +182,18 @@ function failClosed(cause) {
196
182
  * `lstat`/`uid` are injectable seams so a test can drive a stat shape the test
197
183
  * process cannot create (a dir owned by another uid); production binds the real ones.
198
184
  * @param {string} socketPath
199
- * @param {{lstat?: typeof lstatSync, uid?: number}} [deps]
185
+ * @param {{lstat?: typeof lstatSync, uid?: number | null}} [deps]
200
186
  * @returns {"absent" | "untrusted" | "ok"}
201
187
  */
202
188
  export function classifySocket(socketPath, deps = {}) {
203
- const { lstat = lstatSync, uid = userInfo().uid } = deps;
189
+ const { lstat = lstatSync, uid = currentUid() } = deps;
204
190
  let st;
205
191
  try {
206
192
  st = lstat(socketPath);
207
193
  } catch {
208
194
  return "absent";
209
195
  }
210
- if (!st.isSocket() || st.uid !== uid) return "untrusted";
196
+ if (!st.isSocket() || !ownedByCurrentUser(st, uid)) return "untrusted";
211
197
  let dir;
212
198
  try {
213
199
  dir = lstat(dirname(socketPath));
@@ -241,15 +227,11 @@ export function classifySocket(socketPath, deps = {}) {
241
227
  * root to hand it to: there the hook spawns the daemon as the ordinary user, which
242
228
  * binds under a 0700 dir it owns, and our own uid is not the adversary.
243
229
  * @param {import("node:fs").Stats} dir
244
- * @param {number} uid
230
+ * @param {number | null} uid
245
231
  * @returns {boolean}
246
232
  */
247
233
  function isTrustedSocketDir(dir, uid) {
248
- return (
249
- dir.isDirectory() &&
250
- (dir.uid === uid || dir.uid === 0) &&
251
- (dir.mode & 0o022) === 0
252
- );
234
+ return isPrivateDir(dir, { uid, rootMayOwn: true });
253
235
  }
254
236
 
255
237
  /**
@@ -31,9 +31,10 @@ import {
31
31
  closeSync,
32
32
  constants,
33
33
  } from "node:fs";
34
- import { tmpdir, userInfo } from "node:os";
34
+ import { tmpdir } from "node:os";
35
35
  import { join, resolve, sep } from "node:path";
36
- import { PROJECT_HASH, writeFileNoFollow } from "./hook-io.mjs";
36
+ import { PROJECT_HASH, isPrivateDir, writeFileNoFollow } from "./hook-io.mjs";
37
+ import { readKnob } from "./knobs.mjs";
37
38
 
38
39
  /**
39
40
  * Where reveal sidecars are stored. Exported so the PreToolUse placeholder
@@ -48,7 +49,7 @@ export function revealDir() {
48
49
  // instead. The 12-hex span KEY inside the directory is untouched — it is the
49
50
  // published placeholder grammar, not a path detail.
50
51
  return (
51
- process.env._AGENT_SANITIZER_REVEAL_DIR ||
52
+ readKnob("_AGENT_SANITIZER_REVEAL_DIR") ??
52
53
  join(tmpdir(), `agent-sanitizer-layer2-reveal-${PROJECT_HASH}`)
53
54
  );
54
55
  }
@@ -131,13 +132,7 @@ function revealDirIsSafe(dir) {
131
132
  return false;
132
133
  }
133
134
  /* c8 ignore stop */
134
- const groupOrOtherWritable = (st.mode & 0o022) !== 0;
135
- return (
136
- st.isDirectory() &&
137
- !st.isSymbolicLink() &&
138
- st.uid === userInfo().uid &&
139
- !groupOrOtherWritable
140
- );
135
+ return isPrivateDir(st);
141
136
  }
142
137
 
143
138
  /**
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { appendFileSync } from "node:fs";
22
22
  import { chargeHostExtensionSync } from "./hook-timing.mjs";
23
+ import { readKnob } from "./knobs.mjs";
23
24
 
24
25
  /**
25
26
  * The sink shape a hook emits through: the event name, its metadata fields, and
@@ -42,16 +43,44 @@ export const TraceEvent = Object.freeze({
42
43
  const LEVELS = Object.freeze({ off: 0, info: 1, debug: 2 });
43
44
 
44
45
  /**
45
- * Numeric verbosity from _AGENT_SANITIZER_TRACE: 0 off, 1 info, 2 debug.
46
- * Unknown, empty, or "off" → 0.
47
- * @param {NodeJS.ProcessEnv} [env]
46
+ * Numeric verbosity from _AGENT_SANITIZER_TRACE: 0 off, 1 info, 2 debug. Empty
47
+ * or "off" → 0; an unrecognized value is reported and reads as 0.
48
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
48
49
  * @returns {number}
49
50
  */
50
51
  export function traceThreshold(env = process.env) {
51
- const value = (env._AGENT_SANITIZER_TRACE ?? "").toLowerCase();
52
- if (value === "debug" || value === "2") return LEVELS.debug;
53
- if (["info", "1", "true", "on"].includes(value)) return LEVELS.info;
54
- return LEVELS.off;
52
+ return readKnob("_AGENT_SANITIZER_TRACE", env);
53
+ }
54
+
55
+ /** The keys every trace line's envelope owns; a field cannot set them. */
56
+ export const TRACE_RESERVED_KEYS = Object.freeze(["ts", "level", "event"]);
57
+
58
+ /**
59
+ * THE trace line's record: the envelope ({@link TRACE_RESERVED_KEYS}) first,
60
+ * then `fields`. A field named like an envelope key cannot overwrite it — a
61
+ * reader buckets on `level` and `event`, so a producer field `level: "debug"`
62
+ * would misfile the line — and is not dropped silently either: its name lands in
63
+ * `shadowed`, so the producer bug is visible on the channel it broke.
64
+ * @param {string} event
65
+ * @param {Record<string, unknown>} fields
66
+ * @param {"info"|"debug"} level
67
+ * @param {number} ts
68
+ * @returns {Record<string, unknown>}
69
+ */
70
+ export function traceRecord(event, fields, level, ts) {
71
+ const shadowed = TRACE_RESERVED_KEYS.filter((key) =>
72
+ Object.hasOwn(fields, key),
73
+ );
74
+ const rest = Object.fromEntries(
75
+ Object.entries(fields).filter(([key]) => !shadowed.includes(key)),
76
+ );
77
+ return {
78
+ ts,
79
+ level,
80
+ event,
81
+ ...rest,
82
+ ...(shadowed.length > 0 ? { shadowed } : {}),
83
+ };
55
84
  }
56
85
 
57
86
  /**
@@ -69,8 +98,8 @@ export function trace(event, fields = {}, level = "info") {
69
98
  const lvl = level === "debug" ? "debug" : "info";
70
99
  if (traceThreshold() < LEVELS[lvl]) return;
71
100
  const line =
72
- JSON.stringify({ ts: Date.now(), level: lvl, event, ...fields }) + "\n";
73
- const file = process.env._AGENT_SANITIZER_TRACE_FILE;
101
+ JSON.stringify(traceRecord(event, fields, lvl, Date.now())) + "\n";
102
+ const file = readKnob("_AGENT_SANITIZER_TRACE_FILE");
74
103
  try {
75
104
  if (file) appendFileSync(file, line);
76
105
  else process.stderr.write(line);
@@ -26,6 +26,7 @@ import {
26
26
  } from "./lib/hook-io.mjs";
27
27
  import {
28
28
  registerFaultPolicy,
29
+ KNOB_DECIDES,
29
30
  hookFaultOutcome,
30
31
  writeFaultOutcome,
31
32
  } from "./lib/hook-fault.mjs";
@@ -54,6 +55,7 @@ const unknownMode = (/** @type {{ message: string }} */ ctx) => ({
54
55
  registerFaultPolicy(HOOK_NAME, {
55
56
  event: null,
56
57
  guarded: "hook payload",
58
+ alwaysClosed: KNOB_DECIDES,
57
59
  open: unknownMode,
58
60
  closed: unknownMode,
59
61
  });
@@ -45,6 +45,7 @@ import {
45
45
  } from "./lib/hook-io.mjs";
46
46
  import {
47
47
  registerFaultPolicy,
48
+ KNOB_DECIDES,
48
49
  hookFaultOutcome,
49
50
  defaultOpen,
50
51
  } from "./lib/hook-fault.mjs";
@@ -65,6 +66,7 @@ import {
65
66
  } from "./lib/authored-content.mjs";
66
67
  import { redactViaDaemon } from "./lib/redactor-client.mjs";
67
68
  import { secretsEnabled } from "./lib/env-config.mjs";
69
+ import { readKnob } from "./lib/knobs.mjs";
68
70
  import { withSecretDropGuard } from "./lib/secret-drop-guard.mjs";
69
71
  import {
70
72
  placeholderNotice,
@@ -420,7 +422,7 @@ export function preToolUseLayers(rehydrate, env = process.env) {
420
422
  run: (tool, toolInput) => rehydrateLayer2(tool, toolInput),
421
423
  },
422
424
  ];
423
- return env.AGENT_SANITIZER_OUTPUT_DISABLED === "1"
425
+ return readKnob("AGENT_SANITIZER_OUTPUT_DISABLED", env)
424
426
  ? layers.filter((layer) => layer.name !== "authored-content")
425
427
  : layers;
426
428
  }
@@ -848,6 +850,7 @@ export function hookFailureFields(parsedOk, err, opts = {}) {
848
850
  registerFaultPolicy(HOOK_NAME, {
849
851
  event: HookEvent.PRE_TOOL_USE,
850
852
  guarded: "tool input",
853
+ alwaysClosed: KNOB_DECIDES,
851
854
  open: (ctx) => {
852
855
  // Non-carve-out faults take the SHARED open rendering, not a copy of it —
853
856
  // hook-fault.mjs owns that body, and a restated one would silently drift.
@@ -24,7 +24,8 @@
24
24
  * Layer 3 and the strict secret mode run on all MCP connector output (see
25
25
  * isUntrustedIngress).
26
26
  */
27
- import { redactViaDaemon, positiveMsOr } from "./lib/redactor-client.mjs";
27
+ import { redactViaDaemon } from "./lib/redactor-client.mjs";
28
+ import { readKnob } from "./lib/knobs.mjs";
28
29
  import {
29
30
  chargeHostExtension,
30
31
  chargeHostExtensionSync,
@@ -49,7 +50,11 @@ import { registerFaultPolicy, hookFaultOutcome } from "./lib/hook-fault.mjs";
49
50
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
50
51
  import { hookTrace, TraceEvent } from "./lib/trace.mjs";
51
52
  import { hasEnvBoundSecret } from "./lib/secret-annotate.mjs";
52
- import { digestFlaggingEnabled, secretsEnabled } from "./lib/env-config.mjs";
53
+ import {
54
+ digestFlaggingEnabled,
55
+ secretsEnabled,
56
+ SECRETS_ENABLED_ENV,
57
+ } from "./lib/env-config.mjs";
53
58
  import {
54
59
  persistReveal,
55
60
  persistSpan,
@@ -118,10 +123,7 @@ export const REVEAL_WITHHELD_WARNING = withheldWarning("reveal sidecar");
118
123
  // would be shown (fail OPEN). The default sits comfortably above one legitimately
119
124
  // slow leaf (a cold redactor respawn + a full scan) yet far under the hook
120
125
  // timeout; env-tunable so tests can drive the exhausted-budget path fast.
121
- const SANITIZE_BUDGET_MS = positiveMsOr(
122
- process.env._AGENT_SANITIZER_SANITIZE_BUDGET_MS,
123
- 120000,
124
- );
126
+ const SANITIZE_BUDGET_MS = readKnob("_AGENT_SANITIZER_SANITIZE_BUDGET_MS");
125
127
 
126
128
  // Web-ingress tools always get the Layer 2 HTML rewrite; local tools — Read,
127
129
  // Bash, Grep, gh — never do. A local HTML/markdown pass either rewrites bytes the
@@ -276,11 +278,11 @@ export async function sanitizeText(
276
278
  // Layer 4 — OPT-IN (secretsEnabled): with the knob unset the seam gets no
277
279
  // redact callback at all, so plain output never spawns the daemon and no
278
280
  // placeholder ever enters the model's view. When it runs, the seam rethrows
279
- // a redactor throw wrapped, and the CLI applies the caller's posture to it.
280
- // Surface the failure to the operator's terminal here first: whatever the
281
- // CLI decides rides in additionalContext, which only the model sees, so a
282
- // degraded redactor would otherwise be invisible to the human — and under
283
- // the fail-open default this line is the ONLY signal the human gets.
281
+ // a redactor throw wrapped, and this hook's fault policy suppresses the
282
+ // output under either posture (alwaysClosed). Surface the failure to the
283
+ // operator's terminal here first: the suppression note rides in what only
284
+ // the model sees, so a degraded redactor would otherwise be invisible to
285
+ // the human.
284
286
  redact: !secretsEnabled()
285
287
  ? undefined
286
288
  : async (/** @type {string} */ content) => {
@@ -783,15 +785,14 @@ function failClosedParts(
783
785
  * fail-OPEN by default — a warning context and NO `updatedToolOutput`, leaving
784
786
  * the original tool output in the model's view — or the fail-closed
785
787
  * suppression of {@link emitFailClosed} when the caller set
786
- * AGENT_SANITIZER_FAIL_OPEN=0.
788
+ * AGENT_SANITIZER_FAIL_OPEN=0, or opted into Layer 4 (see the policy below).
787
789
  *
788
790
  * This is the hook where the two postures diverge the most, so state the open
789
791
  * one plainly: several of these layers throw on inputs an attacker composes (a
790
- * nesting depth that overflows the walk, a redaction budget spent on a thousand
791
- * secret-shaped leaves), and each of those throws is
792
- * guarding content the open posture hands to the model verbatim, secrets
793
- * included. An operator who cares more about withholding a secret than about
794
- * keeping the session moving sets the knob to `0`.
792
+ * nesting depth that overflows the walk), and each of those throws is guarding
793
+ * content the open posture hands to the model verbatim. An operator who cares
794
+ * more about withholding that than about keeping the session moving sets the
795
+ * knob to `0`.
795
796
  * @param {any} input parsed hook input, or undefined if parsing threw
796
797
  * @param {unknown} err
797
798
  * @param {(fields: Record<string, unknown>) => void} [emit]
@@ -830,14 +831,29 @@ function suppressionMessage(cause) {
830
831
  return `[SANITIZATION FAILED — original output suppressed for safety. Hook error: ${cause}]`;
831
832
  }
832
833
 
834
+ /** Why an open knob still suppressed: the secret layer's own guarantee. */
835
+ const SECRETS_PIN_NOTE =
836
+ ` Secret redaction is on (${SECRETS_ENABLED_ENV}=1), so output it could ` +
837
+ "not vet is withheld under either AGENT_SANITIZER_FAIL_OPEN posture.";
838
+
833
839
  // This hook's entry in the one posture table (lib/hook-fault.mjs). OPEN is the
834
840
  // shared default (a warning context, the original output left in the model's
835
841
  // view); CLOSED replaces every string leaf of the output with the placeholder.
842
+ // With Layer 4 opted in, a fault here means the output was never vetted for
843
+ // secrets — a dead daemon, a spent budget, a walk that overflowed before the
844
+ // redactor ran — so this pin is what keeps the open default from handing an
845
+ // unredacted credential to the model.
836
846
  registerFaultPolicy(HOOK_NAME, {
837
847
  event: HookEvent.POST_TOOL_USE,
838
848
  guarded: "tool output",
849
+ alwaysClosed: secretsEnabled,
839
850
  closed: (ctx) =>
840
- failClosedParts(ctx.input, suppressionMessage(ctx.message), ctx.remedy),
851
+ failClosedParts(
852
+ ctx.input,
853
+ suppressionMessage(ctx.message) +
854
+ (ctx.pinnedClosed ? SECRETS_PIN_NOTE : ""),
855
+ ctx.remedy,
856
+ ),
841
857
  });
842
858
 
843
859
  /**
@@ -1137,9 +1153,9 @@ export async function cliMain(ext = {}) {
1137
1153
  // back the parsed `input` even when the control-plane load failed, so the
1138
1154
  // suppression shape-matches the real tool_response). emitFailClosed itself
1139
1155
  // falls back to a bare string if that shape-matching replacement or its
1140
- // serialization throws, so even a pathological input fails closed. A caller
1141
- // that set AGENT_SANITIZER_FAIL_OPEN=1 gets the warning-only pass-through
1142
- // instead — see emitHookFailure.
1156
+ // serialization throws, so even a pathological input fails closed. Under
1157
+ // the open default with Layer 4 off the caller gets the warning-only
1158
+ // pass-through instead — see emitHookFailure.
1143
1159
  onError: (err, input) =>
1144
1160
  emitHookFailure(input, err, undefined, ext.remedy),
1145
1161
  },