agent-sanitizer 2.67.0 → 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 two enums. A guardrail hook is one
59
- // process per tool call, so that cost lands on every gated call.
60
- const { Decision: decision, EventKind: eventKind } =
61
- /** @type {Partial<typeof import("agent-control-plane-core/contract")>} */ (
62
- await lazyImport("agent-control-plane-core/contract")
63
- );
64
- if (!adapter || !decision || !eventKind) return null;
65
- return { claudeAdapter: adapter, Decision: decision, EventKind: eventKind };
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 = { claudeAdapter, Decision, EventKind, ...overrides };
95
- if (!bindings.claudeAdapter || !bindings.Decision || !bindings.EventKind)
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 — under the adapter's exit-2 enforced-deny
105
- * channel it discards stdout and reads the (empty) stderr instead, so the
106
- * deny would land without its reason. For this host the stdout JSON's
107
- * permissionDecision IS the enforcement channel, and hooks always exit 0.
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 the process always exits 0 with the verdict in the stdout JSON (see
137
- * nativeStdout — exit-code enforcement is deliberately not used). Any throw —
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 out = nativeStdout(
200
- adapter.render(
201
- withSlowHookNotice(hookName, timer.wallMs(), judged, undefined, {
202
- payloadBytes,
203
- tool,
204
- cpuMs: timer.cpuMs(),
205
- redactorMs: timer.redactorMs(),
206
- hostMs: timer.hostMs(),
207
- reportTo,
208
- }),
209
- event,
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. (Renders through the adapter's top-level decision:"block"
149
- // channel — a non-PRE_TOOL event has no permissionDecision body — which Claude
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.0",
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.8.1",
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 — under the adapter's exit-2 enforced-deny
26
- * channel it discards stdout and reads the (empty) stderr instead, so the
27
- * deny would land without its reason. For this host the stdout JSON's
28
- * permissionDecision IS the enforcement channel, and hooks always exit 0.
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 the process always exits 0 with the verdict in the stdout JSON (see
42
- * nativeStdout — exit-code enforcement is deliberately not used). Any throw —
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>;