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
@@ -29,13 +29,14 @@
29
29
  * @param {boolean} [secretsOn] the run's secret opt-in (decided once by
30
30
  * buildPreToolUseResponse; a direct caller reads the env); only with it on
31
31
  * does the context claim the restored bytes were redacted
32
- * @returns {{ updatedInput: any, context: string } | { deny: string } | null}
32
+ * @returns {{ updatedInput: any, context: string } | { deny: string, fault?: string } | null}
33
33
  */
34
34
  export function rehydrateLayer2(tool: string, toolInput: any, secretsOn?: boolean): {
35
35
  updatedInput: any;
36
36
  context: string;
37
37
  } | {
38
38
  deny: string;
39
+ fault?: string;
39
40
  } | null;
40
41
  /**
41
42
  * The declared stage chain after the gate. Every entry states the two properties
@@ -53,9 +54,12 @@ export function rehydrateLayer2(tool: string, toolInput: any, secretsOn?: boolea
53
54
  * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
54
55
  * @param {boolean} [secretsOn] the run's secret opt-in (see
55
56
  * buildPreToolUseResponse, which decides it once and passes it here)
57
+ * @param {FaultLedger} [ledger] where a fault deny the open posture lets
58
+ * through leaves its warning; required as soon as a stage reports a fault,
59
+ * since a warning with nowhere to go would let the call through silently
56
60
  * @returns {import("./lib/layer-pipeline.mjs").Layer[]}
57
61
  */
