agent-sanitizer 2.67.1 → 2.68.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/THREAT-MODEL.md CHANGED
@@ -298,16 +298,17 @@ plugin injects the Python engine from `agent-sanitizer[secrets]`
298
298
  (`python/agent_sanitizer/secrets/engine.py`) over a local daemon;
299
299
  `plugin/scripts/provision-redactor.sh` installs it at SessionStart.
300
300
 
301
- **It is the one fail-closed layer.** A redactor that throws — unreachable
302
- daemon, engine error — is rethrown from `sanitizeValue` as `CRITICAL: secret
303
- redaction failed …` (`src/output.mjs`).
304
- With the layer opted in, the output hook's fault policy (`alwaysClosed` in
305
- `claude-hooks/sanitize-output.mjs`, resolved in `lib/hook-fault.mjs`) then
306
- suppresses the output under **either** `AGENT_SANITIZER_FAIL_OPEN` posture —
307
- and so does any other fault of that hook, since an output the hook failed on
308
- was never vetted for secrets either. One accepted gap: a launcher-level failure
309
- (no `node`, corrupt bundle) never runs the policy, so the launcher's own
310
- posture applies there, warning by default.
301
+ **A redactor fault follows the operator's posture.** A redactor that throws —
302
+ unreachable daemon, engine error, spent budget — is rethrown from
303
+ `sanitizeValue` as `CRITICAL: secret redaction failed …` (`src/output.mjs`), so
304
+ the seam never returns unvetted text as if it were vetted. The output hook then
305
+ answers it like any other fault, under `AGENT_SANITIZER_FAIL_OPEN`
306
+ (`lib/hook-fault.mjs`, the one posture resolver, which has no per-hook override):
307
+ open by default — the output reaches the model with an UNSANITIZED warning, and
308
+ the operator's terminal names the redactor failure — and withheld under `=0`.
309
+ Accepted risk of the default: while the redactor is down, a credential in tool
310
+ output reaches the model unredacted. The alternative blocks every tool call on
311
+ an availability failure, which is what a deployment chooses with `=0`.
311
312
 
312
313
  **Lone surrogates are normalized to U+FFFD before the redactor sees the text.**
313
314
  A secret split by an interposed lone surrogate renders as contiguous to the
@@ -608,9 +609,9 @@ RECONSTITUTES during stripping is judged as the sequence it becomes.
608
609
  Layer order: 1 → 2–3 → 4 → 5 → 4, the second Layer 4 a re-scan of what Layer 5's
609
610
  deletions left. Shape matters
610
611
  (a harness that gets a shape-mismatched value silently shows the raw output).
611
- Layer 4 (secret redaction) is an **injected** redactor and is the one
612
- fail-closed path: a redactor that throws makes the pipeline rethrow, so the
613
- caller suppresses the output rather than emit an unvetted value. That
612
+ Layer 4 (secret redaction) is an **injected** redactor: a redactor that throws
613
+ makes the pipeline rethrow rather than return an unvetted value, and the
614
+ caller's posture decides whether the output is suppressed. That
614
615
  suppression (`suppressToolOutput`) replaces string leaves in place so the
615
616
  placeholder still matches the tool's shape, with one exception it must make: an
616
617
  Anthropic **content block** is collapsed whole to `{ type: "text", text: … }`
@@ -977,14 +978,13 @@ to hold precision. All other faults keep the open default, and
977
978
 
978
979
  **The open default is not enforceable against content.** Several of those
979
980
  failures are composable by whoever authored the payload — in the output hook
