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
package/src/output.mjs CHANGED
@@ -174,18 +174,19 @@ function mutate(state, text, report) {
174
174
  * Run Layer 4 (`options.redact`, when set) over the state's current text and
175
175
  * fold any finding back in. The single Layer-4 invocation site FOR THE PIPELINE
176
176
  * STATE: the first pass and the re-run after a span deletion are the same call,
177
- * so they share one fail-closed handling, warning prose and invariants.
177
+ * so they share one throw-on-fault handling, warning prose and invariants.
178
178
  *
179
179
  * One other site runs Layer 4 deliberately: {@link vetStageValue}, which vets a
180
180
  * stage value on its way out and has no `PipelineState` to fold a finding into.
181
181
  * It shares the post-redaction invariant (normalize what the redactor's own
182
- * output may have stranded) but NOT the fail-closed policy — it withholds the
183
- * one field rather than suppressing the whole output. Adding a third site means
182
+ * output may have stranded) but NOT the fault handling — it withholds the one
183
+ * field instead of throwing. Adding a third site means
184
184
  * re-deciding both halves, so route through one of these two instead.
185
185
  *
186
- * Fails CLOSED: a redactor we could not run might have let a secret through, so
187
- * the throw is rethrown wrapped and the caller suppresses the output rather than
188
- * emitting an unvetted value with a warning.
186
+ * Throws rather than returning unvetted text: a redactor we could not run might
187
+ * have let a secret through, so the seam never hands back a value as if it were
188
+ * vetted. The throw is rethrown wrapped and the CALLER's fault posture decides
189
+ * what reaches the model (the Claude Code hooks: AGENT_SANITIZER_FAIL_OPEN).
189
190
  * @param {PipelineState} state
190
191
  * @param {SanitizeTextOptions} options
191
192
  * @returns {Promise<void>}
@@ -200,7 +201,7 @@ async function runRedact(state, options) {
200
201
  } catch (l4err) {
201
202
  throw new Error(
202
203
  `CRITICAL: secret redaction failed (${errMessage(l4err)}). ` +
203
- "Failing closed — tool output suppressed.",
204
+ "This text was not vetted for secrets.",
204
205
  { cause: l4err },
205
206
  );
206
207
  }
