agent-sanitizer 2.68.0 → 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.
package/README.md CHANGED
@@ -138,9 +138,10 @@ composed against the redacted view are re-anchored onto the real bytes, and
138
138
  placeholders in new content resolve back to the real secrets — disk → tool
139
139
  input only, never into the model's view. Anything ambiguous is denied rather
140
140
  than guessed, the redactor's own map is verified against the file before any
141
- splice, and a write that would persist placeholder text over a real secret
142
- asks instead of passing through even when the hook's own machinery fails
143
- mid-session. The whole layer is opt-in — its denies and asks are friction, so it
141
+ splice, and a write that would persist placeholder text over a real secret is
142
+ refused once, even when the hook's own machinery fails mid-session, and proceeds
143
+ with a warning when re-issued. The whole layer is opt-in — its denies and asks
144
+ are friction, so it
144
145
  engages only when asked for: set `AGENT_SANITIZER_SECRETS_ENABLED=1` in the
145
146
  environment Claude Code runs the hooks with; unset, no redactor runs and no
146
147
  placeholders exist.
@@ -193,9 +194,10 @@ withholds a string-shaped response; the shell arm in `plugin/scripts/safe-launch
193
194
  therefore also emits a top-level `decision`/`reason` pair, honored whatever
194
195
  shape the response had, telling the model that any output still visible above
195
196
  it is unsanitized. One carve-out to the
196
- open default, with secrets enabled: a write-shaped call carrying `[REDACTED…]` placeholder text asks
197
- instead of passing through when the hook itself is broken, since letting it
198
- through would overwrite the real secret with the placeholder. Either way, a plugin that
197
+ open default, with secrets enabled: a write-shaped call carrying `[REDACTED…]`
198
+ placeholder text is refused once when the hook itself is broken, since letting
199
+ it through would overwrite the real secret with the placeholder; the exact
200
+ re-issue proceeds with a warning. Either way, a plugin that
199
201
  never loaded at all is invisible — Claude Code reads a crashed hook as "no
200
202
  objection" — so confirm with `/plugin` rather than reading a quiet session as a
201
203
  working one. Neither posture touches what a sanitizer that RAN decided (see
package/THREAT-MODEL.md CHANGED
@@ -960,20 +960,28 @@ parsed, and a layer that ran and threw.
960
960
  One carve-out: when the PreToolUse hook itself fails (redactor daemon down,
961
961
  package failed to load, a layer threw) and the call is a **write-shaped tool**
962
962
  (Write/Edit/MultiEdit/NotebookEdit) whose input carries the `[REDACTED`
963
- placeholder prefix, the hook **asks** — fail-closed, human in the loop — instead
964
- of passing through. With the sanitizer down, rehydration cannot run, so the
965
- placeholder text would be persisted literally over the real secret on disk: a
966
- destructive clobber, not a missed scan. Holding these calls is safe because a
967
- placeholder-bearing write is never the benign availability case the open default
968
- protects — the model can retry once the sanitizer recovers, or ask the user. The
969
- check is package-free (a literal-string test on the already-parsed payload), so
963
+ placeholder prefix, the hook **refuses it once** instead of passing it through.
964
+ With the sanitizer down, rehydration cannot run, so the placeholder text would be
965
+ persisted literally over the real secret on disk: a destructive clobber, not a
966
+ missed scan. The refusal is releasable, never a loop: the exact same call
967
+ re-issued is let through with a warning that the placeholder was written
968
+ literally, and when the hook cannot record that confirmation (an unwritable
969
+ `$TMPDIR`) it lets the call through at once with the warning. The same rule
970
+ covers a write whose placeholder cannot be restored — a Layer-2 span missing
971
+ from the store, an unreadable target, a redactor map that fails validation.
972
+ Releasing an unhinted Edit on a file whose redactor map fails validation reopens
973
+ the hidden-span probing oracle the rehydrator closes, at the price of one refusal
974
+ per distinct probe, for as long as the redactor stays broken. An unhinted Write
975
+ there skips the secret-drop guard, which reads the same broken map, so its
976
+ refusal and warning say instead that any redacted secret it omits is removed. The check is
977
+ package-free (a literal-string test on the already-parsed payload), so
970
978
  it holds even when the failure IS the missing package. Two accepted gaps: a
971
979
  launcher-level failure (no `node`, corrupt bundle, a provisioned hook binary
972
980
  that is missing, unexecutable or dies without a verdict) never reaches the
973
981
  check — the launcher cannot inspect the payload and always warns — and `Bash`
974
982
  is excluded even though shell redirection can also persist placeholder text,
975
- because command strings mention `[REDACTED` benignly far too often for the ask
976
- to hold precision. All other faults keep the open default, and
983
+ because command strings mention `[REDACTED` benignly far too often for the
984
+ refusal to hold precision. All other faults keep the open default, and
977
985
  `AGENT_SANITIZER_FAIL_OPEN=0` behavior is unchanged.
978
986
 
979
987
  **The open default is not enforceable against content.** Several of those
@@ -1005,13 +1013,13 @@ in `claude-hooks/sanitize-output.mjs` — so a host that wires those directly (a
1005
1013
  `test/downstream-parity.test.mjs` shows) keeps strict failure semantics by
1006
1014
  construction, with no env var to remember and none an agent could set for it.
1007
1015
 
1008
- Two things the posture does NOT reach, in either direction:
1009
-
1010
- - **Detection verdicts.** It speaks only to the hook FAILING; a working
1011
- sanitizer that found an injection blocks under both settings.
1012
- - **An unknown `--hook=` mode**, which still exits 2. That is static wiring
1013
- corruption, and passing it through would mean no hook ever runs — silently,
1014
- for the life of the install.
1016
+ The posture does NOT reach **detection verdicts**: it speaks only to the hook
1017
+ FAILING, and a working sanitizer that found an injection blocks under both
1018
+ settings. It does reach an unknown `--hook=` mode — static wiring corruption,
1019
+ from a `hooks.json` and bundle out of step: under the open default that hook
1020
+ exits non-blocking with a stderr line naming the mode and the fix, so every
1021
+ event it fires on reports the gap loudly instead of blocking the session, and
1022
+ under `=0` it exits 2.
1015
1023
 
1016
1024
  The knob is operator configuration read from the process environment, so
1017
1025
  anything that can set it for a session — `.claude/settings.json`'s `env` block,
@@ -1046,8 +1054,13 @@ extracts by walking the TypeScript AST of every shipped `.mjs` (not by matching
1046
1054
  regex source with a regex), and fails on any pattern with super-linear
1047
1055
  backtracking. A `RegExp(…)` built at runtime must be statically resolvable or
1048
1056
  carry a written exemption, so a new dynamic pattern is a red build rather than a
1049
- hole. `tests/secrets/test_redos_static_guard.py` is the twin over the Python
1050
- engine. In `pnpm lint`, `eslint-plugin-redos` (recheck) and
1057
+ hole. No construction may read call-time data either — a parameter, tool
1058
+ output, `process.env` — because a pattern compiled from it is as large as that
1059
+ data, and past V8's size limit it throws on every call; the same walk covers a
1060
+ string handed to `.match`/`.matchAll`/`.search`.
1061
+ `tests/secrets/test_redos_static_guard.py` is the twin over the Python engine,
1062
+ and `tests/secrets/test_regex_size_static_guard.py` holds the Python package to
1063
+ the call-time rule. In `pnpm lint`, `eslint-plugin-redos` (recheck) and
1051
1064
  `eslint-plugin-regexp`'s `no-super-linear-backtracking` and
1052
1065
  `no-super-linear-move` read every shipped regex literal — the last of them
1053
1066
  reports the shape that carries no backtracking at all, an **unanchored**
@@ -97,8 +97,10 @@ export const FAULT_POLICY_HOOKS = Object.freeze([
97
97
  /**
98
98
  * A hook's declared posture. There is deliberately no way for a policy to pin
99
99
  * the CLOSED arm: AGENT_SANITIZER_FAIL_OPEN alone decides, so no hook fault can
100
- * withhold a tool output or block a prompt unless the operator set the knob to
101
- * `0`. A pin turns any bug in the guarded layer into every tool call failing.
100
+ * withhold a tool output, block a prompt, or hold a tool call for good unless the
101
+ * operator set the knob to `0` (an open arm may refuse a call once, releasing the
102
+ * exact re-issue). A pin turns any bug in the guarded layer into every tool call
103
+ * failing.
102
104
  * @typedef {{
103
105
  * event: string | null,
104
106
  * guarded: string,
@@ -680,6 +680,77 @@ export function emitHookResponse(hookEventName, fields) {
680
680
  );
681
681
  }
682
682
 
683
+ /**
684
+ * Fold several hook envelopes into the ONE that may reach stdout: `systemMessage`
685
+ * and `additionalContext` are joined (a later part is appended, never allowed to
686
+ * displace an earlier one); any other field takes the last value written.
687
+ * @param {Array<Record<string, any>>} envelopes non-empty
688
+ * @returns {Record<string, any>}
689
+ */
690
+ function mergeEnvelopes(envelopes) {
691
+ /** @type {Record<string, any>} */
692
+ const merged = {};
693
+ /** @type {Record<string, any>} */
694
+ const specific = {};
695
+ /** @type {string[]} */
696
+ const systemMessages = [];
697
+ /** @type {string[]} */
698
+ const contexts = [];
699
+ for (const { hookSpecificOutput, systemMessage, ...rest } of envelopes) {
700
+ Object.assign(merged, rest);
701
+ if (systemMessage) systemMessages.push(systemMessage);
702
+ if (!hookSpecificOutput) continue;
703
+ const { additionalContext, ...fields } = hookSpecificOutput;
704
+ Object.assign(specific, fields);
705
+ if (additionalContext) contexts.push(additionalContext);
706
+ }
707
+ if (systemMessages.length > 0)
708
+ merged.systemMessage = systemMessages.join("\n");
709
+ if (contexts.length > 0) specific.additionalContext = contexts.join("\n");
710
+ if (Object.keys(specific).length > 0) merged.hookSpecificOutput = specific;
711
+ return merged;
712
+ }
713
+
714
+ /**
715
+ * @typedef {{
716
+ * add: (envelope: Record<string, any>) => void,
717
+ * emit: (hookEventName: string, fields: Record<string, unknown>) => void,
718
+ * flush: () => void,
719
+ * }} EnvelopeBuffer
720
+ */
721
+
722
+ /**
723
+ * Collect everything a hook run wants to say on stdout and write it as exactly
724
+ * ONE JSON object on `flush`. Claude Code parses a hook's stdout as a single
725
+ * document, so two envelopes written by two reporters (a fault notice, then the
726
+ * slow-hook notice from the timing wrapper's `finally`) concatenate into invalid
727
+ * output and the host DROPS the whole response — the model never sees either
728
+ * warning. Every reporter in a run therefore adds here instead of writing, and
729
+ * the entry point flushes once, last.
730
+ * @param {(chunk: string) => void} [write] injectable stdout sink, for tests
731
+ * @returns {EnvelopeBuffer}
732
+ */
733
+ export function createEnvelopeBuffer(
734
+ write = (chunk) => process.stdout.write(chunk),
735
+ ) {
736
+ /** @type {Array<Record<string, any>>} */
737
+ const pending = [];
738
+ /** @param {Record<string, any>} envelope */
739
+ const add = (envelope) => {
740
+ pending.push(envelope);
741
+ };
742
+ return {
743
+ add,
744
+ emit: (hookEventName, fields) =>
745
+ add({ hookSpecificOutput: { hookEventName, ...fields } }),
746
+ flush() {
747
+ if (pending.length === 0) return;
748
+ write(JSON.stringify(mergeEnvelopes(pending)));
749
+ pending.length = 0;
750
+ },
751
+ };
752
+ }
753
+
683
754
  /**
684
755
  * The project the hooks are guarding. Every per-project $TMPDIR store is keyed to
685
756
  * it, so it lives here — beside the other shared identity these hooks agree on —
@@ -799,7 +870,7 @@ export function hookgateMarkerPath(
799
870
  // Prefer the per-user, mode-0700 runtime dir when the harness gives an
800
871
  // absolute one; else the world-writable /tmp, where markerIsTrusted() — not
801
872
  // the path — defends against a squatted marker.
802
- const base = runtimeDir && runtimeDir.startsWith("/") ? runtimeDir : "/tmp";
873
+ const base = runtimeDir && isAbsolute(runtimeDir) ? runtimeDir : "/tmp";
803
874
  // The flattened dir is for a human reading `ls /tmp`; the digest of the RAW
804
875
  // dir is the identity. Flattening alone is lossy — /work/a-b, /work/a_b and
805
876
  // "/work/a b" all collapse to one name — and two such projects on one machine
@@ -811,7 +882,7 @@ export function hookgateMarkerPath(
811
882
  .digest("hex")
812
883
  .slice(0, 8);
813
884
  const flattened = projectDir.replace(/[^A-Za-z0-9]/g, "_");
814
- return `${base}/${HOOKGATE_MARKER_STEM}${flattened}-${digest}`;
885
+ return join(base, `${HOOKGATE_MARKER_STEM}${flattened}-${digest}`);
815
886
  }
816
887
 
817
888
  /**
@@ -1199,26 +1270,30 @@ export function touchLiveState(path) {
1199
1270
  * file the hook's user owns. Unlink any existing entry first (removing a squatted
1200
1271
  * symlink), then create exclusively (O_EXCL) so a symlink re-planted in the race
1201
1272
  * window fails the open rather than being dereferenced. Content is irrelevant —
1202
- * callers test only for existence — so the file is left empty. Best-effort: a
1203
- * missing/read-only $TMPDIR or a lost race just leaves the sentinel absent, and
1204
- * every caller treats "absent" as "not yet done" (a repeated ask, never a crash),
1205
- * so all failures are swallowed.
1273
+ * callers test only for existence — so the file is left empty. Never throws: a
1274
+ * missing/read-only $TMPDIR or a lost race leaves the sentinel absent, and the
1275
+ * return value says so. A caller whose refusal is released only by a later read
1276
+ * of this sentinel must branch on it, because a refusal that records nothing can
1277
+ * never be released.
1206
1278
  * @param {string} path
1207
- * @returns {void}
1279
+ * @returns {boolean} whether a sentinel this uid owns now exists at `path`
1280
+ * (the same test {@link markerIsTrusted} applies when a reader looks for it)
1208
1281
  */
1209
1282
  export function writeSentinelFile(path) {
1210
1283
  try {
1211
1284
  unlinkSync(path);
1212
1285
  } catch {
1213
1286
  // No existing entry (the common case), or an unremovable one — either way the
1214
- // exclusive create below is the real guard, and its own failure is swallowed.
1287
+ // exclusive create below is the real guard, and its own failure is returned.
1215
1288
  }
1216
1289
  try {
1217
1290
  closeSync(openSync(path, "wx"));
1218
1291
  } catch {
1219
1292
  // A symlink re-planted in the unlink→open window, an unwritable dir, or a
1220
- // leftover entry: skip silently — the caller simply re-asks next time.
1293
+ // leftover entry.
1294
+ return false;
1221
1295
  }
1296
+ return markerIsTrusted(path);
1222
1297
  }
1223
1298
 
1224
1299
  /**
@@ -706,14 +706,17 @@ export function alertAcknowledged(sessionId) {
706
706
  /**
707
707
  * Record that the gate has surfaced its blocking ask, so later tool calls get a
708
708
  * passive reminder instead of an ask on every call. Session-keyed, so the next
709
- * session re-asks once without anything having to clear this.
709
+ * session re-asks once without anything having to clear this. Returns false
710
+ * when the ack could not be recorded, so the caller knows the next call will
711
+ * ask again: under the fail-open posture it surfaces the finding as a warning
712
+ * rather than asking on every tool call for the rest of the session.
710
713
  * @param {string} [sessionId]
711
- * @returns {void}
714
+ * @returns {boolean}
712
715
  */
713
716
  export function acknowledgeAlert(sessionId) {
714
717
  // Symlink-safe presence write: the ack sits at a predictable STATE_ROOT path
715
718
  // a co-tenant could pre-plant a symlink at (see writeSentinelFile).
716
- writeSentinelFile(alertAckFile(sessionId));
719
+ return writeSentinelFile(alertAckFile(sessionId));
717
720
  }
718
721
 
719
722
  // What the operator can actually DO — one bullet per kind of report the alert
@@ -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
+ }
@@ -34,28 +34,31 @@ const HOOK_NAME = "plugin-hooks";
34
34
 
35
35
  // The dispatcher's entry in the one posture table (lib/hook-fault.mjs). It
36
36
  // answers no single event — it is the binder in front of all of them — so it
37
- // 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.
38
40
  //
39
- // BOTH ARMS BLOCK, and that is the declaration, not an oversight. The
40
- // AGENT_SANITIZER_FAIL_OPEN knob covers a hook that RAN and broke; an unknown
41
- // mode is static wiring corruption, which means no hook runs at all, silently,
42
- // for the life of the install — there is no run to degrade. Stating it here (and
43
- // pinning it in plugin/test/plugin-bundle.test.mjs) is the point of the table:
44
- // the arm that ignores the knob does so on the record, next to the ones that
45
- // honor it, instead of by hard-exiting past the question.
46
- //
47
- // Exit 2 is the one non-zero code Claude Code treats as BLOCKING: it blocks
48
- // PreToolUse and UserPromptSubmit, surfaces stderr for PostToolUse, and is
49
- // harmless for SessionStart.
50
- const unknownMode = (/** @type {{ message: string }} */ ctx) => ({
51
- stderr: `${HOOK_NAME}: ${ctx.message}\n`,
52
- exitCode: 2,
53
- });
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.`;
54
49
  registerFaultPolicy(HOOK_NAME, {
55
50
  event: null,
56
51
  guarded: "hook payload",
57
- open: unknownMode,
58
- 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
+ }),
59
62
  });
60
63
 
61
64
  // The packages the hooks lazy-load, each behind a thunk whose import specifier
@@ -199,14 +202,16 @@ export async function main() {
199
202
  // some default hook and vet the wrong payload class. WHICH way it fails is
200
203
  // the operator's call, taken through the one posture table like every other
201
204
  // hook fault.
202
- process.exit(
203
- writeFaultOutcome(
204
- hookFaultOutcome(
205
- HOOK_NAME,
206
- new Error(`unknown hook mode ${JSON.stringify(mode)}`),
207
- ),
205
+ process.exitCode = writeFaultOutcome(
206
+ hookFaultOutcome(
207
+ HOOK_NAME,
208
+ new Error(`unknown hook mode ${JSON.stringify(mode)}`),
208
209
  ),
209
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;
210
215
  }
211
216
 
212
217
  // An empty envelope is a verdict, not a crash: stdout stays non-empty, so the