980
- alone, a nesting depth that overflows the sanitize walk and a redaction budget
981
- exhausted by many secret-shaped leaves — though with Layer 4 opted in, the
982
- output hook withholds on any fault whatever the knob says (see Layer 4). (A
983
- **key collision** — two field names that collapse to one after Layer 1 — used
984
- to be on that list. It no longer
985
- fails the hook at all: only the colliding fields are withheld, and every sibling
986
- field survives. Both colliding values are replaced **whole** by a marker string,
987
- not walked leaf-wise: a shape-preserving walk rewrites only string leaves, so a
981
+ alone, a nesting depth that overflows the sanitize walk, a redaction budget
982
+ exhausted by many secret-shaped leaves, and a payload large enough to blow the
983
+ redactor daemon's scan budget. (A **key collision** — two field names that
984
+ collapse to one after Layer 1 — does not fail the hook: only the colliding
985
+ fields are withheld, and every sibling field survives. Both colliding values
986
+ are replaced **whole** by a marker string, not walked leaf-wise: a
987
+ shape-preserving walk rewrites only string leaves, so a
988
988
  colliding number or boolean would reach the model verbatim under a legitimate
989
989
  field name while the warning claimed it was withheld. That changes the field's
990
990
  JSON type, which is accepted because a duplicate name is off-schema by
@@ -37,6 +37,8 @@ let claudeAdapter;
37
37
  let Decision;
38
38
  /** @type {typeof import("agent-control-plane-core/contract").EventKind | undefined} */
39
39
  let EventKind;
40
+ /** @type {typeof import("agent-control-plane-core/contract").renderEscalated | undefined} */
41
+ let renderEscalated;
40
42
 
41
43
  /* c8 ignore start -- module-load boundary: the real import resolves in every
42
44
  in-process test and spawned CLI run, and a missing node_modules can't be
@@ -55,26 +57,35 @@ const loaded = await awaitLazyDependency({
55
57
  );
56
58
  // The /contract subpath, not the package root: the root barrel also pulls
57
59
  // all four adapters, the registry and the conformance harness, which costs
58
- // ~22 ms of import work per process for two enums. A guardrail hook is one
59
- // process per tool call, so that cost lands on every gated call.
60
- const { Decision: decision, EventKind: eventKind } =
61
- /** @type {Partial<typeof import("agent-control-plane-core/contract")>} */ (
62
- await lazyImport("agent-control-plane-core/contract")
63
- );
64
- if (!adapter || !decision || !eventKind) return null;
65
- return { claudeAdapter: adapter, Decision: decision, EventKind: eventKind };
60
+ // ~22 ms of import work per process for three bindings. A guardrail hook is
61
+ // one process per tool call, so that cost lands on every gated call.
62
+ const {
63
+ Decision: decision,
64
+ EventKind: eventKind,
65
+ renderEscalated: escalated,
66
+ } = /** @type {Partial<typeof import("agent-control-plane-core/contract")>} */ (
67
+ await lazyImport("agent-control-plane-core/contract")
68
+ );
69
+ if (!adapter || !decision || !eventKind || !escalated) return null;
70
+ return {
71
+ claudeAdapter: adapter,
72
+ Decision: decision,
73
+ EventKind: eventKind,
74
+ renderEscalated: escalated,
75
+ };
66
76
  },
67
77
  markerPresent: () => markerIsTrusted(marker),
68
78
  setupAlive: () => probeSetupAlive(marker),
69
79
  });
