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