agent-sanitizer 2.68.0 → 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.
@@ -11,6 +11,28 @@
11
11
  * @returns {string}
12
12
  */
13
13
  export function dropFingerprint(filePath: string, content: string, dropped?: string[]): string;
14
+ /**
15
+ * Fingerprint of one refusal of one exact tool call — same refusing site, same
16
+ * tool, same input — released by re-issuing that call. Keyed on the site too, so
17
+ * a call refused by two sites in turn needs a confirmation for each and one
18
+ * re-issue never releases a refusal the model was not shown. The input is
19
+ * hashed in canonical key order, so a re-issue whose keys arrive reordered is
20
+ * still the same call. Distinct domain tags keep it disjoint from
21
+ * {@link dropFingerprint}.
22
+ * @param {string} site
23
+ * @param {unknown} tool
24
+ * @param {unknown} toolInput
25
+ * @returns {string}
26
+ */
27
+ export function reissueFingerprint(site: string, tool: unknown, toolInput: unknown): string;
28
+ /**
29
+ * Record a confirm sentinel for `fingerprint`, so the identical re-issue is
30
+ * approved by {@link consumeConfirm}. False when it could not be recorded: the
31
+ * caller then must not refuse, because nothing could ever release the refusal.
32
+ * @param {string} fingerprint
33
+ * @returns {boolean}
34
+ */
35
+ export function recordConfirm(fingerprint: string): boolean;
14
36
  /**
15
37
  * The confirm sentinel's path for one fingerprint. Predictable and
16
38
  * world-visible like the alert markers, hence the markerIsTrusted read.
@@ -50,15 +72,16 @@ export function gitTracked(filePath: string, spawn?: typeof spawnSync): boolean;
50
72
  * rehydration substituted any placeholders — so a preserved secret shows up as
51
73
  * its real value in `content`. `io` is the rehydrate-shaped I/O bag (readFile /
52
74
  * redact / redactMap). Injectable seams: `isTracked` (the git probe),
53
- * `confirmSeen`/`recordConfirm` (the sentinel state).
75
+ * `confirmSeen`/`recordConfirm` (the sentinel state). A deny whose confirmation
76
+ * could not be recorded carries `fault` (see {@link dropUnconfirmable}).
54
77
  * @param {{file_path?: unknown, content?: unknown}} toolInput
55
78
  * @param {import("agent-sanitizer/rehydrate").RehydrateIo} io
56
79
  * @param {{
57
80
  * isTracked?: (filePath: string) => boolean,
58
81
  * confirmSeen?: (fingerprint: string) => boolean,
59
- * recordConfirm?: (fingerprint: string) => void,
82
+ * recordConfirm?: (fingerprint: string) => boolean,
60
83
  * }} [opts]
61
- * @returns {Promise<{deny: string} | null>}
84
+ * @returns {Promise<{deny: string, fault?: string} | null>}
62
85
  */