@@ -364,8 +365,9 @@ function processLayer1(text, sgrCarveOut) {
364
365
  * Refuse to START a parse with no budget left. Each of Layers 2/3 parses the
365
366
  * whole document in ONE synchronous call, so nothing interrupts it, and a host
366
367
  * that kills the overrun hook shows the RAW text. Called before EACH parse, and
367
- * only after a pre-gate passed, so a declined call costs no time. Fails closed,
368
- * as Layer 4 does.
368
+ * only after a pre-gate passed, so a declined call costs no time. Throws, as
369
+ * Layer 4 does: whether the unchecked text then passes or is withheld is the
370
+ * caller's fault posture, never this layer's.
369
371
  * @param {Deadline | undefined} deadline
370
372
  * @returns {void}
371
373
  */
@@ -373,8 +375,8 @@ function refuseIfSpent(deadline) {
373
375
  if (deadline && deadline.remainingMs() <= 0)
374
376
  throw new Error(
375
377
  "CRITICAL: the sanitization time budget ran out before the hidden-HTML " +
376
- "and exfil-URL layers finished, so this text was not fully checked. " +
377
- "Failing closed — tool output suppressed.",
378
+ "and exfil-URL layers finished, so this text was not fully checked " +
379
+ "and must be treated as UNSANITIZED.",
378
380
  );
379
381
  }
380
382
 
@@ -599,8 +601,8 @@ async function vetStageValue(text, redact, findings, label) {
599
601
  return secrets ? normalizeLoneSurrogates(secrets.text) : text;
600
602
  } catch {
601
603
  // A WARNING: the caller asked for this field and is not getting it, and the
602
- // reason is an unrunnable redactor — the same fault that fails `cleaned`
603
- // closed, just with a survivable remedy here.
604
+ // reason is an unrunnable redactor — the same fault that makes `cleaned`
605
+ // throw, just with a survivable remedy here.
604
606
  findings.push(warning(withheldWarning(label)));
605
607
  return undefined;
606
608
  }
@@ -629,11 +631,10 @@ async function vetStageValue(text, redact, findings, label) {
629
631
 
630
632
  /**
631
633
  * Run the configured layers over a single text blob. Layer 1 always runs; the
632
- * rest are opt-in via `options`. Layer 4 (`redact`) is the fail-closed path: a
633
- * redactor that throws is rethrown wrapped, so the caller suppresses the
634
- * output rather than emitting an unvetted value. That fail-closed behavior
635
- * also applies to Layer 4's re-scan after a Layer-5 span deletion (see Layer
636
- * 5, below) — a redactor failure there fails the whole call closed too.
634
+ * rest are opt-in via `options`. A Layer 4 (`redact`) redactor that throws is
635
+ * rethrown wrapped, so the caller answers it under its fault posture rather
636
+ * than receiving unvetted text as vetted. The same holds for Layer 4's re-scan
637
+ * after a Layer-5 span deletion (see Layer 5, below).
637
638
  * `reveal` is the pre-Layer-2 text, present only when the HTML splice removed
638
639
  * bytes, so a caller can persist what was hidden for later inspection (see
639
640
  * {@link applyMarkdownPipeline}); the field is omitted otherwise, and also when
@@ -677,7 +678,7 @@ export async function sanitizeText(text, options = {}) {
677
678
  const { reveal: revealText, splices: stageSplices } =
678
679
  await applyMarkdownPipeline(state, options);
679
680
 
680
- // Layer 4 — fail closed (see runRedact).
681
+ // Layer 4 — throws on a redactor fault (see runRedact).
681
682
  await runRedact(state, options);
682
683
 
683
684
  // Layer 5 — secure span-deletion slot (see module doc). A warning-only result
package/src/rehydrate.mjs CHANGED
@@ -823,6 +823,12 @@ function isCandidate(tool, ti) {
823
823
  * there is nothing to do. Throws only on internal error (the caller fails
824
824
  * closed).
825
825
  *
826
+ * A deny carrying `fault` is not a verdict on the input but this module being
827
+ * unable to do its job (an unreadable target, a redactor map that fails
828
+ * validation): `fault` states what proceeding un-rehydrated would write, and the
829
+ * caller's failure posture decides whether the deny stands. A deny without it
830
+ * is a verdict and stands in every posture.
831
+ *
826
832
  * `io` is the injected I/O (file read + redactor map/plain). `hint` is the
827
833
  * redaction-placeholder prefix (defaults to {@link DEFAULT_HINT}); override it
828
834
  * only if the injected redactor emits a different placeholder shape.
@@ -830,7 +836,7 @@ function isCandidate(tool, ti) {
830
836
  * @param {any} toolInput
831
837
  * @param {RehydrateIo} io
832
838
  * @param {{ hint?: string }} [options]
833
- * @returns {Promise<{updatedInput: any, context: string} | {deny: string} | null>}
839
+ * @returns {Promise<{updatedInput: any, context: string} | {deny: string, fault?: string} | null>}
834
840
  */
835
841
  export async function rehydrateRedacted(
836
842
  tool,
@@ -911,12 +917,17 @@ export async function rehydrateRedacted(
911
917
  if (tool !== "Write") throw err;
912
918
  return null;
913
919
  }
920
+ const cause = nodeErr?.code ?? nodeErr?.message;
914
921
  return {
915
922
  deny:
916
923
  `could not read ${toolInput.file_path} to rehydrate its secrets ` +
917
- `(${nodeErr?.code ?? nodeErr?.message}); the file likely still exists, so writing ` +
924
+ `(${cause}); the file likely still exists, so writing ` +
918
925
  `the placeholder text as-is risks overwriting a real secret. Retry the read, or ` +
919
926
  `ask the user to make this change directly`,
927
+ fault:
928
+ `${toolInput.file_path} could not be read (${cause}) to rehydrate its ` +
929
+ `secrets, so this call is not rehydrated and its ${hint}…] placeholder ` +
930
+ `text is written to the file literally`,
920
931
  };
921
932
  }
922
933
  const { layer1Cleaned, cleaned } = layer1View(content);
@@ -949,8 +960,14 @@ export async function rehydrateRedacted(
949
960
  // reaches its own resolver-backed pass-through.
950
961
  if (tool === "MultiEdit") return multiEditDeny(toolInput.file_path);
951
962
  if (!hinted) return null;
963
+ // A verdict, not a fault: the file itself defeats mapping on every call, so
964
+ // a released re-issue would overwrite the real secret with its placeholder.
965
+ // The escape is to stop writing the placeholder, which the reason names.
952
966
  return {
953
- deny: `cannot resolve redaction placeholders in ${toolInput.file_path}: ${mapped.unmappable}`,
967
+ deny:
968
+ `cannot resolve redaction placeholders in ${toolInput.file_path}: ` +
969
+ `${mapped.unmappable}. Leave every ${hint}…] placeholder out of this ` +
970
+ `call (edit around it), or ask the user to make this change`,
954
971
  };
955
972
  }
956
973
  // The redactor emits code-point offsets; the offset machinery below works in
@@ -970,9 +987,8 @@ export async function rehydrateRedacted(
970
987
  // before acting: construction re-checks range/ordering (the throws in
971
988
  // assertPairsOrdered and pairsToUtf16), viewMapDefect proves the view
972
989
  // reconstructs `cleaned` exactly. A defective map DENIES for every tool and
973
- // hint state, and is deliberately NOT allowed to throw out of this module: a
974
- // throw lands in the host's failure posture, whose shipped default is fail
975
- // OPEN, i.e. the unsanitized placeholder write this module exists to prevent.
990
+ // hint state with a `fault`, never a throw: the deny lets the caller refuse
991
+ // the call once and name what proceeding writes, which a throw cannot.
976
992
  let view = null;
977
993
  let mapDefect = null;
978
994
  try {
@@ -993,14 +1009,25 @@ export async function rehydrateRedacted(
993
1009
  // Reaching this line at all means the fast pass-through did not fire — the
994
1010
  // file provably holds redacted content or diverges from its view — so an
995
1011
  // unhinted pass-through here would hand the real Edit bytes inside spans
996
- // nothing can vet (the R1 hidden-span oracle). Deny everything until the
997
- // redactor is fixed.
1012
+ // nothing can vet (the R1 hidden-span oracle). Deny, as a fault: the
1013
+ // engine, not the input, is what is wrong.
998
1014
  if (tool === "MultiEdit") return multiEditDeny(toolInput.file_path);
1015
+ // A Write replaces the whole file, and the secret-drop guard reads this
1016
+ // same broken map, so the loss it would name has to be named here instead.
1017
+ const dropped =
1018
+ tool === "Write"
1019
+ ? `; it replaces the whole file, so any redacted secret it does not ` +
1020
+ `reproduce is removed, and which secrets those are cannot be checked`
1021
+ : "";
999
1022
  return {
1000
1023
  deny:
1001
1024
  `cannot safely edit ${toolInput.file_path}: ${mapDefect}; the file holds ` +
1002
1025
  `redacted content whose location cannot be trusted, so no edit can be ` +
1003
- `verified. Retry later, or ask the user to make this change`,
1026
+ `verified${dropped}. Retry later, or ask the user to make this change`,
1027
+ fault:
1028
+ `${mapDefect} for ${toolInput.file_path}, so this call is not ` +
1029
+ `rehydrated: any ${hint}…] placeholder text in it is written to the ` +
1030
+ `file literally, and it is applied without the redacted-span checks${dropped}`,
1004
1031
  };
1005
1032
  }
1006
1033
  // View identical to disk: any placeholders in an Edit's old_string are
@@ -32,8 +32,7 @@ export function defaultOpen(ctx: FaultContext): FaultParts;
32
32
  /**
33
33
  * Resolve `hook`'s response to its own failure under the caller's posture. This
34
34
  * is the ONLY place {@link failOpenEnabled} is consulted on a hook fault, so the
35
- * knob cannot be honored in three hooks and skipped in the fourth — and the
36
- * only place a policy's `alwaysClosed` can override it.
35
+ * knob cannot be honored in three hooks and skipped in the fourth.
37
36
  * @param {string} hook
38
37
  * @param {unknown} err
39
38
  * @param {Record<string, any> & {
@@ -65,63 +64,6 @@ export function writeFaultOutcome(outcome: FaultOutcome, write?: (chunk: string)
65
64
  * @type {readonly string[]}
66
65
  */
67
66
  export const FAULT_POLICY_HOOKS: readonly string[];
68
- /**
69
- * What a hook's fault renders to. `fields`/`envelope` are the stdout response
70
- * (`fields` is the `hookSpecificOutput` body, wrapped with the policy's event;
71
- * `envelope` is a hook that answers with a top-level shape instead, like
72
- * UserPromptSubmit's `{decision:"block"}`); `stderr` and `exitCode` are the
73
- * process-level halves a hook with no stdout channel uses; `armAlert` asks the
74
- * caller to persist its cross-hook alert so a LATER gate carries the closed
75
- * posture the faulting hook could not express itself.
76
- * @typedef {{
77
- * posture: "open" | "closed",
78
- * fields: Record<string, unknown> | null,
79
- * fallbackFields: Record<string, unknown> | null,
80
- * envelope: object | null,
81
- * stderr: string | null,
82
- * exitCode: number,
83
- * armAlert: boolean,
84
- * }} FaultOutcome
85
- */
86
- /**
87
- * What a policy's `open`/`closed` builder returns: any subset of a
88
- * {@link FaultOutcome}'s renderable slots. Everything omitted takes its default
89
- * (no output, exit 0, no alert).
90
- * @typedef {{
91
- * fields?: Record<string, unknown>,
92
- * fallbackFields?: Record<string, unknown>,
93
- * envelope?: object,
94
- * stderr?: string,
95
- * exitCode?: number,
96
- * armAlert?: boolean,
97
- * }} FaultParts
98
- */
99
- /**
100
- * The context a builder is handed: the caller's own inputs (whatever it passed
101
- * to {@link hookFaultOutcome}) plus the values every hook derived by hand
102
- * before — the hook name, the scrubbed error message, and the model-facing
103
- * open-posture warning — and `pinnedClosed`, true when the policy's
104
- * `alwaysClosed` overrode an open knob, so the closed arm can say why.
105
- * @typedef {Record<string, any> & {
106
- * hook: string,
107
- * err: unknown,
108
- * message: string,
109
- * openContext: string,
110
- * pinnedClosed: boolean,
111
- * }} FaultContext
112
- */
113
- /**
114
- * Whether a hook's faults are withheld whatever AGENT_SANITIZER_FAIL_OPEN says,
115
- * for the caller's environment.
116
- * @typedef {(
117
- * env: NodeJS.ProcessEnv | Record<string, string | undefined> | undefined,
118
- * ) => boolean} AlwaysClosed
119
- */
120
- /**
121
- * The `alwaysClosed` of a hook whose posture the knob alone decides.
122
- * @type {AlwaysClosed}
123
- */
124
- export const KNOB_DECIDES: AlwaysClosed;
125
67
  /**
126
68
  * What a hook's fault renders to. `fields`/`envelope` are the stdout response
127
69
  * (`fields` is the `hookSpecificOutput` body, wrapped with the policy's event;
@@ -157,32 +99,25 @@ export type FaultParts = {
157
99
  * The context a builder is handed: the caller's own inputs (whatever it passed
158
100
  * to {@link hookFaultOutcome}) plus the values every hook derived by hand
159
101
  * before — the hook name, the scrubbed error message, and the model-facing
160
- * open-posture warning — and `pinnedClosed`, true when the policy's
161
- * `alwaysClosed` overrode an open knob, so the closed arm can say why.
102
+ * open-posture warning.
162
103
  */
163
104
  export type FaultContext = Record<string, any> & {
164
105
  hook: string;
165
106
  err: unknown;
166
107
  message: string;
167
108
  openContext: string;
168
- pinnedClosed: boolean;
169
109
  };
170
110
  /**
171
- * Whether a hook's faults are withheld whatever AGENT_SANITIZER_FAIL_OPEN says,
172
- * for the caller's environment.
173
- */
174
- export type AlwaysClosed = (env: NodeJS.ProcessEnv | Record<string, string | undefined> | undefined) => boolean;
175
- /**
176
- * A hook's declared posture. `alwaysClosed` is REQUIRED, so every hook states
177
- * on the record whether the knob can open it: {@link KNOB_DECIDES}, or a
178
- * predicate that pins the CLOSED arm — how a guarantee the docs make
179
- * unconditionally (Layer 4 withholds what it could not vet) is enforced in the
180
- * one posture resolver rather than by a hook re-reading the knob.
111
+ * A hook's declared posture. There is deliberately no way for a policy to pin
112
+ * the CLOSED arm: AGENT_SANITIZER_FAIL_OPEN alone decides, so no hook fault can
113
+ * withhold a tool output, block a prompt, or hold a tool call for good unless the
114
+ * operator set the knob to `0` (an open arm may refuse a call once, releasing the
115
+ * exact re-issue). A pin turns any bug in the guarded layer into every tool call
116
+ * failing.
181
117
  */
182
118
  export type FaultPolicy = {
183
119
  event: string | null;
184
120
  guarded: string;
185
- alwaysClosed: AlwaysClosed;
186
121
  open?: (ctx: FaultContext) => FaultParts;
187
122
  closed: (ctx: FaultContext) => FaultParts;
188
123
  };
@@ -286,6 +286,25 @@ export function safeErrMessage(err: unknown, cap?: number): string;
286
286
  * @returns {void}
287
287
  */
288
288
  export function emitHookResponse(hookEventName: string, fields: Record<string, unknown>): void;
289
+ /**
290
+ * @typedef {{
291
+ * add: (envelope: Record<string, any>) => void,
292
+ * emit: (hookEventName: string, fields: Record<string, unknown>) => void,
293
+ * flush: () => void,
294
+ * }} EnvelopeBuffer
295
+ */
296
+ /**
297
+ * Collect everything a hook run wants to say on stdout and write it as exactly
298
+ * ONE JSON object on `flush`. Claude Code parses a hook's stdout as a single
299
+ * document, so two envelopes written by two reporters (a fault notice, then the
300
+ * slow-hook notice from the timing wrapper's `finally`) concatenate into invalid
301
+ * output and the host DROPS the whole response — the model never sees either
302
+ * warning. Every reporter in a run therefore adds here instead of writing, and
303
+ * the entry point flushes once, last.
304
+ * @param {(chunk: string) => void} [write] injectable stdout sink, for tests
305
+ * @returns {EnvelopeBuffer}
306
+ */
307
+ export function createEnvelopeBuffer(write?: (chunk: string) => void): EnvelopeBuffer;
289
308
  /**
290
309
  * The directory the session-lifetime stores live under: the alert markers and
291
310
  * findings (invisible-alert.mjs) and the reveal sidecars (reveal.mjs). They must
@@ -522,14 +541,16 @@ export function touchLiveState(path: string): void;
522
541
  * file the hook's user owns. Unlink any existing entry first (removing a squatted
523
542
  * symlink), then create exclusively (O_EXCL) so a symlink re-planted in the race
524
543
  * window fails the open rather than being dereferenced. Content is irrelevant —
525
- * callers test only for existence — so the file is left empty. Best-effort: a
526
- * missing/read-only $TMPDIR or a lost race just leaves the sentinel absent, and
527
- * every caller treats "absent" as "not yet done" (a repeated ask, never a crash),
528
- * so all failures are swallowed.
544
+ * callers test only for existence — so the file is left empty. Never throws: a
545
+ * missing/read-only $TMPDIR or a lost race leaves the sentinel absent, and the
546
+ * return value says so. A caller whose refusal is released only by a later read
547
+ * of this sentinel must branch on it, because a refusal that records nothing can
548
+ * never be released.
529
549
  * @param {string} path
530
- * @returns {void}
550
+ * @returns {boolean} whether a sentinel this uid owns now exists at `path`
551
+ * (the same test {@link markerIsTrusted} applies when a reader looks for it)
531
552
  */
532
- export function writeSentinelFile(path: string): void;
553
+ export function writeSentinelFile(path: string): boolean;
533
554
  /**
534
555
  * Write `content` to `path` without following a symlink a co-tenant may have
535
556
  * pre-planted there — the content-bearing counterpart to writeSentinelFile. These
@@ -635,6 +656,11 @@ export const STATE_ROOT: string;
635
656
  * says it locks may be judged by the lock.
636
657
  */
637
658
  export const SETUP_LOCK_DECLARATION: "flock";
659
+ export type EnvelopeBuffer = {
660
+ add: (envelope: Record<string, any>) => void;
661
+ emit: (hookEventName: string, fields: Record<string, unknown>) => void;
662
+ flush: () => void;
663
+ };
638
664
  /**
639
665
  * EVERY process-wide slot these helpers keep — the four a host can observe or
640
666
  * steer, so a second instance that adopts this object is steered in all four at
@@ -170,10 +170,15 @@ export function startHookTimer(now?: () => number, cpuNow?: () => number): {
170
170
  * sanitizer computing, and with `context.redactorMs` too it names the window the
171
171
  * time went into ({@link attributeWait}), including `context.hostMs`'s host
172
172
  * extensions — a caller that measured the first two but has no extensions to
173
- * charge reports that window as zero, which is a measurement and not a shrug. Without them the line says that it cannot
174
- * tell, rather than asserting an attribution nothing measured: a wall-clock
175
- * overrun on a loaded host is the common case, and blaming it on the sanitizer
176
- * sends the operator hunting a per-call cost that does not exist.
173
+ * charge reports that window as zero, which is a measurement and not a shrug.
174
+ * Such a caller is reported only when `cpuMs + redactorMs + hostMs` (an absent
175
+ * `hostMs` counting as 0) exceeds `thresholdMs`: a wall-clock overrun whose
176
+ * owned share is within the budget is the machine's and returns null, so the
177
+ * operator is never asked to report a cost the owner did not spend. Without the
178
+ * measurements the line says that it cannot tell, rather than asserting an
179
+ * attribution nothing measured: a wall-clock overrun on a loaded host is the
180
+ * common case, and blaming it on the sanitizer sends the operator hunting a
181
+ * per-call cost that does not exist.
177
182
  *
178
183
  * A CPU figure alone cannot pick a cause, so with only that the clause names
179
184
  * candidates and commits to none. A hook that blocks on a dead socket inside a
@@ -193,11 +193,14 @@ export function alertAcknowledged(sessionId?: string): boolean;
193
193
  /**
194
194
  * Record that the gate has surfaced its blocking ask, so later tool calls get a
195
195
  * passive reminder instead of an ask on every call. Session-keyed, so the next
196
- * session re-asks once without anything having to clear this.
196
+ * session re-asks once without anything having to clear this. Returns false
197
+ * when the ack could not be recorded, so the caller knows the next call will
198
+ * ask again: under the fail-open posture it surfaces the finding as a warning
199
+ * rather than asking on every tool call for the rest of the session.
197
200
  * @param {string} [sessionId]
198
- * @returns {void}
201
+ * @returns {boolean}
199
202
  */
200
- export function acknowledgeAlert(sessionId?: string): void;
203
+ export function acknowledgeAlert(sessionId?: string): boolean;
201
204
  /**
202
205
  * The blocking ask. The heading states only that the scan did not finish clean:
203
206
  * the alert carries injection findings, unreadable targets, or a scanner fault,
@@ -55,9 +55,9 @@ export function classifySocket(socketPath: string, deps?: {
55
55
  /**
56
56
  * Open one connection, send `request`, resolve with the parsed response object
57
57
  * (or null). Rejects on connect failure, a malformed/oversize/short frame, or an
58
- * {error} response — every one of which the caller turns into a fail-closed. A
58
+ * {error} response — every one of which the caller treats as a fault. A
59
59
  * socket present but not owned by us, and a request the protocol does not
60
- * accept, fail closed WITHOUT dialing (the error carries no errno, so
60
+ * accept, throw WITHOUT dialing (the error carries no errno, so
61
61
  * isRespawnable is false): we never dial into a squat, and an oversize request
62
62
  * is not mistaken for a dead daemon worth respawning.
63
63
  * @param {string} socketPath
@@ -105,7 +105,7 @@ export function waitForSocket(socketPath: string, { deadlineMs, stepMs, abandon
105
105
  /**
106
106
  * Redact `text` via the daemon. Returns the response object (`{text, found}` for
107
107
  * plain, `{text, pairs, found}` / `{unmappable}` for map) or null when nothing was
108
- * redacted (plain mode). Throws to fail closed when the text cannot be vetted.
108
+ * redacted (plain mode). Throws when the text cannot be vetted.
109
109
  *
110
110
  * `connect`/`spawn`/`waitForSocket` are injectable seams (default to the real
111
111
  * implementations) so callers can stub the daemon in-process. `deadline` is the
@@ -1,26 +1,20 @@
1
- /**
2
- * Regex matching `value` tolerating invisible chars spliced between its
3
- * characters (mirrors the engine's env-value regex). Code-point split so
4
- * an astral character is escaped whole, not as two surrogate halves — and the
5
- * `u` flag, which the astral `\u{…}` class members in {@link ENV_INVIS_RUN}
6
- * require.
7
- * Memoized per distinct value: {@link ENV_INVIS_RUN} renders ~435 code points
8
- * as ~4 KB of source and is joined at EVERY interior gap, so a 20-char secret
9
- * compiles a ~75 KB pattern — and `hasEnvBoundSecret` builds one per configured
10
- * var on every PostToolUse output. Env values are stable for the process's
11
- * lifetime, so the cache is bounded by the number of distinct values. Sharing an
12
- * instance is safe because the regex carries no `g`/`y` flag, hence no
13
- * `lastIndex` state to leak between calls.
14
- * @param {string} value
15
- * @returns {RegExp}
16
- */
17
- export function envValueRegex(value: string): RegExp;
18
1
  /**
19
2
  * True when tool output contains the literal value of a configured env-bound
20
3
  * secret. The shape-based secret hint can't match a prefix-less key or a host
21
4
  * credential, so the pre-gate must also fire on the value itself — otherwise
22
5
  * the engine's env-bound redaction never runs. Invisible-tolerant so a
23
6
  * value with spliced Cf chars (which the daemon still redacts) trips it too.
7
+ *
8
+ * Both sides are stripped and compared as substrings, which is exactly
9
+ * equivalent: the value's visible characters appear contiguously in the
10
+ * stripped text iff they appear in the original separated only by invisibles.
11
+ * It is linear and independent of the value's length — a regex with an
12
+ * invisible class at every gap grows ~4 KB per character, and a long secret (a
13
+ * PEM key) overflows V8's regex size limit and throws on every output.
14
+ *
15
+ * Callers pass post-Layer-1 text: terminal escapes are already gone, so a value
16
+ * split by one is contiguous here. Raw text spliced with an escape is not
17
+ * matched.
24
18
  * @param {string} text
25
19
  * @param {NodeJS.ProcessEnv} [env]
26
20
  * @returns {boolean}
@@ -11,6 +11,28 @@
11
11
  * @returns {string}
12
12
  */
13
13
  export function dropFingerprint(filePath: string, content: string, dropped?: string[]): string;
14
+ /**
15
+ * Fingerprint of one refusal of one exact tool call — same refusing site, same
16
+ * tool, same input — released by re-issuing that call. Keyed on the site too, so
17
+ * a call refused by two sites in turn needs a confirmation for each and one
18
+ * re-issue never releases a refusal the model was not shown. The input is
19
+ * hashed in canonical key order, so a re-issue whose keys arrive reordered is
20
+ * still the same call. Distinct domain tags keep it disjoint from
21
+ * {@link dropFingerprint}.
22
+ * @param {string} site
23
+ * @param {unknown} tool
24
+ * @param {unknown} toolInput
25
+ * @returns {string}
26
+ */
27
+ export function reissueFingerprint(site: string, tool: unknown, toolInput: unknown): string;
28
+ /**
29
+ * Record a confirm sentinel for `fingerprint`, so the identical re-issue is
30
+ * approved by {@link consumeConfirm}. False when it could not be recorded: the
31
+ * caller then must not refuse, because nothing could ever release the refusal.
32
+ * @param {string} fingerprint
33
+ * @returns {boolean}
34
+ */
35
+ export function recordConfirm(fingerprint: string): boolean;
14
36
  /**
15
37
  * The confirm sentinel's path for one fingerprint. Predictable and
16
38
  * world-visible like the alert markers, hence the markerIsTrusted read.
@@ -50,15 +72,16 @@ export function gitTracked(filePath: string, spawn?: typeof spawnSync): boolean;
50
72
  * rehydration substituted any placeholders — so a preserved secret shows up as
51
73
  * its real value in `content`. `io` is the rehydrate-shaped I/O bag (readFile /
52
74
  * redact / redactMap). Injectable seams: `isTracked` (the git probe),
53
- * `confirmSeen`/`recordConfirm` (the sentinel state).
75
+ * `confirmSeen`/`recordConfirm` (the sentinel state). A deny whose confirmation
76
+ * could not be recorded carries `fault` (see {@link dropUnconfirmable}).
54
77
  * @param {{file_path?: unknown, content?: unknown}} toolInput
55
78
  * @param {import("agent-sanitizer/rehydrate").RehydrateIo} io
56
79
  * @param {{
57
80
  * isTracked?: (filePath: string) => boolean,
58
81
  * confirmSeen?: (fingerprint: string) => boolean,
59
- * recordConfirm?: (fingerprint: string) => void,
82
+ * recordConfirm?: (fingerprint: string) => boolean,
60
83
  * }} [opts]
61
- * @returns {Promise<{deny: string} | null>}
84
+ * @returns {Promise<{deny: string, fault?: string} | null>}
62
85
  */
63
86
  export function secretDropGuard(toolInput: {
64
87
  file_path?: unknown;
@@ -66,9 +89,10 @@ export function secretDropGuard(toolInput: {
66
89
  }, io: import("agent-sanitizer/rehydrate").RehydrateIo, opts?: {
67
90
  isTracked?: (filePath: string) => boolean;
68
91
  confirmSeen?: (fingerprint: string) => boolean;
69
- recordConfirm?: (fingerprint: string) => void;
92
+ recordConfirm?: (fingerprint: string) => boolean;
70
93
  }): Promise<{
71
94
  deny: string;
95
+ fault?: string;
72
96
  } | null>;
73
97
  /**
74
98
  * Compose a Layer-4 rehydrator with this guard: for a Write the rehydrator
@@ -76,14 +100,41 @@ export function secretDropGuard(toolInput: {
76
100
  * substitution — so a rehydrated secret counts as preserved and only a
77
101
  * genuinely dropped one can trip it. A rehydrate deny short-circuits (the
78
102
  * guard never runs), non-Write tools skip the guard entirely, and a guard
79
- * deny wins over a pass/rewrite result.
80
- * @typedef {{updatedInput: any, context: string} | {deny: string} | null} RehydrateResult
103
+ * deny wins over a pass/rewrite result. A guard FAULT deny carries the
104
+ * rewrite as `release`: the open posture lets that call proceed, and it must
105
+ * proceed rehydrated, or every secret the rewrite restored is overwritten by
106
+ * its placeholder.
107
+ * @typedef {{updatedInput: any, context: string}} RehydrateRewrite
108
+ * @typedef {RehydrateRewrite | {deny: string, fault?: string, release?: RehydrateRewrite} | null} RehydrateResult
81
109
  * @param {(tool: string, toolInput: any) => Promise<RehydrateResult>} rehydrate
82
110
  * @param {import("agent-sanitizer/rehydrate").RehydrateIo} io
83
111
  * @param {typeof secretDropGuard} [guard]
84
112
  * @returns {(tool: string, toolInput: any) => Promise<RehydrateResult>}
85
113
  */
86
114
  export function withSecretDropGuard(rehydrate: (tool: string, toolInput: any) => Promise<RehydrateResult>, io: import("agent-sanitizer/rehydrate").RehydrateIo, guard?: typeof secretDropGuard): (tool: string, toolInput: any) => Promise<RehydrateResult>;
115
+ /**
116
+ * True when a trusted, fresh confirm sentinel exists for `fingerprint`,
117
+ * consuming it so one confirmation approves exactly one call. A sentinel
118
+ * older than {@link CONFIRM_TTL_MS} is stale — an abandoned deny from a
119
+ * previous session, not a live confirmation — so it is removed and NOT
120
+ * honored, preventing a leftover marker from acting as a standing
121
+ * auto-approval. Removal is best-effort: a fresh marker we could not unlink
122
+ * still counted as consumed for THIS call, and a leftover empty sentinel only
123
+ * ever re-approves the identical fingerprinted call the model already
124
+ * confirmed — and only within the TTL.
125
+ * @param {string} fingerprint
126
+ * @returns {boolean}
127
+ */
128
+ export function consumeConfirm(fingerprint: string): boolean;
129
+ /**
130
+ * True when a trusted confirm sentinel younger than {@link CONFIRM_TTL_MS}
131
+ * exists for `fingerprint`, WITHOUT consuming it — for a caller that learns
132
+ * only later whether the call it confirms actually proceeds, and consumes it
133
+ * then.
134
+ * @param {string} fingerprint
135
+ * @returns {boolean}
136
+ */
137
+ export function confirmFresh(fingerprint: string): boolean;
87
138
  /** A confirm sentinel older than this is stale, not a confirmation. */
88
139
  export const CONFIRM_TTL_MS: number;
89
140
  /**
@@ -92,12 +143,29 @@ export const CONFIRM_TTL_MS: number;
92
143
  * substitution — so a rehydrated secret counts as preserved and only a
93
144
  * genuinely dropped one can trip it. A rehydrate deny short-circuits (the
94
145
  * guard never runs), non-Write tools skip the guard entirely, and a guard
95
- * deny wins over a pass/rewrite result.
146
+ * deny wins over a pass/rewrite result. A guard FAULT deny carries the
147
+ * rewrite as `release`: the open posture lets that call proceed, and it must
148
+ * proceed rehydrated, or every secret the rewrite restored is overwritten by
149
+ * its placeholder.
96
150
  */
97
- export type RehydrateResult = {
151
+ export type RehydrateRewrite = {
98
152
  updatedInput: any;
99
153
  context: string;
100
- } | {
154
+ };
155
+ /**
156
+ * Compose a Layer-4 rehydrator with this guard: for a Write the rehydrator
157
+ * did not deny, run the guard on the FINAL content — after any placeholder
158
+ * substitution — so a rehydrated secret counts as preserved and only a
159
+ * genuinely dropped one can trip it. A rehydrate deny short-circuits (the
160
+ * guard never runs), non-Write tools skip the guard entirely, and a guard
161
+ * deny wins over a pass/rewrite result. A guard FAULT deny carries the
162
+ * rewrite as `release`: the open posture lets that call proceed, and it must
163
+ * proceed rehydrated, or every secret the rewrite restored is overwritten by
164
+ * its placeholder.
165
+ */
166
+ export type RehydrateResult = RehydrateRewrite | {
101
167
  deny: string;
168
+ fault?: string;
169
+ release?: RehydrateRewrite;
102
170
  } | null;
103
171
  import { spawnSync } from "node:child_process";