agent-sanitizer 2.67.2 → 2.69.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.
Files changed (33) hide show
  1. package/README.md +8 -6
  2. package/THREAT-MODEL.md +52 -39
  3. package/claude-hooks/lib/env-config.mjs +2 -2
  4. package/claude-hooks/lib/hook-fault.mjs +13 -35
  5. package/claude-hooks/lib/hook-io.mjs +84 -9
  6. package/claude-hooks/lib/hook-timing.mjs +26 -6
  7. package/claude-hooks/lib/invisible-alert.mjs +6 -3
  8. package/claude-hooks/lib/redactor-client.mjs +23 -23
  9. package/claude-hooks/lib/secret-annotate.mjs +36 -66
  10. package/claude-hooks/lib/secret-drop-guard.mjs +136 -29
  11. package/claude-hooks/plugin-hooks.mjs +30 -27
  12. package/claude-hooks/pretooluse-sanitize.mjs +288 -64
  13. package/claude-hooks/sanitize-output.mjs +27 -40
  14. package/claude-hooks/sanitize-user-prompt.mjs +28 -17
  15. package/claude-hooks/scan-invisible-chars.mjs +86 -35
  16. package/claude-hooks/scan-loaded-instructions.mjs +20 -16
  17. package/package.json +1 -1
  18. package/src/html.mjs +17 -17
  19. package/src/output.mjs +20 -19
  20. package/src/rehydrate.mjs +36 -9
  21. package/types/claude-hooks/lib/hook-fault.d.mts +8 -73
  22. package/types/claude-hooks/lib/hook-io.d.mts +32 -6
  23. package/types/claude-hooks/lib/hook-timing.d.mts +9 -4
  24. package/types/claude-hooks/lib/invisible-alert.d.mts +6 -3
  25. package/types/claude-hooks/lib/redactor-client.d.mts +3 -3
  26. package/types/claude-hooks/lib/secret-annotate.d.mts +11 -17
  27. package/types/claude-hooks/lib/secret-drop-guard.d.mts +77 -9
  28. package/types/claude-hooks/pretooluse-sanitize.d.mts +28 -10
  29. package/types/claude-hooks/sanitize-output.d.mts +1 -1
  30. package/types/claude-hooks/sanitize-user-prompt.d.mts +3 -2
  31. package/types/claude-hooks/scan-invisible-chars.d.mts +3 -3
  32. package/types/output.d.mts +4 -5
  33. package/types/rehydrate.d.mts +8 -1
@@ -17,70 +17,25 @@ import charset from "../../python/agent_sanitizer/data/invisible-charset.json" w
17
17
  // Invisible characters an attacker can splice between a value's characters to
18
18
  // break an exact-substring pre-gate while the daemon's redactor still matches
19
19
  // across them. Built from the SAME pinned charset Layer 1 strips and the daemon