63
86
  export function secretDropGuard(toolInput: {
64
87
  file_path?: unknown;
@@ -66,9 +89,10 @@ export function secretDropGuard(toolInput: {
66
89
  }, io: import("agent-sanitizer/rehydrate").RehydrateIo, opts?: {
67
90
  isTracked?: (filePath: string) => boolean;
68
91
  confirmSeen?: (fingerprint: string) => boolean;
69
- recordConfirm?: (fingerprint: string) => void;
92
+ recordConfirm?: (fingerprint: string) => boolean;
70
93
  }): Promise<{
71
94
  deny: string;
95
+ fault?: string;
72
96
  } | null>;
73
97
  /**
74
98
  * Compose a Layer-4 rehydrator with this guard: for a Write the rehydrator
@@ -76,14 +100,41 @@ export function secretDropGuard(toolInput: {
76
100
  * substitution — so a rehydrated secret counts as preserved and only a
77
101
  * genuinely dropped one can trip it. A rehydrate deny short-circuits (the
78
102
  * guard never runs), non-Write tools skip the guard entirely, and a guard
79
- * deny wins over a pass/rewrite result.
80
- * @typedef {{updatedInput: any, context: string} | {deny: string} | null} RehydrateResult
103
+ * deny wins over a pass/rewrite result. A guard FAULT deny carries the
104
+ * rewrite as `release`: the open posture lets that call proceed, and it must
105
+ * proceed rehydrated, or every secret the rewrite restored is overwritten by
106
+ * its placeholder.
107
+ * @typedef {{updatedInput: any, context: string}} RehydrateRewrite
108
+ * @typedef {RehydrateRewrite | {deny: string, fault?: string, release?: RehydrateRewrite} | null} RehydrateResult
81
109
  * @param {(tool: string, toolInput: any) => Promise<RehydrateResult>} rehydrate
82
110
  * @param {import("agent-sanitizer/rehydrate").RehydrateIo} io
83
111
  * @param {typeof secretDropGuard} [guard]
84
112
  * @returns {(tool: string, toolInput: any) => Promise<RehydrateResult>}
85
113
  */
86
114
  export function withSecretDropGuard(rehydrate: (tool: string, toolInput: any) => Promise<RehydrateResult>, io: import("agent-sanitizer/rehydrate").RehydrateIo, guard?: typeof secretDropGuard): (tool: string, toolInput: any) => Promise<RehydrateResult>;
115
+ /**
116
+ * True when a trusted, fresh confirm sentinel exists for `fingerprint`,
117
+ * consuming it so one confirmation approves exactly one call. A sentinel
118
+ * older than {@link CONFIRM_TTL_MS} is stale — an abandoned deny from a
119
+ * previous session, not a live confirmation — so it is removed and NOT
120
+ * honored, preventing a leftover marker from acting as a standing
121
+ * auto-approval. Removal is best-effort: a fresh marker we could not unlink
122
+ * still counted as consumed for THIS call, and a leftover empty sentinel only
123
+ * ever re-approves the identical fingerprinted call the model already
124
+ * confirmed — and only within the TTL.
125
+ * @param {string} fingerprint
126
+ * @returns {boolean}
127
+ */
128
+ export function consumeConfirm(fingerprint: string): boolean;
129
+ /**
130
+ * True when a trusted confirm sentinel younger than {@link CONFIRM_TTL_MS}
131
+ * exists for `fingerprint`, WITHOUT consuming it — for a caller that learns
132
+ * only later whether the call it confirms actually proceeds, and consumes it
133
+ * then.
134
+ * @param {string} fingerprint
135
+ * @returns {boolean}
136
+ */
137
+ export function confirmFresh(fingerprint: string): boolean;
87
138
  /** A confirm sentinel older than this is stale, not a confirmation. */
88
139
  export const CONFIRM_TTL_MS: number;
89
140
  /**
@@ -92,12 +143,29 @@ export const CONFIRM_TTL_MS: number;
92
143
  * substitution — so a rehydrated secret counts as preserved and only a
93
144
  * genuinely dropped one can trip it. A rehydrate deny short-circuits (the
94
145
  * guard never runs), non-Write tools skip the guard entirely, and a guard
95
- * deny wins over a pass/rewrite result.
146
+ * deny wins over a pass/rewrite result. A guard FAULT deny carries the
147
+ * rewrite as `release`: the open posture lets that call proceed, and it must
148
+ * proceed rehydrated, or every secret the rewrite restored is overwritten by
149
+ * its placeholder.
96
150
  */
97
- export type RehydrateResult = {
151
+ export type RehydrateRewrite = {
98
152
  updatedInput: any;
99
153
  context: string;
100
- } | {
154
+ };
155
+ /**
156
+ * Compose a Layer-4 rehydrator with this guard: for a Write the rehydrator
157
+ * did not deny, run the guard on the FINAL content — after any placeholder
158
+ * substitution — so a rehydrated secret counts as preserved and only a
159
+ * genuinely dropped one can trip it. A rehydrate deny short-circuits (the
160
+ * guard never runs), non-Write tools skip the guard entirely, and a guard
161
+ * deny wins over a pass/rewrite result. A guard FAULT deny carries the
162
+ * rewrite as `release`: the open posture lets that call proceed, and it must
163
+ * proceed rehydrated, or every secret the rewrite restored is overwritten by
164
+ * its placeholder.
165
+ */
166
+ export type RehydrateResult = RehydrateRewrite | {
101
167
  deny: string;
168
+ fault?: string;
169
+ release?: RehydrateRewrite;
102
170
  } | null;
103
171
  import { spawnSync } from "node:child_process";
@@ -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
@@ -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
  */
@@ -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
  /**