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.
- package/README.md +8 -6
- package/THREAT-MODEL.md +31 -18
- package/claude-hooks/lib/hook-fault.mjs +4 -2
- package/claude-hooks/lib/hook-io.mjs +84 -9
- package/claude-hooks/lib/invisible-alert.mjs +6 -3
- package/claude-hooks/lib/secret-drop-guard.mjs +136 -29
- package/claude-hooks/plugin-hooks.mjs +29 -24
- package/claude-hooks/pretooluse-sanitize.mjs +288 -62
- package/claude-hooks/sanitize-user-prompt.mjs +28 -15
- package/claude-hooks/scan-invisible-chars.mjs +86 -33
- package/claude-hooks/scan-loaded-instructions.mjs +20 -14
- package/package.json +1 -1
- package/src/html.mjs +17 -17
- package/src/output.mjs +5 -4
- package/src/rehydrate.mjs +36 -9
- package/types/claude-hooks/lib/hook-fault.d.mts +4 -2
- package/types/claude-hooks/lib/hook-io.d.mts +32 -6
- package/types/claude-hooks/lib/invisible-alert.d.mts +6 -3
- package/types/claude-hooks/lib/secret-drop-guard.d.mts +77 -9
- package/types/claude-hooks/pretooluse-sanitize.d.mts +28 -10
- package/types/claude-hooks/sanitize-user-prompt.d.mts +3 -2
- package/types/claude-hooks/scan-invisible-chars.d.mts +3 -3
- package/types/rehydrate.d.mts +8 -1
|
@@ -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) =>
|
|
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) =>
|
|
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
|
-
*
|
|
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
|
|
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
|
|
83
|
-
*
|
|
84
|
-
* caller's fault posture holds even when the
|
|
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
|
-
*
|
|
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
|
|
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
|
|
176
|
-
* {@link hintedWriteFault}) — pass `input` so the policy
|
|
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
|
|
6
|
-
* cannot classify takes the hook's
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
*/
|
package/types/rehydrate.d.mts
CHANGED
|
@@ -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
|
/**
|