agent-sanitizer 2.67.2 → 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
@@ -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
@@ -17,70 +17,25 @@ import charset from "../../python/agent_sanitizer/data/invisible-charset.json" w
17
17
  // Invisible characters an attacker can splice between a value's characters to
18
18
  // break an exact-substring pre-gate while the daemon's redactor still matches
19
19
  // across them. Built from the SAME pinned charset Layer 1 strips and the daemon
20
- // tolerates (`invisible_run_pattern` in agent_sanitizer.secrets.invisible
21
- // builds the same shape from the same set), not a hand-curated subset: a subset
22
- // pre-gate silently under-matches whenever this gate runs on text Layer 1 has
23
- // not already stripped (the module is exported and callable on its own), and a
24
- // value spliced with a code point in the gap then never reaches the daemon at
25
- // all. A run of zero-or-more is allowed at each interior gap, so the plain
26
- // value still matches (a superset of `includes`). The required literals between
27
- // every gap are what bound the match — a `*` gap is never adjacent to another
28
- // gap, so there is no ambiguity to backtrack over and no ReDoS. (The class size
29
- // is irrelevant to that: a character class matches in O(1) whether it holds one
30
- // member or all 435.) That argument holds only while no literal is itself a
31
- // member of the class, so {@link envValueRegex} drops the value's own invisible
32
- // code points before it splices: `a<ZWSP>a` would otherwise build
33
- // `a[…]*<ZWSP>[…]*a`, whose two stars share the literal and backtrack
34
- // quadratically over a long run of ZWSP.
35
- const ENV_INVIS_CODEPOINTS = [
36
- ...new Set([...charset.cf_codepoints, ...charset.extra_codepoints]),
37
- ].sort((a, b) => a - b);
38
- const ENV_INVIS_SET = new Set(ENV_INVIS_CODEPOINTS);
39
- const ENV_INVIS_RUN =
20
+ // tolerates (`_env_value_spans` in agent_sanitizer.secrets.engine), not
21
+ // a hand-curated subset: a subset pre-gate silently under-matches whenever this
22
+ // gate runs on text Layer 1 has not already stripped, and a value spliced with a
23
+ // code point in the gap then never reaches the daemon at all.
24
+ const ENV_INVIS_RE = new RegExp(
40
25
  "[" +
41
- ENV_INVIS_CODEPOINTS.map((cp) => `\\u{${cp.toString(16)}}`).join("") +
42
- "]*";
43
-
44
- /** @param {string} ch */
45
- const escapeLiteral = (ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
26
+ [...new Set([...charset.cf_codepoints, ...charset.extra_codepoints])]
27
+ .map((cp) => `\\u{${cp.toString(16)}}`)
28
+ .join("") +
29
+ "]",
30
+ "gu",
31
+ );
46
32
 
47
33
  /**
48
- * Regex matching `value` tolerating invisible chars spliced between its
49
- * characters (mirrors the engine's env-value regex). Code-point split so
50
- * an astral character is escaped whole, not as two surrogate halves — and the
51
- * `u` flag, which the astral `\u{…}` class members in {@link ENV_INVIS_RUN}
52
- * require.
53
- * Memoized per distinct value: {@link ENV_INVIS_RUN} renders ~435 code points
54
- * as ~4 KB of source and is joined at EVERY interior gap, so a 20-char secret
55
- * compiles a ~75 KB pattern — and `hasEnvBoundSecret` builds one per configured
56
- * var on every PostToolUse output. Env values are stable for the process's
57
- * lifetime, so the cache is bounded by the number of distinct values. Sharing an
58
- * instance is safe because the regex carries no `g`/`y` flag, hence no
59
- * `lastIndex` state to leak between calls.
60
- * @param {string} value
61
- * @returns {RegExp}
34
+ * `s` with every charset invisible removed.
35
+ * @param {string} s
36
+ * @returns {string}
62
37
  */
63
- export function envValueRegex(value) {
64
- let re = ENV_VALUE_REGEX_CACHE.get(value);
65
- if (re === undefined) {
66
- const visible = [...value].filter(
67
- (ch) => !ENV_INVIS_SET.has(/** @type {number} */ (ch.codePointAt(0))),
68
- );
69
- // A value made only of invisibles has no literal to anchor a gap on: match
70
- // it exactly rather than build a pattern that is nothing but gaps.
71
- re = new RegExp(
72
- visible.length > 0
73
- ? visible.map(escapeLiteral).join(ENV_INVIS_RUN)
74
- : [...value].map(escapeLiteral).join(""),
75
- "u",
76
- );
77
- ENV_VALUE_REGEX_CACHE.set(value, re);
78
- }
79
- return re;
80
- }
81
-
82
- /** value → compiled matcher; see {@link envValueRegex}. */
83
- const ENV_VALUE_REGEX_CACHE = new Map();
38
+ const stripInvisibles = (s) => s.replace(ENV_INVIS_RE, "");
84
39
 
85
40
  /**
86
41
  * True when tool output contains the literal value of a configured env-bound
@@ -88,19 +43,34 @@ const ENV_VALUE_REGEX_CACHE = new Map();
88
43
  * credential, so the pre-gate must also fire on the value itself — otherwise
89
44
  * the engine's env-bound redaction never runs. Invisible-tolerant so a
90
45
  * value with spliced Cf chars (which the daemon still redacts) trips it too.
46
+ *
47
+ * Both sides are stripped and compared as substrings, which is exactly
48
+ * equivalent: the value's visible characters appear contiguously in the
49
+ * stripped text iff they appear in the original separated only by invisibles.
50
+ * It is linear and independent of the value's length — a regex with an
51
+ * invisible class at every gap grows ~4 KB per character, and a long secret (a
52
+ * PEM key) overflows V8's regex size limit and throws on every output.
53
+ *
54
+ * Callers pass post-Layer-1 text: terminal escapes are already gone, so a value
55
+ * split by one is contiguous here. Raw text spliced with an escape is not
56
+ * matched.
91
57
  * @param {string} text
92
58
  * @param {NodeJS.ProcessEnv} [env]
93
59
  * @returns {boolean}
94
60
  */
95
61
  export function hasEnvBoundSecret(text, env = process.env) {
96
62
  const minLen = minEnvSecretLen();
63
+ /** @type {string | undefined} */
64
+ let strippedText;
97
65
  return envBoundSecretVars(env).some((name) => {
98
66
  const value = env[name];
99
- // Code-point length to match envValueRegex's code-point split — an astral
100
- // char counts once, not as two UTF-16 units, so minLen means the same thing
101
- // on both sides of the gate.
102
- return (
103
- value && [...value].length >= minLen && envValueRegex(value).test(text)
104
- );
67
+ // Code-point length, so an astral char counts once, not as two UTF-16 units.
68
+ if (!value || [...value].length < minLen) return false;
69
+ const needle = stripInvisibles(value);
70
+ // A value made only of invisibles has no visible character to anchor on:
71
+ // match it exactly rather than as the empty string, which matches anything.
72
+ if (needle === "") return text.includes(value);
73
+ strippedText ??= stripInvisibles(text);
74
+ return strippedText.includes(needle);
105
75
  });
106
76
  }
@@ -26,7 +26,6 @@ import {
26
26
  } from "./lib/hook-io.mjs";
27
27
  import {
28
28
  registerFaultPolicy,
29
- KNOB_DECIDES,
30
29
  hookFaultOutcome,
31
30
  writeFaultOutcome,
32
31
  } from "./lib/hook-fault.mjs";
@@ -55,7 +54,6 @@ const unknownMode = (/** @type {{ message: string }} */ ctx) => ({
55
54
  registerFaultPolicy(HOOK_NAME, {
56
55
  event: null,
57
56
  guarded: "hook payload",
58
- alwaysClosed: KNOB_DECIDES,
59
57
  open: unknownMode,
60
58
  closed: unknownMode,
61
59
  });
@@ -90,7 +88,7 @@ const LAZY_LOADERS = {
90
88
  * as a non-blocking hook error and shows the raw tool output. That is a
91
89
  * fail-OPEN on the very hook that withholds secrets. Registering what resolves
92
90
  * and leaving the rest absent hands the gap to each hook's own lazyImport
93
- * guard, whose posture is to block.
91
+ * guard, which answers it loudly under the operator's fault posture.
94
92
  * @returns {Promise<void>}
95
93
  */
96
94
  async function registerAvailableModules() {
@@ -44,7 +44,6 @@ import {
44
44
  } from "./lib/hook-io.mjs";
45
45
  import {
46
46
  registerFaultPolicy,
47
- KNOB_DECIDES,
48
47
  hookFaultOutcome,
49
48
  defaultOpen,
50
49
  } from "./lib/hook-fault.mjs";
@@ -845,7 +844,6 @@ export function hookFailureFields(parsedOk, err, opts = {}) {
845
844
  registerFaultPolicy(HOOK_NAME, {
846
845
  event: HookEvent.PRE_TOOL_USE,
847
846
  guarded: "tool input",
848
- alwaysClosed: KNOB_DECIDES,
849
847
  open: (ctx) => {
850
848
  // Non-carve-out faults take the SHARED open rendering, not a copy of it —
851
849
  // hook-fault.mjs owns that body, and a restated one would silently drift.
@@ -50,11 +50,7 @@ import { registerFaultPolicy, hookFaultOutcome } from "./lib/hook-fault.mjs";
50
50
  import { controlPlane, runJudgeCli } from "./lib/control-plane.mjs";
51
51
  import { hookTrace, TraceEvent } from "./lib/trace.mjs";
52
52
  import { hasEnvBoundSecret } from "./lib/secret-annotate.mjs";
53
- import {
54
- digestFlaggingEnabled,
55
- secretsEnabled,
56
- SECRETS_ENABLED_ENV,
57
- } from "./lib/env-config.mjs";
53
+ import { digestFlaggingEnabled, secretsEnabled } from "./lib/env-config.mjs";
58
54
  import {
59
55
  persistReveal,
60
56
  persistSpan,
@@ -120,16 +116,17 @@ const HOOK_NAME = "sanitize-output";
120
116
  // reaches the model-facing context. Exported so tests assert it by reference.
121
117
  export const REVEAL_WITHHELD_WARNING = withheldWarning("reveal sidecar");
122
118
 
123
- // Total wall-clock budget for one hook invocation's blocking daemon calls — the
124
- // Layer-4 redactor — SHARED across every string leaf of the tool output. Each
125
- // call is handed the budget remaining at that moment; once it is spent, a further
126
- // secret-shaped leaf fails CLOSED (the redactor throws → the output is
127
- // suppressed). The per-call timeouts already bound each call, but not their SUM:
128
- // a structured output with many secret-shaped leaves could otherwise pile up past
129
- // the PostToolUse hook kill — a killed hook is non-blocking, so the RAW output
130
- // would be shown (fail OPEN). The default sits comfortably above one legitimately
131
- // slow leaf (a cold redactor respawn + a full scan) yet far under the hook
132
- // timeout; env-tunable so tests can drive the exhausted-budget path fast.
119
+ /**
120
+ * Total wall-clock budget for one hook invocation's blocking Layer-4 redactor
121
+ * calls, SHARED across every string leaf of the tool output. Each call gets the
122
+ * budget remaining at that moment; once it is spent, a further secret-shaped
123
+ * leaf throws, answered under the operator's fault posture. The per-call
124
+ * timeouts bound each call but not their SUM, and a structured output with many
125
+ * secret-shaped leaves could otherwise run past the PostToolUse hook kill,
126
+ * which shows the RAW output with no warning. The default sits above one slow
127
+ * leaf (a cold redactor respawn plus a full scan) and far under the hook
128
+ * timeout; env-tunable so tests can drive the exhausted-budget path fast.
129
+ */
133
130
  const SANITIZE_BUDGET_MS = readKnob("_AGENT_SANITIZER_SANITIZE_BUDGET_MS");
134
131
 
135
132
  // Web-ingress tools always get the Layer 2 HTML rewrite; local tools — Read,
@@ -172,11 +169,13 @@ function isUntrustedIngress(toolName) {
172
169
 
173
170
  /**
174
171
  * Redact secrets via the long-lived redactor daemon (lib/redactor-client.mjs).
175
- * Returns `{text, found}` or null when nothing was redacted; throws (fail closed)
176
- * when secret-shaped text cannot be vetted, which the caller turns into
177
- * suppression. The cheap pre-gate runs first so plain output never touches the
178
- * daemon. A transient daemon failure fails only THIS call — no session-wide
179
- * sentinel — and the client respawns a dead daemon on the next call.
172
+ * Returns `{text, found}` or null when nothing was redacted; throws when
173
+ * secret-shaped text cannot be vetted, which the CLI answers under the
174
+ * operator's fault posture. The cheap pre-gate runs first so plain output never
175
+ * touches the daemon. `text` is post-Layer-1, so it carries no terminal escape
176
+ * and the pre-gate needs no escape-stripped view. A transient daemon failure
177
+ * fails only THIS call — no session-wide sentinel — and the client respawns a
178
+ * dead daemon on the next call.
180
179
  * @param {string} text
181
180
  * @param {boolean} webIngress
182
181
  * @param {{remainingMs: () => number}} [deadline] shared wall-clock budget
@@ -288,9 +287,9 @@ export async function sanitizeText(
288
287
  // Layer 4 — OPT-IN (`secretsOn`): with the knob unset the seam gets no
289
288
  // redact callback at all, so plain output never spawns the daemon and no
290
289
  // placeholder ever enters the model's view. When it runs, the seam rethrows
291
- // a redactor throw wrapped, and this hook's fault policy suppresses the
292
- // output under either posture (alwaysClosed). Surface the failure to the
293
- // operator's terminal here first: the suppression note rides in what only
290
+ // a redactor throw wrapped, and this hook's fault policy answers it under
291
+ // the operator's AGENT_SANITIZER_FAIL_OPEN posture. Surface the failure to
292
+ // the operator's terminal here first: the fault note rides in what only
294
293
  // the model sees, so a degraded redactor would otherwise be invisible to
295
294
  // the human.
296
295
  redact: !secretsOn
@@ -579,7 +578,7 @@ function failClosedParts(
579
578
  * fail-OPEN by default — a warning context and NO `updatedToolOutput`, leaving
580
579
  * the original tool output in the model's view — or the fail-closed
581
580
  * suppression of {@link emitFailClosed} when the caller set
582
- * AGENT_SANITIZER_FAIL_OPEN=0, or opted into Layer 4 (see the policy below).
581
+ * AGENT_SANITIZER_FAIL_OPEN=0.
583
582
  *
584
583
  * This is the hook where the two postures diverge the most, so state the open
585
584
  * one plainly: several of these layers throw on inputs an attacker composes (a
@@ -625,29 +624,17 @@ function suppressionMessage(cause) {
625
624
  return `[SANITIZATION FAILED — original output suppressed for safety. Hook error: ${cause}]`;
626
625
  }
627
626
 
628
- /** Why an open knob still suppressed: the secret layer's own guarantee. */
629
- const SECRETS_PIN_NOTE =
630
- ` Secret redaction is on (${SECRETS_ENABLED_ENV}=1), so output it could ` +
631
- "not vet is withheld under either AGENT_SANITIZER_FAIL_OPEN posture.";
632
-
633
627
  // This hook's entry in the one posture table (lib/hook-fault.mjs). OPEN is the
634
628
  // shared default (a warning context, the original output left in the model's
635
629
  // view); CLOSED replaces every string leaf of the output with the placeholder.
636
- // With Layer 4 opted in, a fault here means the output was never vetted for
637
- // secrets — a dead daemon, a spent budget, a walk that overflowed before the
638
- // redactor ran — so this pin is what keeps the open default from handing an
639
- // unredacted credential to the model.
630
+ // Layer 4 follows the same knob: a redactor that cannot run (a dead daemon, a
631
+ // spent budget) leaves the output unvetted and says so, rather than blocking
632
+ // every tool call on an availability failure.
640
633
  registerFaultPolicy(HOOK_NAME, {
641
634
  event: HookEvent.POST_TOOL_USE,
642
635
  guarded: "tool output",
643
- alwaysClosed: secretsEnabled,
644
636
  closed: (ctx) =>
645
- failClosedParts(
646
- ctx.input,
647
- suppressionMessage(ctx.message) +
648
- (ctx.pinnedClosed ? SECRETS_PIN_NOTE : ""),
649
- ctx.remedy,
650
- ),
637
+ failClosedParts(ctx.input, suppressionMessage(ctx.message), ctx.remedy),
651
638
  });
652
639
 
653
640
  /**
@@ -28,7 +28,6 @@ import {
28
28
  } from "./lib/hook-io.mjs";
29
29
  import {
30
30
  registerFaultPolicy,
31
- KNOB_DECIDES,
32
31
  hookFaultOutcome,
33
32
  writeFaultOutcome,
34
33
  } from "./lib/hook-fault.mjs";
@@ -86,7 +85,6 @@ const HOOK_NAME = "sanitize-user-prompt";
86
85
  registerFaultPolicy(HOOK_NAME, {
87
86
  event: HookEvent.USER_PROMPT_SUBMIT,
88
87
  guarded: "prompt",
89
- alwaysClosed: KNOB_DECIDES,
90
88
  closed: (ctx) => ({
91
89
  envelope: {
92
90
  decision: "block",
@@ -39,7 +39,6 @@ import {
39
39
  } from "./lib/hook-io.mjs";
40
40
  import {
41
41
  registerFaultPolicy,
42
- KNOB_DECIDES,
43
42
  hookFaultOutcome,
44
43
  writeFaultOutcome,
45
44
  } from "./lib/hook-fault.mjs";
@@ -166,7 +165,6 @@ function faultLine(ctx) {
166
165
  registerFaultPolicy(HOOK_NAME, {
167
166
  event: HookEvent.SESSION_START,
168
167
  guarded: "instruction files",
169
- alwaysClosed: KNOB_DECIDES,
170
168
  open: (ctx) => ({
171
169
  stderr: `${faultLine(ctx)} Passing through unguarded; set AGENT_SANITIZER_FAIL_OPEN=0 to arm the tool-call gate instead.\n`,
172
170
  exitCode: 1,
@@ -32,7 +32,6 @@ import {
32
32
  } from "./lib/hook-io.mjs";
33
33
  import {
34
34
  registerFaultPolicy,
35
- KNOB_DECIDES,
36
35
  hookFaultOutcome,
37
36
  writeFaultOutcome,
38
37
  } from "./lib/hook-fault.mjs";
@@ -123,7 +122,6 @@ function emptyPayloadParts(ctx) {
123
122
  registerFaultPolicy(HOOK_NAME, {
124
123
  event: HookEvent.INSTRUCTIONS_LOADED,
125
124
  guarded: "a loaded instruction file",
126
- alwaysClosed: KNOB_DECIDES,
127
125
  open: (ctx) =>
128
126
  emptyPayloadParts(ctx) ?? {
129
127
  stderr: `${faultLine(ctx)} Passing through unguarded; set AGENT_SANITIZER_FAIL_OPEN=0 to arm the tool-call gate instead.\n`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.67.2",
3
+ "version": "2.68.0",
4
4
  "description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
5
5
  "type": "module",
6
6
  "repository": {
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
  }
@@ -599,8 +600,8 @@ async function vetStageValue(text, redact, findings, label) {
599
600
  return secrets ? normalizeLoneSurrogates(secrets.text) : text;
600
601
  } catch {
601
602
  // 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.
603
+ // reason is an unrunnable redactor — the same fault that makes `cleaned`
604
+ // throw, just with a survivable remedy here.
604
605
  findings.push(warning(withheldWarning(label)));
605
606
  return undefined;
606
607
  }
@@ -629,11 +630,10 @@ async function vetStageValue(text, redact, findings, label) {
629
630
 
630
631
  /**
631
632
  * 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.
633
+ * rest are opt-in via `options`. A Layer 4 (`redact`) redactor that throws is
634
+ * rethrown wrapped, so the caller answers it under its fault posture rather
635
+ * than receiving unvetted text as vetted. The same holds for Layer 4's re-scan
636
+ * after a Layer-5 span deletion (see Layer 5, below).
637
637
  * `reveal` is the pre-Layer-2 text, present only when the HTML splice removed
638
638
  * bytes, so a caller can persist what was hidden for later inspection (see
639
639
  * {@link applyMarkdownPipeline}); the field is omitted otherwise, and also when
@@ -677,7 +677,7 @@ export async function sanitizeText(text, options = {}) {
677
677
  const { reveal: revealText, splices: stageSplices } =
678
678
  await applyMarkdownPipeline(state, options);
679
679
 
680
- // Layer 4 — fail closed (see runRedact).
680
+ // Layer 4 — throws on a redactor fault (see runRedact).
681
681
  await runRedact(state, options);
682
682
 
683
683
  // Layer 5 — secure span-deletion slot (see module doc). A warning-only result
@@ -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,23 @@ 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 or block a prompt unless the operator set the knob to
114
+ * `0`. A pin turns any bug in the guarded layer into every tool call failing.
181
115
  */
182
116
  export type FaultPolicy = {
183
117
  event: string | null;
184
118
  guarded: string;
185
- alwaysClosed: AlwaysClosed;
186
119
  open?: (ctx: FaultContext) => FaultParts;
187
120
  closed: (ctx: FaultContext) => FaultParts;
188
121
  };
@@ -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
@@ -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}
@@ -177,7 +177,7 @@ export function emitFailClosed(input: any, message: string, emit?: (fields: Reco
177
177
  * fail-OPEN by default — a warning context and NO `updatedToolOutput`, leaving
178
178
  * the original tool output in the model's view — or the fail-closed
179
179
  * suppression of {@link emitFailClosed} when the caller set
180
- * AGENT_SANITIZER_FAIL_OPEN=0, or opted into Layer 4 (see the policy below).
180
+ * AGENT_SANITIZER_FAIL_OPEN=0.
181
181
  *
182
182
  * This is the hook where the two postures diverge the most, so state the open
183
183
  * one plainly: several of these layers throw on inputs an attacker composes (a
@@ -55,11 +55,10 @@ export function deleteVerbatimSpans(text: string, spans: string[]): {
55
55
  */
56
56
  /**
57
57
  * Run the configured layers over a single text blob. Layer 1 always runs; the
58
- * rest are opt-in via `options`. Layer 4 (`redact`) is the fail-closed path: a
59
- * redactor that throws is rethrown wrapped, so the caller suppresses the
60
- * output rather than emitting an unvetted value. That fail-closed behavior
61
- * also applies to Layer 4's re-scan after a Layer-5 span deletion (see Layer
62
- * 5, below) — a redactor failure there fails the whole call closed too.
58
+ * rest are opt-in via `options`. A Layer 4 (`redact`) redactor that throws is
59
+ * rethrown wrapped, so the caller answers it under its fault posture rather
60
+ * than receiving unvetted text as vetted. The same holds for Layer 4's re-scan
61
+ * after a Layer-5 span deletion (see Layer 5, below).
63
62
  * `reveal` is the pre-Layer-2 text, present only when the HTML splice removed
64
63
  * bytes, so a caller can persist what was hidden for later inspection (see
65
64
  * {@link applyMarkdownPipeline}); the field is omitted otherwise, and also when