agent-sanitizer 2.8.0 → 2.9.1
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/claude-hooks/lib/control-plane.mjs +39 -14
- package/claude-hooks/lib/hook-io.mjs +262 -3
- package/claude-hooks/plugin-hooks.mjs +1 -0
- package/claude-hooks/pretooluse-sanitize.mjs +136 -26
- package/claude-hooks/sanitize-output.mjs +29 -14
- package/claude-hooks/sanitize-user-prompt.mjs +95 -34
- package/claude-hooks/scan-invisible-chars.mjs +56 -7
- package/package.json +29 -1
- package/types/claude-hooks/lib/hook-io.d.mts +119 -0
- package/types/claude-hooks/pretooluse-sanitize.d.mts +71 -3
- package/types/claude-hooks/sanitize-output.d.mts +22 -4
- package/types/claude-hooks/sanitize-user-prompt.d.mts +27 -2
- package/types/claude-hooks/scan-invisible-chars.d.mts +3 -3
|
@@ -19,9 +19,29 @@ export function buildPreToolUseResponse(input: any, rehydrate?: (tool: string, t
|
|
|
19
19
|
* fail-closed posture holds even when the adapter never loaded.
|
|
20
20
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
21
21
|
* @param {(tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>} [rehydrate]
|
|
22
|
+
* @param {{ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>, gates?: HostGate[] }} [opts]
|
|
23
|
+
* messages are merged over the defaults, so a partial table is supported
|
|
22
24
|
* @returns {Promise<import("agent-control-plane-core").Verdict>}
|
|
23
25
|
*/
|
|
24
|
-
export function judgePreToolUseSanitize(event: import("agent-control-plane-core").ToolCallEvent, rehydrate?: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted
|
|
26
|
+
export function judgePreToolUseSanitize(event: import("agent-control-plane-core").ToolCallEvent, rehydrate?: (tool: string, toolInput: any) => ReturnType<typeof rehydrateRedacted>, opts?: {
|
|
27
|
+
messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
|
|
28
|
+
gates?: HostGate[];
|
|
29
|
+
}): Promise<import("agent-control-plane-core").Verdict>;
|
|
30
|
+
/**
|
|
31
|
+
* The dependency-load failure hiding behind a hook error, or "". A binding that
|
|
32
|
+
* never loaded surfaces at use time as a bare TypeError ("X is not a function")
|
|
33
|
+
* that names neither the package nor the remedy; when any lazily-loaded package
|
|
34
|
+
* has a recorded load error, name it — the failed set is derived from the
|
|
35
|
+
* loader's own records, so a future dependency is covered without editing a list
|
|
36
|
+
* here. An error already reporting a missing package (missingPackageError's
|
|
37
|
+
* `DEP_UNAVAILABLE` tag) gets no second copy.
|
|
38
|
+
* @param {unknown} err
|
|
39
|
+
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
40
|
+
* @param {() => string[]} [failedPackages]
|
|
41
|
+
* @param {(pkg: string) => unknown} [loadErrorFor]
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function depLoadHint(err: unknown, remedy?: string, failedPackages?: () => string[], loadErrorFor?: (pkg: string) => unknown): string;
|
|
25
45
|
/**
|
|
26
46
|
* The fail-closed hookSpecificOutput fields for a hook-level failure, chosen by
|
|
27
47
|
* WHICH failure it was. Corrupt/unparsable INPUT (`parsedOk` false — a JSON parse
|
|
@@ -32,16 +52,64 @@ export function judgePreToolUseSanitize(event: import("agent-control-plane-core"
|
|
|
32
52
|
* so it ASKS to keep a human in the loop rather than hard-block on infrastructure.
|
|
33
53
|
* @param {boolean} parsedOk whether the input parsed before the failure
|
|
34
54
|
* @param {unknown} err
|
|
55
|
+
* @param {{ messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>, hint?: string }} [opts]
|
|
35
56
|
* @returns {Record<string, unknown>}
|
|
36
57
|
*/
|
|
37
|
-
export function failClosedFields(parsedOk: boolean, err: unknown
|
|
58
|
+
export function failClosedFields(parsedOk: boolean, err: unknown, opts?: {
|
|
59
|
+
messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
|
|
60
|
+
hint?: string;
|
|
61
|
+
}): Record<string, unknown>;
|
|
38
62
|
/**
|
|
39
63
|
* The hook's CLI: parse → judge → render, with this hook's fail-closed posture.
|
|
40
64
|
* Exported so a bundle entry (which must claim the CLI slot before this module
|
|
41
65
|
* loads) can run the exact same wiring instead of duplicating the onError
|
|
42
66
|
* posture.
|
|
67
|
+
* @param {{
|
|
68
|
+
* messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>,
|
|
69
|
+
* gates?: HostGate[],
|
|
70
|
+
* }} [opts]
|
|
43
71
|
* @returns {Promise<void>}
|
|
44
72
|
*/
|
|
45
|
-
export function cliMain(
|
|
73
|
+
export function cliMain(opts?: {
|
|
74
|
+
messages?: Partial<typeof PRE_TOOL_USE_MESSAGES>;
|
|
75
|
+
gates?: HostGate[];
|
|
76
|
+
}): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* A host-supplied deny gate: given the PreToolUse input, the reason this call
|
|
79
|
+
* must be blocked, or null to let the pipeline continue. Hosts use these for
|
|
80
|
+
* policy the package has no view of (a required workflow step, a project-local
|
|
81
|
+
* rule); the package ships none.
|
|
82
|
+
* @typedef {(input: { tool_name: string | null, tool_input: any, session_id?: string })
|
|
83
|
+
* => string | null | undefined} HostGate
|
|
84
|
+
*/
|
|
85
|
+
/**
|
|
86
|
+
* The reasons this hook emits, as a table a host overrides. A host that knows
|
|
87
|
+
* which of ITS files wires the adapter, and what a reader should do about a
|
|
88
|
+
* failure, can say so — the package cannot, since it has no idea where it is
|
|
89
|
+
* installed.
|
|
90
|
+
* @type {Readonly<{
|
|
91
|
+
* unknownEvent: string,
|
|
92
|
+
* failed: (cause: string) => string,
|
|
93
|
+
* unparsable: (cause: string) => string,
|
|
94
|
+
* remedy: string,
|
|
95
|
+
* }>}
|
|
96
|
+
*/
|
|
97
|
+
export const PRE_TOOL_USE_MESSAGES: Readonly<{
|
|
98
|
+
unknownEvent: string;
|
|
99
|
+
failed: (cause: string) => string;
|
|
100
|
+
unparsable: (cause: string) => string;
|
|
101
|
+
remedy: string;
|
|
102
|
+
}>;
|
|
103
|
+
/**
|
|
104
|
+
* A host-supplied deny gate: given the PreToolUse input, the reason this call
|
|
105
|
+
* must be blocked, or null to let the pipeline continue. Hosts use these for
|
|
106
|
+
* policy the package has no view of (a required workflow step, a project-local
|
|
107
|
+
* rule); the package ships none.
|
|
108
|
+
*/
|
|
109
|
+
export type HostGate = (input: {
|
|
110
|
+
tool_name: string | null;
|
|
111
|
+
tool_input: any;
|
|
112
|
+
session_id?: string;
|
|
113
|
+
}) => string | null | undefined;
|
|
46
114
|
declare const rehydrateRedacted: typeof import("agent-sanitizer/rehydrate").rehydrateRedacted;
|
|
47
115
|
export {};
|
|
@@ -30,6 +30,11 @@
|
|
|
30
30
|
* @property {(record: { tool: string | null, modified: boolean, output: unknown, context?: string }) => Promise<void> | void} [audit]
|
|
31
31
|
* Awaited once per judged event that carried a tool response, with the output
|
|
32
32
|
* the model will actually see.
|
|
33
|
+
* @property {string} [remedy]
|
|
34
|
+
* What a reader should run when the sanitizer's own bindings are what is
|
|
35
|
+
* missing. This hook's host channel is `ext`, where the other two gates use a
|
|
36
|
+
* frozen message table; either way it is one channel per gate, so a host
|
|
37
|
+
* cannot supply its wording somewhere the fail-closed context never reads.
|
|
33
38
|
*/
|
|
34
39
|
/**
|
|
35
40
|
* Run Layers 1-4 over a single text blob, delegated to the package's output seam
|
|
@@ -118,12 +123,17 @@ export function failClosedReplacement(input: any, message: string): any;
|
|
|
118
123
|
*/
|
|
119
124
|
export function sanitizerDepsLoaded(): boolean;
|
|
120
125
|
/**
|
|
121
|
-
* The model-facing note for a fail-closed emission
|
|
122
|
-
*
|
|
126
|
+
* The model-facing note for a fail-closed emission. When the sanitizer's own
|
|
127
|
+
* bindings are what is absent — a broken INSTALL rather than a broken hook, and
|
|
128
|
+
* otherwise invisible because every later tool call then fails closed with no
|
|
129
|
+
* stated cause — the recorded loader error and its remedy ride along. The text
|
|
130
|
+
* comes from missingPackageMessage so this hook, the PreToolUse gate and the
|
|
131
|
+
* prompt gate cannot drift apart on what a missing dependency reads like.
|
|
123
132
|
* @param {() => boolean} [depsLoaded] injectable seam for testing
|
|
133
|
+
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
124
134
|
* @returns {string}
|
|
125
135
|
*/
|
|
126
|
-
export function failClosedContext(depsLoaded?: () => boolean): string;
|
|
136
|
+
export function failClosedContext(depsLoaded?: () => boolean, remedy?: string): string;
|
|
127
137
|
/**
|
|
128
138
|
* Emit a fail-closed PostToolUse response, robust to the suppression itself
|
|
129
139
|
* throwing. The shape-matching replacement walks `input.tool_response` and the
|
|
@@ -137,9 +147,10 @@ export function failClosedContext(depsLoaded?: () => boolean): string;
|
|
|
137
147
|
* @param {any} input parsed hook input, or undefined if parsing threw
|
|
138
148
|
* @param {string} message
|
|
139
149
|
* @param {(fields: Record<string, unknown>) => void} [emit]
|
|
150
|
+
* @param {string} [remedy] what a reader should run; hosts pass their own
|
|
140
151
|
* @returns {void}
|
|
141
152
|
*/
|
|
142
|
-
export function emitFailClosed(input: any, message: string, emit?: (fields: Record<string, unknown>) => void): void;
|
|
153
|
+
export function emitFailClosed(input: any, message: string, emit?: (fields: Record<string, unknown>) => void, remedy?: string): void;
|
|
143
154
|
/**
|
|
144
155
|
* Run the sanitization pipeline over a tool output and return the contract-
|
|
145
156
|
* shaped verdict fields — `mutated_output` (the shape-matching sanitized value)
|
|
@@ -261,4 +272,11 @@ export type SanitizeExtensions = {
|
|
|
261
272
|
output: unknown;
|
|
262
273
|
context?: string;
|
|
263
274
|
}) => Promise<void> | void) | undefined;
|
|
275
|
+
/**
|
|
276
|
+
* What a reader should run when the sanitizer's own bindings are what is
|
|
277
|
+
* missing. This hook's host channel is `ext`, where the other two gates use a
|
|
278
|
+
* frozen message table; either way it is one channel per gate, so a host
|
|
279
|
+
* cannot supply its wording somewhere the fail-closed context never reads.
|
|
280
|
+
*/
|
|
281
|
+
remedy?: string | undefined;
|
|
264
282
|
};
|
|
@@ -8,16 +8,41 @@
|
|
|
8
8
|
* @param {import("agent-control-plane-core").ToolCallEvent} event
|
|
9
9
|
* @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
|
|
10
10
|
* to the package's stripAnsiFully; injectable so the fail-closed path is testable)
|
|
11
|
+
* @param {Partial<typeof USER_PROMPT_MESSAGES>} [overrides] reason overrides,
|
|
12
|
+
* merged over the defaults so a partial table can never leave a field unset
|
|
11
13
|
* @returns {import("agent-control-plane-core").Verdict}
|
|
12
14
|
*/
|
|
13
|
-
export function judgeSanitizeUserPrompt(event: import("agent-control-plane-core").ToolCallEvent, strip?: ((s: string) => string) | null): import("agent-control-plane-core").Verdict;
|
|
15
|
+
export function judgeSanitizeUserPrompt(event: import("agent-control-plane-core").ToolCallEvent, strip?: ((s: string) => string) | null, overrides?: Partial<typeof USER_PROMPT_MESSAGES>): import("agent-control-plane-core").Verdict;
|
|
14
16
|
/**
|
|
15
17
|
* @param {() => Promise<any> | any} read
|
|
16
18
|
* @param {(chunk: string) => void} write
|
|
17
19
|
* @param {((s: string) => string) | null} [strip] the ANSI stripper (defaults
|
|
18
20
|
* to the package's stripAnsiFully; injectable so the fail-closed path is testable)
|
|
21
|
+
* @param {Partial<typeof USER_PROMPT_MESSAGES>} [overrides] reason overrides,
|
|
22
|
+
* merged over the defaults so a partial table can never leave a field unset
|
|
19
23
|
* @returns {Promise<void>}
|
|
20
24
|
*/
|
|
21
|
-
export function main(read: () => Promise<any> | any, write: (chunk: string) => void, strip?: ((s: string) => string) | null): Promise<void>;
|
|
25
|
+
export function main(read: () => Promise<any> | any, write: (chunk: string) => void, strip?: ((s: string) => string) | null, overrides?: Partial<typeof USER_PROMPT_MESSAGES>): Promise<void>;
|
|
22
26
|
/** @type {typeof import("agent-sanitizer/prompt").classifyPrompt} */
|
|
23
27
|
export let classifyPrompt: typeof import("agent-sanitizer/prompt").classifyPrompt;
|
|
28
|
+
/**
|
|
29
|
+
* The reasons this gate emits, as a table a host overrides. A host that knows
|
|
30
|
+
* which of ITS files wires the adapter, and what a reader should do about a
|
|
31
|
+
* failure, can say so — the package cannot, since it has no idea where it is
|
|
32
|
+
* installed. Every field is a plain string or a string-returning function, so
|
|
33
|
+
* an override is auditable next to the default it replaces.
|
|
34
|
+
* @type {Readonly<{
|
|
35
|
+
* unknownEvent: string,
|
|
36
|
+
* blockContext: string,
|
|
37
|
+
* sgrNote: string,
|
|
38
|
+
* hookFailed: (cause: string) => string,
|
|
39
|
+
* remedy: string,
|
|
40
|
+
* }>}
|
|
41
|
+
*/
|
|
42
|
+
export const USER_PROMPT_MESSAGES: Readonly<{
|
|
43
|
+
unknownEvent: string;
|
|
44
|
+
blockContext: string;
|
|
45
|
+
sgrNote: string;
|
|
46
|
+
hookFailed: (cause: string) => string;
|
|
47
|
+
remedy: string;
|
|
48
|
+
}>;
|
|
@@ -42,9 +42,9 @@ export function scanFile(filePath: string): Array<{
|
|
|
42
42
|
}>;
|
|
43
43
|
import { ALERT_FILE } from "./lib/invisible-alert.mjs";
|
|
44
44
|
import { ALERT_ACK_FILE } from "./lib/invisible-alert.mjs";
|
|
45
|
-
export
|
|
46
|
-
export
|
|
47
|
-
export
|
|
45
|
+
export let LONG_RUN_RE: RegExp;
|
|
46
|
+
export let LONG_RUN_THRESHOLD: 10;
|
|
47
|
+
export let TOTAL_INVISIBLE_THRESHOLD: 30;
|
|
48
48
|
/**
|
|
49
49
|
* @param {Array<{
|
|
50
50
|
* file: string,
|