70
80
  if (loaded) {
71
81
  const bound =
72
- /** @type {{ claudeAdapter: typeof claudeAdapter, Decision: typeof Decision, EventKind: typeof EventKind }} */ (
82
+ /** @type {{ claudeAdapter: typeof claudeAdapter, Decision: typeof Decision, EventKind: typeof EventKind, renderEscalated: typeof renderEscalated }} */ (
73
83
  loaded
74
84
  );
75
85
  claudeAdapter = bound.claudeAdapter;
76
86
  Decision = bound.Decision;
77
87
  EventKind = bound.EventKind;
88
+ renderEscalated = bound.renderEscalated;
78
89
  }
79
90
  // Stryker restore all
80
91
  /* c8 ignore stop */
@@ -83,16 +94,28 @@ if (loaded) {
83
94
  * The loaded control-plane bindings, narrowed to non-undefined — or a throw
84
95
  * the calling hook's catch converts into its own failure posture. Overrides
85
96
  * exist so tests can drive the unavailable arm in-process.
86
- * @param {{ claudeAdapter?: unknown, Decision?: unknown, EventKind?: unknown }} [overrides]
97
+ * @param {{ claudeAdapter?: unknown, Decision?: unknown, EventKind?: unknown, renderEscalated?: unknown }} [overrides]
87
98
  * @returns {{
88
99
  * claudeAdapter: typeof import("agent-control-plane-core/claude").claudeAdapter,
89
100
  * Decision: typeof import("agent-control-plane-core/contract").Decision,
90
101
  * EventKind: typeof import("agent-control-plane-core/contract").EventKind,
102
+ * renderEscalated: typeof import("agent-control-plane-core/contract").renderEscalated,
91
103
  * }}
92
104
  */
93
105
  export function controlPlane(overrides = {}) {
94
- const bindings = { claudeAdapter, Decision, EventKind, ...overrides };
95
- if (!bindings.claudeAdapter || !bindings.Decision || !bindings.EventKind)
106
+ const bindings = {
107
+ claudeAdapter,
108
+ Decision,
109
+ EventKind,
110
+ renderEscalated,
111
+ ...overrides,
112
+ };
113
+ if (
114
+ !bindings.claudeAdapter ||
115
+ !bindings.Decision ||
116
+ !bindings.EventKind ||
117
+ !bindings.renderEscalated
118
+ )
96
119
  throw missingPackageError("agent-control-plane-core");
97
120
  return /** @type {ReturnType<typeof controlPlane>} */ (bindings);
98
121
  }
@@ -101,10 +124,10 @@ export function controlPlane(overrides = {}) {
101
124
  * Serialize a rendered NativeResponse for Claude Code's stdout, or null when
102
125
  * the body carries nothing a silent exit 0 doesn't already say. The adapter's
103
126
  * exit_code is deliberately NOT honored by the hooks: Claude Code parses hook
104
- * stdout as JSON only on exit 0 — under the adapter's exit-2 enforced-deny
105
- * channel it discards stdout and reads the (empty) stderr instead, so the
106
- * deny would land without its reason. For this host the stdout JSON's
107
- * permissionDecision IS the enforcement channel, and hooks always exit 0.
127
+ * stdout as JSON only on exit 0, and that body is the one channel that carries
128
+ * every verdict field (updatedInput, additionalContext) beside the decision.
129
+ * For this host the stdout JSON's permissionDecision IS the enforcement
130
+ * channel, and a rendered verdict always exits 0.
108
131
  * @param {{ stdout?: unknown }} response a NativeResponse from adapter.render
109
132
  * @returns {string | null}
110
133
  */
@@ -133,8 +156,10 @@ export function nativeStdout(response) {
133
156
  * native response. This encodes the two transport invariants every gate hook
134
157
  * shares: stdin is read BEFORE the control-plane bindings are touched, so a
135
158
  * package-load failure still lands in `onError` with the parsed input in hand;
136
- * and the process always exits 0 with the verdict in the stdout JSON (see
137
- * nativeStdout — exit-code enforcement is deliberately not used). Any throw —
159
+ * and a verdict exits 0 in the stdout JSON (see nativeStdout). The verdict
160
+ * renders through the package's renderEscalated: a response with no stdout body
161
+ * that still blocks (a deny on a payload that names no event) blocks through its
162
+ * exit code and stderr instead. Any throw —
138
163
  * unparsable stdin, missing package, a judge error — is reported on stderr and
139
164
  * routed to `onError(err, input)` (`input` undefined when stdin never parsed),
140
165
  * where the hook applies its declared fail posture.
@@ -152,6 +177,8 @@ export function nativeStdout(response) {
152
177
  * @param {(input: unknown) => unknown} [opts.transformInput] raw-payload normalization before adapter.parse
153
178
  * @param {() => Promise<unknown>} [opts.readInput] injectable stdin reader
154
179
  * @param {(chunk: string) => void} [opts.write] injectable stdout writer
180
+ * @param {(chunk: string) => void} [opts.writeErr] injectable stderr writer
181
+ * @param {(code: number) => void} [opts.setExitCode] injectable exit-code setter
155
182
  * @param {import("./hook-timing.mjs").ReportTo} [opts.reportTo] the composer
156
183
  * that owns this hook, which a slow-run notice names and sends the report to;
157
184
  * omitted, the notice names this package
@@ -165,6 +192,10 @@ export async function runJudgeCli(
165
192
  transformInput = (raw) => raw,
166
193
  readInput = readStdinJson,
167
194
  write = (chunk) => process.stdout.write(chunk),
195
+ writeErr = (chunk) => process.stderr.write(chunk),
196
+ setExitCode = (code) => {
197
+ process.exitCode = code;
198
+ },
168
199
  reportTo,
169
200
  },
170
201
  ) {
@@ -190,26 +221,30 @@ export async function runJudgeCli(
190
221
  // over stdin is not this hook's cost, and blaming it for one would send
191
222
  // operators chasing a bug report that is not theirs to fix.
192
223
  timer = startHookTimer();
193
- const { claudeAdapter: adapter } = controlPlane();
224
+ const { claudeAdapter: adapter, renderEscalated: render } = controlPlane();
194
225
  const event = adapter.parse(transformInput(input));
195
226
  tool = event.tool ?? null;
196
227
  // Awaited into its own binding first: as an inline argument, the timer read
197
228
  // would be evaluated BEFORE the judge it is supposed to be timing.
198
229
  const judged = await judge(event);
199
- const out = nativeStdout(
200
- adapter.render(
201
- withSlowHookNotice(hookName, timer.wallMs(), judged, undefined, {
202
- payloadBytes,
203
- tool,
204
- cpuMs: timer.cpuMs(),
205
- redactorMs: timer.redactorMs(),
206
- hostMs: timer.hostMs(),
207
- reportTo,
208
- }),
209
- event,
210
- ),
230
+ const response = render(
231
+ adapter,
232
+ withSlowHookNotice(hookName, timer.wallMs(), judged, undefined, {
233
+ payloadBytes,
234
+ tool,
235
+ cpuMs: timer.cpuMs(),
236
+ redactorMs: timer.redactorMs(),
237
+ hostMs: timer.hostMs(),
238
+ reportTo,
239
+ }),
240
+ event,
211
241
  );
242
+ const out = nativeStdout(response);
212
243
  if (out !== null) write(out);
244
+ else if (response.enforced) {
245
+ writeErr(`${response.stderr}\n`);
246
+ setExitCode(response.exit_code);
247
+ }
213
248
  } catch (err) {
214
249
  process.stderr.write(`${hookName} hook error: ${errMessage(err)}\n`);
215
250
  // After the error line, before the posture: the fault is what the reader
@@ -15,7 +15,7 @@
15
15
  * esbuild inlines them into the plugin bundle (which ships with no config
16
16
  * directory beside it) and Node resolves them natively from the package when the
17
17
  * hooks run from source. Their VALIDATION stays lazy — a malformed credential
18
- * vocabulary throws on first use, inside the consuming hook's fail-closed catch,
18
+ * vocabulary throws on first use, inside the consuming hook's fault catch,
19
19
  * rather than at module load where a throw would abort before that catch installs
20
20
  * and let the harness pass the tool output through UNSANITIZED (fail OPEN).
21
21
  */
@@ -72,7 +72,7 @@ export function configureEnvConfigSource(source) {
72
72
  /**
73
73
  * The stored host source with its shape validated, or null when unconfigured.
74
74
  * Shape errors — a non-object source, a key this seam does not read — throw
75
- * HERE, on first use inside the consuming hook's fail-closed catch, never at
75
+ * HERE, on first use inside the consuming hook's fault catch, never at
76
76
  * configure time: a top-level throw in a bundle entry would kill the hook
77
77
  * before that catch installs (fail OPEN), and silently ignoring the problem
78
78
  * is worse — a typo'd `minSecretLength: 32` must not leave the helpers
@@ -85,41 +85,23 @@ export const FAULT_POLICY_HOOKS = Object.freeze([
85
85
  * The context a builder is handed: the caller's own inputs (whatever it passed
86
86
  * to {@link hookFaultOutcome}) plus the values every hook derived by hand
87
87
  * before — the hook name, the scrubbed error message, and the model-facing
88
- * open-posture warning — and `pinnedClosed`, true when the policy's
89
- * `alwaysClosed` overrode an open knob, so the closed arm can say why.
88
+ * open-posture warning.
90
89
  * @typedef {Record<string, any> & {
91
90
  * hook: string,
92
91
  * err: unknown,
93
92
  * message: string,
94
93
  * openContext: string,
95
- * pinnedClosed: boolean,
96
94
  * }} FaultContext
97
95
  */
98
96
 
99
97
  /**
100
- * Whether a hook's faults are withheld whatever AGENT_SANITIZER_FAIL_OPEN says,
101
- * for the caller's environment.
102
- * @typedef {(
103
- * env: NodeJS.ProcessEnv | Record<string, string | undefined> | undefined,
104
- * ) => boolean} AlwaysClosed
105
- */
106
-
107
- /**
108
- * The `alwaysClosed` of a hook whose posture the knob alone decides.
109
- * @type {AlwaysClosed}
110
- */
111
- export const KNOB_DECIDES = Object.freeze(() => false);
112
-
113
- /**
114
- * A hook's declared posture. `alwaysClosed` is REQUIRED, so every hook states
115
- * on the record whether the knob can open it: {@link KNOB_DECIDES}, or a
116
- * predicate that pins the CLOSED arm — how a guarantee the docs make
117
- * unconditionally (Layer 4 withholds what it could not vet) is enforced in the
118
- * one posture resolver rather than by a hook re-reading the knob.
98
+ * A hook's declared posture. There is deliberately no way for a policy to pin
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.
119
102
  * @typedef {{
120
103
  * event: string | null,
121
104
  * guarded: string,
122
- * alwaysClosed: AlwaysClosed,
123
105
  * open?: (ctx: FaultContext) => FaultParts,
124
106
  * closed: (ctx: FaultContext) => FaultParts,
125
107
  * }} FaultPolicy
@@ -143,12 +125,10 @@ export function registerFaultPolicy(hook, policy) {
143
125
  );
144
126
  // Checked here, not at fault time: a posture that is incomplete must fail the
145
127
  // import, never the one fault it was supposed to answer.
146
- for (const arm of /** @type {const} */ (["alwaysClosed", "closed"]))
147
- if (typeof policy[arm] !== "function")
148
- throw new Error(
149
- `fault policy for ${JSON.stringify(hook)} declares no \`${arm}\`; ` +
150
- "every hook states it explicitly (KNOB_DECIDES for alwaysClosed)",
151
- );
128
+ if (typeof policy.closed !== "function")
129
+ throw new Error(
130
+ `fault policy for ${JSON.stringify(hook)} declares no \`closed\` arm`,
131
+ );
152
132
  policies.set(hook, policy);
153
133
  }
154
134
 
@@ -190,8 +170,7 @@ export function defaultOpen(ctx) {
190
170
  /**
191
171
  * Resolve `hook`'s response to its own failure under the caller's posture. This
192
172
  * is the ONLY place {@link failOpenEnabled} is consulted on a hook fault, so the
193
- * knob cannot be honored in three hooks and skipped in the fourth — and the
194
- * only place a policy's `alwaysClosed` can override it.
173
+ * knob cannot be honored in three hooks and skipped in the fourth.
195
174
  * @param {string} hook
196
175
  * @param {unknown} err
197
176
  * @param {Record<string, any> & {
@@ -204,9 +183,7 @@ export function defaultOpen(ctx) {
204
183
  */
205
184
  export function hookFaultOutcome(hook, err, ctx = {}) {
206
185
  const policy = faultPolicy(hook);
207
- const knobOpen = failOpenEnabled(ctx.env);
208
- const pinnedClosed = knobOpen && policy.alwaysClosed(ctx.env);
209
- const open = knobOpen && !pinnedClosed;
186
+ const open = failOpenEnabled(ctx.env);
210
187
  /** @type {FaultContext} */
211
188
  const full = {
212
189
  ...ctx,
@@ -214,7 +191,6 @@ export function hookFaultOutcome(hook, err, ctx = {}) {
214
191
  err,
215
192
  message: safeErrMessage(err),
216
193
  openContext: failOpenContext(hook, policy.guarded, err),
217
- pinnedClosed,
218
194
  };
219
195
  const parts = (open ? (policy.open ?? defaultOpen) : policy.closed)(full);
220
196
  const fields = parts.fields ?? null;
@@ -17,8 +17,16 @@
17
17
  * EXTENSION it called ({@link chargeHostExtension}) — the redactor daemon and a
18
18
  * host callback's subprocess or socket peer are separate processes whose CPU this
19
19
  * one cannot see (see {@link processCpuMs}), so the call that waits for each is
20
- * the only measurable stand-in. The notice GATES on none of them: a hook wedged
21
- * on a dead redactor socket burns no CPU and is exactly the sanitizer's fault.
20
+ * the only measurable stand-in. A caller that supplies the CPU and redactor
21
+ * figures is reported only when the share its owner answers for — CPU plus
22
+ * redactor round trips plus host extensions — itself exceeds the budget; wall
23
+ * time past that is the machine's, and neither a notice nor a report request is
24
+ * the answer to it. Redactor wait counts toward that share, so a hook wedged on a
25
+ * dead redactor socket (no CPU, all wait) is still reported. A wait on a
26
+ * subprocess this package spawns itself (a `git` probe, a lock) is charged to
27
+ * no window, so it reads as the machine's and goes unreported — the accepted
28
+ * cost of never blaming the sanitizer for a loaded host. A caller that supplies
29
+ * no attribution is gated on wall-clock alone.
22
30
  *
23
31
  * The host-extension window is what turned "blocked on something outside the
24
32
  * sanitizer" — a verdict nobody can act on — into a named callee: a composer's
@@ -598,6 +606,11 @@ export function startHookTimer(now = Date.now, cpuNow = processCpuMs) {
598
606
  * The CPU, redactor and host shares overlap by the framing this side does
599
607
  * mid-round-trip and mid-callback, so they do not sum to the elapsed time; a
600
608
  * share that dominates despite the overlap is still the one to act on.
609
+ *
610
+ * Reached only for a run whose owned share already exceeds the budget
611
+ * ({@link slowHookNotice}), so the "none of them" verdict means the unmeasured
612
+ * remainder is larger than a share that is itself over budget — not that the
613
+ * hook's own cost was small.
601
614
  * @param {number} elapsedMs
602
615
  * @param {number} cpuMs
603
616
  * @param {number} redactorMs
@@ -646,10 +659,15 @@ function attributeWait(elapsedMs, cpuMs, redactorMs, hostMs, owner) {
646
659
  * sanitizer computing, and with `context.redactorMs` too it names the window the
647
660
  * time went into ({@link attributeWait}), including `context.hostMs`'s host
648
661
  * extensions — a caller that measured the first two but has no extensions to
649
- * charge reports that window as zero, which is a measurement and not a shrug. Without them the line says that it cannot
650
- * tell, rather than asserting an attribution nothing measured: a wall-clock
651
- * overrun on a loaded host is the common case, and blaming it on the sanitizer
652
- * sends the operator hunting a per-call cost that does not exist.
662
+ * charge reports that window as zero, which is a measurement and not a shrug.
663
+ * Such a caller is reported only when `cpuMs + redactorMs + hostMs` (an absent
664
+ * `hostMs` counting as 0) exceeds `thresholdMs`: a wall-clock overrun whose
665
+ * owned share is within the budget is the machine's and returns null, so the
666
+ * operator is never asked to report a cost the owner did not spend. Without the
667
+ * measurements the line says that it cannot tell, rather than asserting an
668
+ * attribution nothing measured: a wall-clock overrun on a loaded host is the
669
+ * common case, and blaming it on the sanitizer sends the operator hunting a
670
+ * per-call cost that does not exist.
653
671
  *
654
672
  * A CPU figure alone cannot pick a cause, so with only that the clause names
655
673
  * candidates and commits to none. A hook that blocks on a dead socket inside a
@@ -684,6 +702,8 @@ export function slowHookNotice(
684
702
  // number, including when it is 0.
685
703
  const hostMs =
686
704
  typeof context?.hostMs === "number" ? context.hostMs : undefined;
705
+ if (attributed && cpuMs + redactorMs + (hostMs ?? 0) <= thresholdMs)
706
+ return null;
687
707
  const owner = noticeOwner(context?.reportTo, version);
688
708
  const attribution = attributed
689
709
  ? attributeWait(elapsedMs, cpuMs, redactorMs, hostMs, owner)
@@ -6,9 +6,9 @@
6
6
  * request is just a scan. detect-secrets stays the one and only detection engine
7
7
  * — this module never inspects the text itself.
8
8
  *
9
- * Fail-closed, per call only: a connection/protocol/scan failure throws so the
10
- * caller suppresses THAT output; it sets no session-wide state, so the next call
11
- * retries from scratch. If the socket is absent or dead we (re)spawn the daemon
9
+ * Per call only: a connection/protocol/scan failure throws, and the caller
10
+ * answers THAT output under its fault posture; it sets no session-wide state,
11
+ * so the next call retries from scratch. If the socket is absent or dead we (re)spawn the daemon
12
12
  * once and retry — a crashed daemon self-heals on the next redaction.
13
13
  *
14
14
  * Wire protocol: python/agent_sanitizer/secrets/data/redactor-protocol.json,
@@ -54,7 +54,7 @@ const DAEMON_REASON = /^[\x20-\x7e]{1,200}$/u;
54
54
  export { positiveMsOr } from "./knobs.mjs";
55
55
 
56
56
  // sun_path is 104 bytes on macOS (108 on Linux), NUL included; a longer path
57
- // makes the daemon's bind() fail and every call fail closed. The daemon binds
57
+ // makes the daemon's bind() fail and every call fault. The daemon binds
58
58
  // "." + 16 hex digits before renaming onto "redactor.sock", so the longer
59
59
  // 17-byte name is what must fit.
60
60
  const SOCKET_PATH_MAX = 103;
@@ -100,7 +100,7 @@ export const DEFAULT_SOCKET_PATH = resolveSocketPath(process.env, currentUid());
100
100
  *
101
101
  * 1. `_AGENT_SANITIZER_REDACTOR_DAEMON` — authoritative when set (the launcher
102
102
  * sets it to the provisioned venv's console script when one exists, and
103
- * tests point it at dead paths to drive the fail-closed arm; a fallback
103
+ * tests point it at dead paths to drive the fault arm; a fallback
104
104
  * from an explicit setting would turn those arms into silent passes).
105
105
  * 2. The shipped self-contained zipapp (`plugin/dist/redactor/daemon.pyz`) —
106
106
  * the guaranteed floor: it needs only a python3 on PATH, so a cold start
@@ -126,20 +126,20 @@ function daemonCommand() {
126
126
  }
127
127
  // How long to wait for a freshly-spawned daemon to start accepting. A cold start
128
128
  // pays the detect-secrets import + plugin prime (~1-3s), so the default leaves
129
- // margin; tests shorten it to exercise the give-up-and-fail-closed path quickly.
129
+ // margin; tests shorten it to exercise the give-up path quickly.
130
130
  const WAIT_DEADLINE_MS = readKnob("_AGENT_SANITIZER_REDACTOR_WAIT_MS");
131
131
 
132
- // A daemon that ACCEPTS the connection but then stalls — a deadlock, a
133
- // pathological detect-secrets input, or a half-written length prefix that never
134
- // completes — emits none of the errno codes isRespawnable reacts to and never
135
- // closes, so without a deadline connectAndRequest's Promise never settles.
136
- // redactViaDaemon (the required, fail-closed Layer 4) would then hang until
137
- // Claude Code kills the PostToolUse hook at its own timeout, and a killed hook is
138
- // non-blocking — so the RAW, unredacted tool output is shown (the exact fail-open
139
- // this layer exists to prevent). The total per-connect deadline below makes a
140
- // stall reject, so the caller fails closed. It stays comfortably under the hook
141
- // timeout (two connects + the spawn wait must fit); tests shorten it via the env
142
- // override (read per call) or the deadlineMs parameter to exercise the path fast.
132
+ /**
133
+ * The total per-connect deadline. A daemon that ACCEPTS and then stalls — a
134
+ * deadlock, a pathological detect-secrets input, a length prefix that never
135
+ * completes — emits no errno isRespawnable reacts to and never closes, so
136
+ * without it connectAndRequest never settles and the PostToolUse hook is killed
137
+ * at its timeout, which shows the RAW output with no warning. A stall instead
138
+ * rejects, and the caller's fault posture answers it, warning included. Two
139
+ * connects plus the spawn wait must fit under the hook timeout. Read per call,
140
+ * so tests shorten it through the env override (or the deadlineMs parameter).
141
+ * @returns {number}
142
+ */
143
143
  function requestDeadlineMs() {
144
144
  return readKnob("_AGENT_SANITIZER_REDACTOR_REQUEST_MS");
145
145
  }
@@ -270,9 +270,9 @@ export function classifySocket(socketPath, deps = {}) {
270
270
  * by us or by root, carrying no group/other write bit.
271
271
  *
272
272
  * This refusal is what blocks a co-tenant unlinking our socket and binding their
273
- * own listener that answers "nothing to redact" to every payload, which would flip
274
- * the fail-CLOSED redactor to fail-OPEN and leak unscrubbed secrets to the
275
- * transcript.
273
+ * own listener that answers "nothing to redact" to every payload, which would turn
274
+ * a loud fault into a silent "nothing to redact" and leak unscrubbed secrets to
275
+ * the transcript.
276
276
  *
277
277
  * Root ownership is trusted because it is strictly stronger than our own, not a
278
278
  * concession: a deployment may hand the socket dir to root after the daemon has
@@ -291,9 +291,9 @@ function isTrustedSocketDir(dir, uid) {
291
291
  /**
292
292
  * Open one connection, send `request`, resolve with the parsed response object
293
293
  * (or null). Rejects on connect failure, a malformed/oversize/short frame, or an
294
- * {error} response — every one of which the caller turns into a fail-closed. A
294
+ * {error} response — every one of which the caller treats as a fault. A
295
295
  * socket present but not owned by us, and a request the protocol does not
296
- * accept, fail closed WITHOUT dialing (the error carries no errno, so
296
+ * accept, throw WITHOUT dialing (the error carries no errno, so
297
297
  * isRespawnable is false): we never dial into a squat, and an oversize request
298
298
  * is not mistaken for a dead daemon worth respawning.
299
299
  * @param {string} socketPath
@@ -484,7 +484,7 @@ function canConnect(socketPath) {
484
484
  /**
485
485
  * Redact `text` via the daemon. Returns the response object (`{text, found}` for
486
486
  * plain, `{text, pairs, found}` / `{unmappable}` for map) or null when nothing was
487
- * redacted (plain mode). Throws to fail closed when the text cannot be vetted.
487
+ * redacted (plain mode). Throws when the text cannot be vetted.
488
488
  *
489
489
  * `connect`/`spawn`/`waitForSocket` are injectable seams (default to the real
490
490
  * implementations) so callers can stub the daemon in-process. `deadline` is the