20
- // tolerates (`invisible_run_pattern` in agent_sanitizer.secrets.invisible
21
- // builds the same shape from the same set), not a hand-curated subset: a subset
22
- // pre-gate silently under-matches whenever this gate runs on text Layer 1 has
23
- // not already stripped (the module is exported and callable on its own), and a
24
- // value spliced with a code point in the gap then never reaches the daemon at
25
- // all. A run of zero-or-more is allowed at each interior gap, so the plain
26
- // value still matches (a superset of `includes`). The required literals between
27
- // every gap are what bound the match — a `*` gap is never adjacent to another
28
- // gap, so there is no ambiguity to backtrack over and no ReDoS. (The class size
29
- // is irrelevant to that: a character class matches in O(1) whether it holds one
30
- // member or all 435.) That argument holds only while no literal is itself a
31
- // member of the class, so {@link envValueRegex} drops the value's own invisible
32
- // code points before it splices: `a<ZWSP>a` would otherwise build
33
- // `a[…]*<ZWSP>[…]*a`, whose two stars share the literal and backtrack
34
- // quadratically over a long run of ZWSP.
35
- const ENV_INVIS_CODEPOINTS = [
36
- ...new Set([...charset.cf_codepoints, ...charset.extra_codepoints]),
37
- ].sort((a, b) => a - b);
38
- const ENV_INVIS_SET = new Set(ENV_INVIS_CODEPOINTS);
39
- const ENV_INVIS_RUN =
20
+ // tolerates (`_env_value_spans` in agent_sanitizer.secrets.engine), not
21
+ // a hand-curated subset: a subset pre-gate silently under-matches whenever this
22
+ // gate runs on text Layer 1 has not already stripped, and a value spliced with a
23
+ // code point in the gap then never reaches the daemon at all.
24
+ const ENV_INVIS_RE = new RegExp(
40
25
  "[" +
41
- ENV_INVIS_CODEPOINTS.map((cp) => `\\u{${cp.toString(16)}}`).join("") +
42
- "]*";
43
-
44
- /** @param {string} ch */
45
- const escapeLiteral = (ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
26
+ [...new Set([...charset.cf_codepoints, ...charset.extra_codepoints])]
27
+ .map((cp) => `\\u{${cp.toString(16)}}`)
28
+ .join("") +
29
+ "]",
30
+ "gu",
31
+ );
46
32
 
47
33
  /**
48
- * Regex matching `value` tolerating invisible chars spliced between its
49
- * characters (mirrors the engine's env-value regex). Code-point split so
50
- * an astral character is escaped whole, not as two surrogate halves — and the
51
- * `u` flag, which the astral `\u{…}` class members in {@link ENV_INVIS_RUN}
52
- * require.
53
- * Memoized per distinct value: {@link ENV_INVIS_RUN} renders ~435 code points
54
- * as ~4 KB of source and is joined at EVERY interior gap, so a 20-char secret
55
- * compiles a ~75 KB pattern — and `hasEnvBoundSecret` builds one per configured
56
- * var on every PostToolUse output. Env values are stable for the process's
57
- * lifetime, so the cache is bounded by the number of distinct values. Sharing an
58
- * instance is safe because the regex carries no `g`/`y` flag, hence no
59
- * `lastIndex` state to leak between calls.
60
- * @param {string} value
61
- * @returns {RegExp}
34
+ * `s` with every charset invisible removed.
35
+ * @param {string} s
36
+ * @returns {string}
62
37
  */
63
- export function envValueRegex(value) {
64
- let re = ENV_VALUE_REGEX_CACHE.get(value);
65
- if (re === undefined) {
66
- const visible = [...value].filter(
67
- (ch) => !ENV_INVIS_SET.has(/** @type {number} */ (ch.codePointAt(0))),
68
- );
69
- // A value made only of invisibles has no literal to anchor a gap on: match
70
- // it exactly rather than build a pattern that is nothing but gaps.
71
- re = new RegExp(
72
- visible.length > 0
73
- ? visible.map(escapeLiteral).join(ENV_INVIS_RUN)
74
- : [...value].map(escapeLiteral).join(""),
75
- "u",
76
- );
77
- ENV_VALUE_REGEX_CACHE.set(value, re);
78
- }
79
- return re;
80
- }
81
-
82
- /** value → compiled matcher; see {@link envValueRegex}. */
83
- const ENV_VALUE_REGEX_CACHE = new Map();
38
+ const stripInvisibles = (s) => s.replace(ENV_INVIS_RE, "");
84
39
 
85
40
  /**
86
41
  * True when tool output contains the literal value of a configured env-bound
@@ -88,19 +43,34 @@ const ENV_VALUE_REGEX_CACHE = new Map();
88
43
  * credential, so the pre-gate must also fire on the value itself — otherwise
89
44
  * the engine's env-bound redaction never runs. Invisible-tolerant so a
90
45
  * value with spliced Cf chars (which the daemon still redacts) trips it too.
46
+ *
47
+ * Both sides are stripped and compared as substrings, which is exactly
48
+ * equivalent: the value's visible characters appear contiguously in the
49
+ * stripped text iff they appear in the original separated only by invisibles.
50
+ * It is linear and independent of the value's length — a regex with an
51
+ * invisible class at every gap grows ~4 KB per character, and a long secret (a
52
+ * PEM key) overflows V8's regex size limit and throws on every output.
53
+ *
54
+ * Callers pass post-Layer-1 text: terminal escapes are already gone, so a value
55
+ * split by one is contiguous here. Raw text spliced with an escape is not
56
+ * matched.
91
57
  * @param {string} text
92
58
  * @param {NodeJS.ProcessEnv} [env]
93
59
  * @returns {boolean}
94
60
  */
95
61
  export function hasEnvBoundSecret(text, env = process.env) {
96
62
  const minLen = minEnvSecretLen();
63
+ /** @type {string | undefined} */
64
+ let strippedText;
97
65
  return envBoundSecretVars(env).some((name) => {
98
66
  const value = env[name];
99
- // Code-point length to match envValueRegex's code-point split — an astral
100
- // char counts once, not as two UTF-16 units, so minLen means the same thing
101
- // on both sides of the gate.
102
- return (
103
- value && [...value].length >= minLen && envValueRegex(value).test(text)
104
- );
67
+ // Code-point length, so an astral char counts once, not as two UTF-16 units.
68
+ if (!value || [...value].length < minLen) return false;
69
+ const needle = stripInvisibles(value);
70
+ // A value made only of invisibles has no visible character to anchor on:
71
+ // match it exactly rather than as the empty string, which matches anything.
72
+ if (needle === "") return text.includes(value);
73
+ strippedText ??= stripInvisibles(text);
74
+ return strippedText.includes(needle);
105
75
  });
106
76
  }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Clobber-by-omission guard for whole-file Writes.
2
+ * Clobber-by-omission guard for whole-file Writes, and the confirm-by-exact-
3
+ * re-issue sentinel it shares with every other releasable PreToolUse refusal.
3
4
  *
4
5
  * Rehydration (agent-sanitizer/rehydrate) only engages when a Write's content
5
6
  * carries a placeholder; a Write that REGENERATES a secret-bearing file and
@@ -65,12 +66,62 @@ export const CONFIRM_TTL_MS = 10 * 60_000;
65
66
  * @returns {string}
66
67
  */
67
68
  export function dropFingerprint(filePath, content, dropped = []) {
68
- const digest = createHash("sha256").update(filePath).update("\0");
69
+ const digest = createHash("sha256").update("drop\0").update(filePath);
70
+ digest.update("\0");
69
71
  digest.update(content);
70
72
  for (const secret of dropped) digest.update("\0").update(secret);
71
73
  return digest.digest("hex").slice(0, 32);
72
74
  }
73
75
 
76
+ /**
77
+ * Fingerprint of one refusal of one exact tool call — same refusing site, same
78
+ * tool, same input — released by re-issuing that call. Keyed on the site too, so
79
+ * a call refused by two sites in turn needs a confirmation for each and one
80
+ * re-issue never releases a refusal the model was not shown. The input is
81
+ * hashed in canonical key order, so a re-issue whose keys arrive reordered is
82
+ * still the same call. Distinct domain tags keep it disjoint from
83
+ * {@link dropFingerprint}.
84
+ * @param {string} site
85
+ * @param {unknown} tool
86
+ * @param {unknown} toolInput
87
+ * @returns {string}
88
+ */
89
+ export function reissueFingerprint(site, tool, toolInput) {
90
+ return createHash("sha256")
91
+ .update("reissue\0")
92
+ .update(canonicalJson([site, tool ?? null, toolInput ?? null]))
93
+ .digest("hex")
94
+ .slice(0, 32);
95
+ }
96
+
97
+ /**
98
+ * JSON with every object's keys sorted, so two encodings of one value agree.
99
+ * @param {unknown} value
100
+ * @returns {string}
101
+ */
102
+ function canonicalJson(value) {
103
+ return JSON.stringify(value, (_key, v) =>
104
+ v !== null && typeof v === "object" && !Array.isArray(v)
105
+ ? Object.fromEntries(
106
+ Object.keys(v)
107
+ .sort()
108
+ .map((k) => [k, v[k]]),
109
+ )
110
+ : v,
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Record a confirm sentinel for `fingerprint`, so the identical re-issue is
116
+ * approved by {@link consumeConfirm}. False when it could not be recorded: the
117
+ * caller then must not refuse, because nothing could ever release the refusal.
118
+ * @param {string} fingerprint
119
+ * @returns {boolean}
120
+ */
121
+ export function recordConfirm(fingerprint) {
122
+ return writeSentinelFile(confirmMarkerPath(fingerprint));
123
+ }
124
+
74
125
  /**
75
126
  * The confirm sentinel's path for one fingerprint. Predictable and
76
127
  * world-visible like the alert markers, hence the markerIsTrusted read.
@@ -81,6 +132,9 @@ export function confirmMarkerPath(fingerprint) {
81
132
  return join(tmpdir(), `.claude-secret-drop-${PROJECT_HASH}-${fingerprint}`);
82
133
  }
83
134
 
135
+ /** readdir codes meaning the confirm store's directory cannot be used at all. */
136
+ const UNUSABLE_DIR_CODES = new Set(["ENOENT", "ENOTDIR", "EACCES", "EPERM"]);
137
+
84
138
  /**
85
139
  * Delete this project's confirm sentinels older than {@link CONFIRM_TTL_MS}.
86
140
  *
@@ -95,7 +149,18 @@ export function sweepStaleConfirms() {
95
149
  const dir = tmpdir();
96
150
  const prefix = `.claude-secret-drop-${PROJECT_HASH}-`;
97
151
  const cutoff = Date.now() - CONFIRM_TTL_MS;
98
- for (const name of readdirSync(dir)) {
152
+ let names;
153
+ try {
154
+ names = readdirSync(dir);
155
+ } catch (err) {
156
+ // An unusable $TMPDIR holds no sentinel to sweep, and recordConfirm already
157
+ // answers for it on every call; letting it throw here would abort the
158
+ // SessionStart scan this sweep merely rides along with.
159
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
160
+ if (UNUSABLE_DIR_CODES.has(String(code))) return;
161
+ throw err;
162
+ }
163
+ for (const name of names) {
99
164
  if (!name.startsWith(prefix)) continue;
100
165
  const path = join(dir, name);
101
166
  try {
@@ -150,14 +215,34 @@ export function gitTracked(filePath, spawn = spawnSync) {
150
215
  /**
151
216
  * @param {number} count
152
217
  * @param {string} filePath
218
+ * @param {boolean} [confirmable] false when no confirmation could be recorded,
219
+ * so the reason must not offer a re-issue that would only be refused again
153
220
  * @returns {string}
154
221
  */
155
- function dropDeny(count, filePath) {
156
- return (
222
+ function dropDeny(count, filePath, confirmable = true) {
223
+ const head =
157
224
  `this Write removes ${count} redacted secret value(s) from ${filePath}, and the ` +
158
- `file is not tracked by git, so the secrets may be unrecoverable. If removing ` +
159
- `them is intended, re-issue this exact Write to confirm; otherwise keep each ` +
160
- `[REDACTED…] placeholder (or its secret's line) in the new content`
225
+ `file is not tracked by git, so the secrets may be unrecoverable. `;
226
+ const placeholders = `[REDACTED…] placeholder (or its secret's line) in the new content`;
227
+ return confirmable
228
+ ? `${head}If removing them is intended, re-issue this exact Write to confirm; ` +
229
+ `otherwise keep each ${placeholders}`
230
+ : `${head}Keep each ${placeholders}, or ask the user to remove them`;
231
+ }
232
+
233
+ /**
234
+ * The `fault` a drop deny carries when its confirmation could not be recorded:
235
+ * the deny could then never be released by a re-issue, so it is a sanitizer
236
+ * fault rather than a verdict, and the hook's fault posture decides it.
237
+ * @param {number} count
238
+ * @param {string} filePath
239
+ * @returns {string}
240
+ */
241
+ function dropUnconfirmable(count, filePath) {
242
+ return (
243
+ `the secret-drop confirmation could not be recorded under ${tmpdir()}, so ` +
244
+ `this Write removes ${count} redacted secret value(s) from untracked ` +
245
+ `${filePath} without a confirmed re-issue`
161
246
  );
162
247
  }
163
248
 
@@ -167,22 +252,22 @@ function dropDeny(count, filePath) {
167
252
  * rehydration substituted any placeholders — so a preserved secret shows up as
168
253
  * its real value in `content`. `io` is the rehydrate-shaped I/O bag (readFile /
169
254
  * redact / redactMap). Injectable seams: `isTracked` (the git probe),
170
- * `confirmSeen`/`recordConfirm` (the sentinel state).
255
+ * `confirmSeen`/`recordConfirm` (the sentinel state). A deny whose confirmation
256
+ * could not be recorded carries `fault` (see {@link dropUnconfirmable}).
171
257
  * @param {{file_path?: unknown, content?: unknown}} toolInput
172
258
  * @param {import("agent-sanitizer/rehydrate").RehydrateIo} io
173
259
  * @param {{
174
260
  * isTracked?: (filePath: string) => boolean,
175
261
  * confirmSeen?: (fingerprint: string) => boolean,
176
- * recordConfirm?: (fingerprint: string) => void,
262
+ * recordConfirm?: (fingerprint: string) => boolean,
177
263
  * }} [opts]
178
- * @returns {Promise<{deny: string} | null>}
264
+ * @returns {Promise<{deny: string, fault?: string} | null>}
179
265
  */
180
266
  export async function secretDropGuard(toolInput, io, opts = {}) {
181
267
  const {
182
268
  isTracked = gitTracked,
183
269
  confirmSeen = consumeConfirm,
184
- recordConfirm = (fingerprint) =>
185
- writeSentinelFile(confirmMarkerPath(fingerprint)),
270
+ recordConfirm: record = recordConfirm,
186
271
  } = opts;
187
272
  const { file_path: filePath, content } = toolInput ?? {};
188
273
  if (typeof filePath !== "string" || typeof content !== "string") return null;
@@ -225,8 +310,11 @@ export async function secretDropGuard(toolInput, io, opts = {}) {
225
310
  // context, or a marker left by an abandoned attempt becomes a standing
226
311
  // auto-approval for a Write the user never saw.
227
312
  if (confirmSeen(fingerprint)) return null;
228
- recordConfirm(fingerprint);
229
- return { deny: dropDeny(dropped.length, filePath) };
313
+ if (record(fingerprint)) return { deny: dropDeny(dropped.length, filePath) };
314
+ return {
315
+ deny: dropDeny(dropped.length, filePath, false),
316
+ fault: dropUnconfirmable(dropped.length, filePath),
317
+ };
230
318
  }
231
319
 
232
320
  /**
@@ -235,8 +323,12 @@ export async function secretDropGuard(toolInput, io, opts = {}) {
235
323
  * substitution — so a rehydrated secret counts as preserved and only a
236
324
  * genuinely dropped one can trip it. A rehydrate deny short-circuits (the
237
325
  * guard never runs), non-Write tools skip the guard entirely, and a guard
238
- * deny wins over a pass/rewrite result.
239
- * @typedef {{updatedInput: any, context: string} | {deny: string} | null} RehydrateResult
326
+ * deny wins over a pass/rewrite result. A guard FAULT deny carries the
327
+ * rewrite as `release`: the open posture lets that call proceed, and it must
328
+ * proceed rehydrated, or every secret the rewrite restored is overwritten by
329
+ * its placeholder.
330
+ * @typedef {{updatedInput: any, context: string}} RehydrateRewrite
331
+ * @typedef {RehydrateRewrite | {deny: string, fault?: string, release?: RehydrateRewrite} | null} RehydrateResult
240
332
  * @param {(tool: string, toolInput: any) => Promise<RehydrateResult>} rehydrate
241
333
  * @param {import("agent-sanitizer/rehydrate").RehydrateIo} io
242
334
  * @param {typeof secretDropGuard} [guard]
@@ -250,33 +342,29 @@ export function withSecretDropGuard(rehydrate, io, guard = secretDropGuard) {
250
342
  const finalInput =
251
343
  rehydrated === null ? toolInput : rehydrated.updatedInput;
252
344
  const drop = await guard(finalInput, io);
253
- return drop ?? rehydrated;
345
+ if (drop === null) return rehydrated;
346
+ if (drop.fault === undefined || rehydrated === null) return drop;
347
+ return { ...drop, release: rehydrated };
254
348
  };
255
349
  }
256
350
 
257
351
  /**
258
352
  * True when a trusted, fresh confirm sentinel exists for `fingerprint`,
259
- * consuming it so one confirmation approves exactly one Write. A sentinel
353
+ * consuming it so one confirmation approves exactly one call. A sentinel
260
354
  * older than {@link CONFIRM_TTL_MS} is stale — an abandoned deny from a
261
355
  * previous session, not a live confirmation — so it is removed and NOT
262
356
  * honored, preventing a leftover marker from acting as a standing
263
357
  * auto-approval. Removal is best-effort: a fresh marker we could not unlink
264
358
  * still counted as consumed for THIS call, and a leftover empty sentinel only
265
- * ever re-approves the identical (path, bytes, drop-set) Write the model
266
- * already confirmed — and only within the TTL.
359
+ * ever re-approves the identical fingerprinted call the model already
360
+ * confirmed — and only within the TTL.
267
361
  * @param {string} fingerprint
268
362
  * @returns {boolean}
269
363
  */
270
- function consumeConfirm(fingerprint) {
364
+ export function consumeConfirm(fingerprint) {
271
365
  const marker = confirmMarkerPath(fingerprint);
272
366
  if (!markerIsTrusted(marker)) return false;
273
- let fresh;
274
- try {
275
- fresh = Date.now() - lstatSync(marker).mtimeMs <= CONFIRM_TTL_MS;
276
- } catch {
277
- // Raced away between the trust check and the stat: no sentinel, no confirm.
278
- return false;
279
- }
367
+ const fresh = confirmFresh(fingerprint);
280
368
  try {
281
369
  unlinkSync(marker);
282
370
  } catch {
@@ -284,3 +372,22 @@ function consumeConfirm(fingerprint) {
284
372
  }
285
373
  return fresh;
286
374
  }
375
+
376
+ /**
377
+ * True when a trusted confirm sentinel younger than {@link CONFIRM_TTL_MS}
378
+ * exists for `fingerprint`, WITHOUT consuming it — for a caller that learns
379
+ * only later whether the call it confirms actually proceeds, and consumes it
380
+ * then.
381
+ * @param {string} fingerprint
382
+ * @returns {boolean}
383
+ */
384
+ export function confirmFresh(fingerprint) {
385
+ const marker = confirmMarkerPath(fingerprint);
386
+ if (!markerIsTrusted(marker)) return false;
387
+ try {
388
+ return Date.now() - lstatSync(marker).mtimeMs <= CONFIRM_TTL_MS;
389
+ } catch {
390
+ // Raced away between the trust check and the stat: no sentinel, no confirm.
391
+ return false;
392
+ }
393
+ }
@@ -26,7 +26,6 @@ import {
26
26
  } from "./lib/hook-io.mjs";
27
27
  import {
28
28
  registerFaultPolicy,
29
- KNOB_DECIDES,
30
29
  hookFaultOutcome,
31
30
  writeFaultOutcome,
32
31
  } from "./lib/hook-fault.mjs";
@@ -35,29 +34,31 @@ const HOOK_NAME = "plugin-hooks";
35
34
 
36
35
  // The dispatcher's entry in the one posture table (lib/hook-fault.mjs). It
37
36
  // answers no single event — it is the binder in front of all of them — so it
38
- // carries no stdout envelope and both arms are process-level.
37
+ // carries no stdout envelope and both arms are process-level. Its one fault is
38
+ // an unknown `--hook` mode: broken hooks.json wiring, so no hook runs for that
39
+ // event until the install is fixed.
39
40
  //
40
- // BOTH ARMS BLOCK, and that is the declaration, not an oversight. The
41
- // AGENT_SANITIZER_FAIL_OPEN knob covers a hook that RAN and broke; an unknown
42
- // mode is static wiring corruption, which means no hook runs at all, silently,
43
- // for the life of the install — there is no run to degrade. Stating it here (and
44
- // pinning it in plugin/test/plugin-bundle.test.mjs) is the point of the table:
45
- // the arm that ignores the knob does so on the record, next to the ones that
46
- // honor it, instead of by hard-exiting past the question.
47
- //
48
- // Exit 2 is the one non-zero code Claude Code treats as BLOCKING: it blocks
49
- // PreToolUse and UserPromptSubmit, surfaces stderr for PostToolUse, and is
50
- // harmless for SessionStart.
51
- const unknownMode = (/** @type {{ message: string }} */ ctx) => ({
52
- stderr: `${HOOK_NAME}: ${ctx.message}\n`,
53
- exitCode: 2,
54
- });
41
+ // OPEN exits 1, which Claude Code treats as a non-blocking hook error; through
42
+ // plugin/scripts/safe-launch.sh it becomes that event's fail-open warning.
43
+ // CLOSED exits 2, the one non-zero code Claude Code treats as BLOCKING: it
44
+ // blocks PreToolUse and UserPromptSubmit, surfaces stderr for PostToolUse, and
45
+ // is harmless for SessionStart.
46
+ const unknownModeLine = (/** @type {{ message: string }} */ ctx) =>
47
+ `${HOOK_NAME}: ${ctx.message} — hooks.json names a hook this bundle does ` +
48
+ `not dispatch (known: ${HOOK_MODES.join(", ")}); reinstall or update the plugin.`;
55
49
  registerFaultPolicy(HOOK_NAME, {
56
50
  event: null,
57
51
  guarded: "hook payload",
58
- alwaysClosed: KNOB_DECIDES,
59
- open: unknownMode,
60
- closed: unknownMode,
52
+ open: (ctx) => ({
53
+ stderr:
54
+ `${unknownModeLine(ctx)} This event is UNGUARDED; set ` +
55
+ "AGENT_SANITIZER_FAIL_OPEN=0 to block it instead.\n",
56
+ exitCode: 1,
57
+ }),
58
+ closed: (ctx) => ({
59
+ stderr: `${unknownModeLine(ctx)} Blocking (AGENT_SANITIZER_FAIL_OPEN=0).\n`,
60
+ exitCode: 2,
61
+ }),
61
62
  });
62
63
 
63
64
  // The packages the hooks lazy-load, each behind a thunk whose import specifier
@@ -90,7 +91,7 @@ const LAZY_LOADERS = {
90
91
  * as a non-blocking hook error and shows the raw tool output. That is a
91
92
  * fail-OPEN on the very hook that withholds secrets. Registering what resolves
92
93
  * and leaving the rest absent hands the gap to each hook's own lazyImport
93
- * guard, whose posture is to block.
94
+ * guard, which answers it loudly under the operator's fault posture.
94
95
  * @returns {Promise<void>}
95
96
  */
96
97
  async function registerAvailableModules() {
@@ -201,14 +202,16 @@ export async function main() {
201
202
  // some default hook and vet the wrong payload class. WHICH way it fails is
202
203
  // the operator's call, taken through the one posture table like every other
203
204
  // hook fault.
204
- process.exit(
205
- writeFaultOutcome(
206
- hookFaultOutcome(
207
- HOOK_NAME,
208
- new Error(`unknown hook mode ${JSON.stringify(mode)}`),
209
- ),
205
+ process.exitCode = writeFaultOutcome(
206
+ hookFaultOutcome(
207
+ HOOK_NAME,
208
+ new Error(`unknown hook mode ${JSON.stringify(mode)}`),
210
209
  ),
211
210
  );
211
+ // Drained like the disabled arm below: under the open posture the event
212
+ // proceeds, and a harness left writing into a closed pipe sees EPIPE.
213
+ process.stdin.resume();
214
+ return;
212
215
  }
213
216
 
214
217
  // An empty envelope is a verdict, not a crash: stdout stays non-empty, so the