58
- export function preToolUseLayers(rehydrate: ((tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>) | null, env?: NodeJS.ProcessEnv | Record<string, string | undefined>, secretsOn?: boolean): import("./lib/layer-pipeline.mjs").Layer[];
62
+ export function preToolUseLayers(rehydrate: ((tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>) | null, env?: NodeJS.ProcessEnv | Record<string, string | undefined>, secretsOn?: boolean, ledger?: FaultLedger): import("./lib/layer-pipeline.mjs").Layer[];
59
63
  /**
60
64
  * Compose the five stages. Returns the `hookSpecificOutput` fields to emit, or
61
65
  * null for a clean no-op. Throws only if a stage's engine throws; the caller
@@ -79,9 +83,11 @@ export function buildPreToolUseResponse(input: any, rehydrate?: (tool: string, t
79
83
  * ToolCallEvent and returns a Verdict, so a non-Claude host can run the same
80
84
  * sanitization pipeline through its own adapter. The wired Claude CLI below
81
85
  * routes through this judge and renders the Verdict with the Claude adapter; on
82
- * any throw (a control-plane package-load failure included) it falls back to
83
- * hookFailureFields — a native response that needs no package — so the
84
- * caller's fault posture holds even when the adapter never loaded.
86
+ * any throw (a control-plane package-load failure, or an event the adapter
87
+ * could not classify) it falls back to hookFailureFields — a native response
88
+ * that needs no package — so the caller's fault posture holds even when the
89
+ * adapter never loaded. A host calling this directly renders its throws the
90
+ * same way.
85
91
  * @param {import("agent-control-plane-core").ToolCallEvent} event
86
92
  * @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
87
93
  * @param {{
@@ -147,8 +153,8 @@ export function failClosedFields(parsedOk: boolean, err: unknown, opts?: {
147
153
  }): Record<string, unknown>;
148
154
  /**
149
155
  * True when a faulting PreToolUse call is the one case the OPEN posture must
150
- * still not pass: a write-shaped tool whose input carries a redaction
151
- * placeholder, matched by the real grammar (containsPlaceholder) so
156
+ * not pass on the first try: a write-shaped tool whose input carries a
157
+ * redaction placeholder, matched by the real grammar (containsPlaceholder) so
152
158
  * hint-prefixed prose such as `[REDACTED…]` is not mistaken for one. Such a
153
159
  * placeholder stands for a secret the sanitizer redacted out of the model's
154
160
  * view; with the sanitizer down, rehydration cannot translate it back, so
@@ -159,7 +165,7 @@ export function failClosedFields(parsedOk: boolean, err: unknown, opts?: {
159
165
  *
160
166
  * Deliberately NOT exhaustive over every clobber path: Bash can also write a
161
167
  * placeholder to disk (`>`, `tee`, `sed -i`, a heredoc), but command strings
162
- * mention "[REDACTED" benignly far too often for an ask to hold precision
168
+ * mention "[REDACTED" benignly far too often for a refusal to hold precision
163
169
  * there. That is an accepted gap, named in THREAT-MODEL.md's carve-out.
164
170
  * @param {unknown} input raw parsed PreToolUse payload (undefined if unparsed)
165
171
  * @returns {boolean}
@@ -172,8 +178,9 @@ export function hintedWriteFault(input: unknown): boolean;
172
178
  * fail-CLOSED verdict of {@link failClosedFields} when the caller set
173
179
  * AGENT_SANITIZER_FAIL_OPEN=0. The open default has ONE carve-out, declared in
174
180
  * this hook's fault policy below: a write-shaped call whose input carries a
175
- * `[REDACTED…]` placeholder asks instead of passing (see
176
- * {@link hintedWriteFault}) — pass `input` so the policy can see it.
181
+ * `[REDACTED…]` placeholder is refused once, and its exact re-issue proceeds
182
+ * with a warning (see {@link hintedWriteFault}) — pass `input` so the policy
183
+ * can see it.
177
184
  *
178
185
  * The posture covers this hook's own failures, whatever their cause. What it
179
186
  * does NOT cover is the verdict of a sanitizer that ran: a payload
@@ -239,6 +246,17 @@ export const PRE_TOOL_USE_MESSAGES: Readonly<{
239
246
  remedy: string;
240
247
  }>;
241
248
  export const REDACTION_HINT: "[REDACTED";
249
+ /**
250
+ * What the stage table hands back besides the pipeline's own result: the
251
+ * warnings of fault denies the open posture let through (the pipeline drops a
252
+ * stage's note when the input is unchanged, and these calls proceed unchanged),
253
+ * and the re-issue confirmations they rode on, consumed only once the call is
254
+ * known to proceed.
255
+ */
256
+ export type FaultLedger = {
257
+ warnings: string[];
258
+ released: string[];
259
+ };
242
260
  /**
243
261
  * A host-supplied deny gate: given the PreToolUse input, the reason this call
244
262
  * must be blocked, or null to let the pipeline continue. Hosts use these for
@@ -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
@@ -2,8 +2,9 @@
2
2
  * Judge a normalized prompt-submit event. Agent-agnostic: consumes the
3
3
  * control-plane ToolCallEvent and returns a Verdict, so the same prompt gate
4
4
  * renders through any agent adapter, not just Claude's. Throws (into the
5
- * calling hook's catch) when the sanitizer package never loaded, so a prompt it
6
- * cannot classify takes the hook's fault posture rather than a silent ALLOW.
5
+ * calling hook's catch) when the sanitizer package never loaded or the payload
6
+ * is no recognizable event, so a prompt it cannot classify takes the hook's
7
+ * fault posture rather than a silent ALLOW.
7
8
  * @param {import("agent-control-plane-core").ToolCallEvent} event
8
9
  * @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
9
10
  * to the package's stripAnsiFully; injectable so the fault path is testable)
@@ -28,9 +28,9 @@ export function scanProject(dir?: string): {
28
28
  absent: string[];
29
29
  };
30
30
  /**
31
- * The report for targets the scan could not read. Rendered into the alert the
32
- * PreToolUse gate surfaces, so an incomplete scan reaches the operator as a
33
- * checkpoint rather than as silence.
31
+ * The report for targets the scan could not read: a SessionStart warning under
32
+ * the open posture, and the alert the PreToolUse gate surfaces under the closed
33
+ * one — either way an incomplete scan reaches the operator rather than silence.
34
34
  * @param {Array<{file: string, reason: string}>} skipped
35
35
  * @returns {string}
36
36
  */
@@ -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
@@ -8,6 +8,12 @@
8
8
  * there is nothing to do. Throws only on internal error (the caller fails
9
9
  * closed).
10
10
  *
11
+ * A deny carrying `fault` is not a verdict on the input but this module being
12
+ * unable to do its job (an unreadable target, a redactor map that fails
13
+ * validation): `fault` states what proceeding un-rehydrated would write, and the
14
+ * caller's failure posture decides whether the deny stands. A deny without it
15
+ * is a verdict and stands in every posture.
16
+ *
11
17
  * `io` is the injected I/O (file read + redactor map/plain). `hint` is the
12
18
  * redaction-placeholder prefix (defaults to {@link DEFAULT_HINT}); override it
13
19
  * only if the injected redactor emits a different placeholder shape.
@@ -15,7 +21,7 @@
15
21
  * @param {any} toolInput
16
22
  * @param {RehydrateIo} io
17
23
  * @param {{ hint?: string }} [options]
18
- * @returns {Promise<{updatedInput: any, context: string} | {deny: string} | null>}
24
+ * @returns {Promise<{updatedInput: any, context: string} | {deny: string, fault?: string} | null>}
19
25
  */
20
26
  export function rehydrateRedacted(tool: string, toolInput: any, io: RehydrateIo, { hint }?: {
21
27
  hint?: string;
@@ -24,6 +30,7 @@ export function rehydrateRedacted(tool: string, toolInput: any, io: RehydrateIo,
24
30
  context: string;
25
31
  } | {
26
32
  deny: string;
33
+ fault?: string;
27
34
  } | null>;
28
35
  export const DEFAULT_HINT: "[REDACTED";
29
36
  /**