agent-sanitizer 2.67.1 → 2.67.2
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.
|
@@ -37,6 +37,8 @@ let claudeAdapter;
|
|
|
37
37
|
let Decision;
|
|
38
38
|
/** @type {typeof import("agent-control-plane-core/contract").EventKind | undefined} */
|
|
39
39
|
let EventKind;
|
|
40
|
+
/** @type {typeof import("agent-control-plane-core/contract").renderEscalated | undefined} */
|
|
41
|
+
let renderEscalated;
|
|
40
42
|
|
|
41
43
|
/* c8 ignore start -- module-load boundary: the real import resolves in every
|
|
42
44
|
in-process test and spawned CLI run, and a missing node_modules can't be
|
|
@@ -55,26 +57,35 @@ const loaded = await awaitLazyDependency({
|
|
|
55
57
|
);
|
|
56
58
|
// The /contract subpath, not the package root: the root barrel also pulls
|
|
57
59
|
// all four adapters, the registry and the conformance harness, which costs
|
|
58
|
-
// ~22 ms of import work per process for
|
|
59
|
-
// process per tool call, so that cost lands on every gated call.
|
|
60
|
-
const {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
// ~22 ms of import work per process for three bindings. A guardrail hook is
|
|
61
|
+
// one process per tool call, so that cost lands on every gated call.
|
|
62
|
+
const {
|
|
63
|
+
Decision: decision,
|
|
64
|
+
EventKind: eventKind,
|
|
65
|
+
renderEscalated: escalated,
|
|
66
|
+
} = /** @type {Partial<typeof import("agent-control-plane-core/contract")>} */ (
|
|
67
|
+
await lazyImport("agent-control-plane-core/contract")
|
|
68
|
+
);
|
|
69
|
+
if (!adapter || !decision || !eventKind || !escalated) return null;
|
|
70
|
+
return {
|
|
71
|
+
claudeAdapter: adapter,
|
|
72
|
+
Decision: decision,
|
|
73
|
+
EventKind: eventKind,
|
|
74
|
+
renderEscalated: escalated,
|
|
75
|
+
};
|
|
66
76
|
},
|
|
67
77
|
markerPresent: () => markerIsTrusted(marker),
|
|
68
78
|
setupAlive: () => probeSetupAlive(marker),
|
|
69
79
|
});
|
|
70
80
|
if (loaded) {
|
|
71
81
|
const bound =
|
|
72
|
-
/** @type {{ claudeAdapter: typeof claudeAdapter, Decision: typeof Decision, EventKind: typeof EventKind }} */ (
|
|
82
|
+
/** @type {{ claudeAdapter: typeof claudeAdapter, Decision: typeof Decision, EventKind: typeof EventKind, renderEscalated: typeof renderEscalated }} */ (
|
|
73
83
|
loaded
|
|
74
84
|
);
|
|
75
85
|
claudeAdapter = bound.claudeAdapter;
|
|
76
86
|
Decision = bound.Decision;
|
|
77
87
|
EventKind = bound.EventKind;
|
|
88
|
+
renderEscalated = bound.renderEscalated;
|
|
78
89
|
}
|
|
79
90
|
// Stryker restore all
|
|
80
91
|
/* c8 ignore stop */
|
|
@@ -83,16 +94,28 @@ if (loaded) {
|
|
|
83
94
|
* The loaded control-plane bindings, narrowed to non-undefined — or a throw
|
|
84
95
|
* the calling hook's catch converts into its own failure posture. Overrides
|
|
85
96
|
* exist so tests can drive the unavailable arm in-process.
|
|
86
|
-
* @param {{ claudeAdapter?: unknown, Decision?: unknown, EventKind?: unknown }} [overrides]
|
|
97
|
+
* @param {{ claudeAdapter?: unknown, Decision?: unknown, EventKind?: unknown, renderEscalated?: unknown }} [overrides]
|
|
87
98
|
* @returns {{
|
|
88
99
|
* claudeAdapter: typeof import("agent-control-plane-core/claude").claudeAdapter,
|
|
89
100
|
* Decision: typeof import("agent-control-plane-core/contract").Decision,
|
|
90
101
|
* EventKind: typeof import("agent-control-plane-core/contract").EventKind,
|
|
102
|
+
* renderEscalated: typeof import("agent-control-plane-core/contract").renderEscalated,
|
|
91
103
|
* }}
|
|
92
104
|
*/
|
|
93
105
|
export function controlPlane(overrides = {}) {
|
|
94
|
-
const bindings = {
|
|
95
|
-
|
|
106
|
+
const bindings = {
|
|
107
|
+
claudeAdapter,
|
|
108
|
+
Decision,
|
|
109
|
+
EventKind,
|
|
110
|
+
renderEscalated,
|
|
111
|
+
...overrides,
|
|
112
|
+
};
|
|
113
|
+
if (
|
|
114
|
+
!bindings.claudeAdapter ||
|
|
115
|
+
!bindings.Decision ||
|
|
116
|
+
!bindings.EventKind ||
|
|
117
|
+
!bindings.renderEscalated
|
|
118
|
+
)
|
|
96
119
|
throw missingPackageError("agent-control-plane-core");
|
|
97
120
|
return /** @type {ReturnType<typeof controlPlane>} */ (bindings);
|
|
98
121
|
}
|
|
@@ -101,10 +124,10 @@ export function controlPlane(overrides = {}) {
|
|
|
101
124
|
* Serialize a rendered NativeResponse for Claude Code's stdout, or null when
|
|
102
125
|
* the body carries nothing a silent exit 0 doesn't already say. The adapter's
|
|
103
126
|
* exit_code is deliberately NOT honored by the hooks: Claude Code parses hook
|
|
104
|
-
* stdout as JSON only on exit 0
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
127
|
+
* stdout as JSON only on exit 0, and that body is the one channel that carries
|
|
128
|
+
* every verdict field (updatedInput, additionalContext) beside the decision.
|
|
129
|
+
* For this host the stdout JSON's permissionDecision IS the enforcement
|
|
130
|
+
* channel, and a rendered verdict always exits 0.
|
|
108
131
|
* @param {{ stdout?: unknown }} response a NativeResponse from adapter.render
|
|
109
132
|
* @returns {string | null}
|
|
110
133
|
*/
|
|
@@ -133,8 +156,10 @@ export function nativeStdout(response) {
|
|
|
133
156
|
* native response. This encodes the two transport invariants every gate hook
|
|
134
157
|
* shares: stdin is read BEFORE the control-plane bindings are touched, so a
|
|
135
158
|
* package-load failure still lands in `onError` with the parsed input in hand;
|
|
136
|
-
* and
|
|
137
|
-
*
|
|
159
|
+
* and a verdict exits 0 in the stdout JSON (see nativeStdout). The verdict
|
|
160
|
+
* renders through the package's renderEscalated: a response with no stdout body
|
|
161
|
+
* that still blocks (a deny on a payload that names no event) blocks through its
|
|
162
|
+
* exit code and stderr instead. Any throw —
|
|
138
163
|
* unparsable stdin, missing package, a judge error — is reported on stderr and
|
|
139
164
|
* routed to `onError(err, input)` (`input` undefined when stdin never parsed),
|
|
140
165
|
* where the hook applies its declared fail posture.
|
|
@@ -152,6 +177,8 @@ export function nativeStdout(response) {
|
|
|
152
177
|
* @param {(input: unknown) => unknown} [opts.transformInput] raw-payload normalization before adapter.parse
|
|
153
178
|
* @param {() => Promise<unknown>} [opts.readInput] injectable stdin reader
|
|
154
179
|
* @param {(chunk: string) => void} [opts.write] injectable stdout writer
|
|
180
|
+
* @param {(chunk: string) => void} [opts.writeErr] injectable stderr writer
|
|
181
|
+
* @param {(code: number) => void} [opts.setExitCode] injectable exit-code setter
|
|
155
182
|
* @param {import("./hook-timing.mjs").ReportTo} [opts.reportTo] the composer
|
|
156
183
|
* that owns this hook, which a slow-run notice names and sends the report to;
|
|
157
184
|
* omitted, the notice names this package
|
|
@@ -165,6 +192,10 @@ export async function runJudgeCli(
|
|
|
165
192
|
transformInput = (raw) => raw,
|
|
166
193
|
readInput = readStdinJson,
|
|
167
194
|
write = (chunk) => process.stdout.write(chunk),
|
|
195
|
+
writeErr = (chunk) => process.stderr.write(chunk),
|
|
196
|
+
setExitCode = (code) => {
|
|
197
|
+
process.exitCode = code;
|
|
198
|
+
},
|
|
168
199
|
reportTo,
|
|
169
200
|
},
|
|
170
201
|
) {
|
|
@@ -190,26 +221,30 @@ export async function runJudgeCli(
|
|
|
190
221
|
// over stdin is not this hook's cost, and blaming it for one would send
|
|
191
222
|
// operators chasing a bug report that is not theirs to fix.
|
|
192
223
|
timer = startHookTimer();
|
|
193
|
-
const { claudeAdapter: adapter } = controlPlane();
|
|
224
|
+
const { claudeAdapter: adapter, renderEscalated: render } = controlPlane();
|
|
194
225
|
const event = adapter.parse(transformInput(input));
|
|
195
226
|
tool = event.tool ?? null;
|
|
196
227
|
// Awaited into its own binding first: as an inline argument, the timer read
|
|
197
228
|
// would be evaluated BEFORE the judge it is supposed to be timing.
|
|
198
229
|
const judged = await judge(event);
|
|
199
|
-
const
|
|
200
|
-
adapter
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
),
|
|
230
|
+
const response = render(
|
|
231
|
+
adapter,
|
|
232
|
+
withSlowHookNotice(hookName, timer.wallMs(), judged, undefined, {
|
|
233
|
+
payloadBytes,
|
|
234
|
+
tool,
|
|
235
|
+
cpuMs: timer.cpuMs(),
|
|
236
|
+
redactorMs: timer.redactorMs(),
|
|
237
|
+
hostMs: timer.hostMs(),
|
|
238
|
+
reportTo,
|
|
239
|
+
}),
|
|
240
|
+
event,
|
|
211
241
|
);
|
|
242
|
+
const out = nativeStdout(response);
|
|
212
243
|
if (out !== null) write(out);
|
|
244
|
+
else if (response.enforced) {
|
|
245
|
+
writeErr(`${response.stderr}\n`);
|
|
246
|
+
setExitCode(response.exit_code);
|
|
247
|
+
}
|
|
213
248
|
} catch (err) {
|
|
214
249
|
process.stderr.write(`${hookName} hook error: ${errMessage(err)}\n`);
|
|
215
250
|
// After the error line, before the posture: the fault is what the reader
|
|
@@ -145,9 +145,8 @@ export function judgeSanitizeUserPrompt(
|
|
|
145
145
|
const { Decision, EventKind } = controlPlane();
|
|
146
146
|
// A payload the adapter cannot classify carries no readable prompt, so an
|
|
147
147
|
// abstain would fail OPEN on harness contract drift; this gate's posture is
|
|
148
|
-
// deny-when-blind. (
|
|
149
|
-
//
|
|
150
|
-
// honors on UserPromptSubmit.)
|
|
148
|
+
// deny-when-blind. (runJudgeCli's renderEscalated blocks it with exit 2 and
|
|
149
|
+
// the reason on stderr, which Claude honors on UserPromptSubmit.)
|
|
151
150
|
if (event.event === EventKind.UNKNOWN)
|
|
152
151
|
return { decision: Decision.DENY, reason: messages.unknownEvent };
|
|
153
152
|
if (event.event !== EventKind.PROMPT_SUBMIT)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.67.
|
|
3
|
+
"version": "2.67.2",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -227,7 +227,7 @@
|
|
|
227
227
|
"SECURITY.md"
|
|
228
228
|
],
|
|
229
229
|
"dependencies": {
|
|
230
|
-
"agent-control-plane-core": "0.
|
|
230
|
+
"agent-control-plane-core": "0.11.2",
|
|
231
231
|
"css-tree": "^3.2.1",
|
|
232
232
|
"hast-util-from-parse5": "8.0.3",
|
|
233
233
|
"mdast-util-gfm": "3.1.0",
|
|
@@ -2,30 +2,33 @@
|
|
|
2
2
|
* The loaded control-plane bindings, narrowed to non-undefined — or a throw
|
|
3
3
|
* the calling hook's catch converts into its own failure posture. Overrides
|
|
4
4
|
* exist so tests can drive the unavailable arm in-process.
|
|
5
|
-
* @param {{ claudeAdapter?: unknown, Decision?: unknown, EventKind?: unknown }} [overrides]
|
|
5
|
+
* @param {{ claudeAdapter?: unknown, Decision?: unknown, EventKind?: unknown, renderEscalated?: unknown }} [overrides]
|
|
6
6
|
* @returns {{
|
|
7
7
|
* claudeAdapter: typeof import("agent-control-plane-core/claude").claudeAdapter,
|
|
8
8
|
* Decision: typeof import("agent-control-plane-core/contract").Decision,
|
|
9
9
|
* EventKind: typeof import("agent-control-plane-core/contract").EventKind,
|
|
10
|
+
* renderEscalated: typeof import("agent-control-plane-core/contract").renderEscalated,
|
|
10
11
|
* }}
|
|
11
12
|
*/
|
|
12
13
|
export function controlPlane(overrides?: {
|
|
13
14
|
claudeAdapter?: unknown;
|
|
14
15
|
Decision?: unknown;
|
|
15
16
|
EventKind?: unknown;
|
|
17
|
+
renderEscalated?: unknown;
|
|
16
18
|
}): {
|
|
17
19
|
claudeAdapter: typeof import("agent-control-plane-core/claude").claudeAdapter;
|
|
18
20
|
Decision: typeof import("agent-control-plane-core/contract").Decision;
|
|
19
21
|
EventKind: typeof import("agent-control-plane-core/contract").EventKind;
|
|
22
|
+
renderEscalated: typeof import("agent-control-plane-core/contract").renderEscalated;
|
|
20
23
|
};
|
|
21
24
|
/**
|
|
22
25
|
* Serialize a rendered NativeResponse for Claude Code's stdout, or null when
|
|
23
26
|
* the body carries nothing a silent exit 0 doesn't already say. The adapter's
|
|
24
27
|
* exit_code is deliberately NOT honored by the hooks: Claude Code parses hook
|
|
25
|
-
* stdout as JSON only on exit 0
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
28
|
+
* stdout as JSON only on exit 0, and that body is the one channel that carries
|
|
29
|
+
* every verdict field (updatedInput, additionalContext) beside the decision.
|
|
30
|
+
* For this host the stdout JSON's permissionDecision IS the enforcement
|
|
31
|
+
* channel, and a rendered verdict always exits 0.
|
|
29
32
|
* @param {{ stdout?: unknown }} response a NativeResponse from adapter.render
|
|
30
33
|
* @returns {string | null}
|
|
31
34
|
*/
|
|
@@ -38,8 +41,10 @@ export function nativeStdout(response: {
|
|
|
38
41
|
* native response. This encodes the two transport invariants every gate hook
|
|
39
42
|
* shares: stdin is read BEFORE the control-plane bindings are touched, so a
|
|
40
43
|
* package-load failure still lands in `onError` with the parsed input in hand;
|
|
41
|
-
* and
|
|
42
|
-
*
|
|
44
|
+
* and a verdict exits 0 in the stdout JSON (see nativeStdout). The verdict
|
|
45
|
+
* renders through the package's renderEscalated: a response with no stdout body
|
|
46
|
+
* that still blocks (a deny on a payload that names no event) blocks through its
|
|
47
|
+
* exit code and stderr instead. Any throw —
|
|
43
48
|
* unparsable stdin, missing package, a judge error — is reported on stderr and
|
|
44
49
|
* routed to `onError(err, input)` (`input` undefined when stdin never parsed),
|
|
45
50
|
* where the hook applies its declared fail posture.
|
|
@@ -57,15 +62,19 @@ export function nativeStdout(response: {
|
|
|
57
62
|
* @param {(input: unknown) => unknown} [opts.transformInput] raw-payload normalization before adapter.parse
|
|
58
63
|
* @param {() => Promise<unknown>} [opts.readInput] injectable stdin reader
|
|
59
64
|
* @param {(chunk: string) => void} [opts.write] injectable stdout writer
|
|
65
|
+
* @param {(chunk: string) => void} [opts.writeErr] injectable stderr writer
|
|
66
|
+
* @param {(code: number) => void} [opts.setExitCode] injectable exit-code setter
|
|
60
67
|
* @param {import("./hook-timing.mjs").ReportTo} [opts.reportTo] the composer
|
|
61
68
|
* that owns this hook, which a slow-run notice names and sends the report to;
|
|
62
69
|
* omitted, the notice names this package
|
|
63
70
|
* @returns {Promise<void>}
|
|
64
71
|
*/
|
|
65
|
-
export function runJudgeCli(hookName: string, judge: (event: import("agent-control-plane-core").ToolCallEvent) => import("agent-control-plane-core").Verdict | Promise<import("agent-control-plane-core").Verdict>, { onError, transformInput, readInput, write, reportTo, }: {
|
|
72
|
+
export function runJudgeCli(hookName: string, judge: (event: import("agent-control-plane-core").ToolCallEvent) => import("agent-control-plane-core").Verdict | Promise<import("agent-control-plane-core").Verdict>, { onError, transformInput, readInput, write, writeErr, setExitCode, reportTo, }: {
|
|
66
73
|
onError: (err: unknown, input: unknown) => void;
|
|
67
74
|
transformInput?: ((input: unknown) => unknown) | undefined;
|
|
68
75
|
readInput?: (() => Promise<unknown>) | undefined;
|
|
69
76
|
write?: ((chunk: string) => void) | undefined;
|
|
77
|
+
writeErr?: ((chunk: string) => void) | undefined;
|
|
78
|
+
setExitCode?: ((code: number) => void) | undefined;
|
|
70
79
|
reportTo?: import("./hook-timing.mjs").ReportTo | undefined;
|
|
71
80
|
}): Promise<void>;
|