immune-brain 3.3.0 → 3.5.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.
@@ -1,13 +1,22 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { createInterface } from "node:readline";
2
3
  import { stdin, stdout } from "node:process";
3
4
  import type { Readable, Writable } from "node:stream";
4
5
  import { CORE_CONTRACT, HOST_ID, MIN_CLAUDE_CODE_VERSION, probeHost } from "./capability";
5
6
  import { PLUGIN_VERSION } from "../plugin_version";
6
- import { isPrivilegedOperation, privilegedAnnotations, type NativeDecision } from "./interaction";
7
+ import {
8
+ isPrivilegedOperation,
9
+ NativeAuthorityError,
10
+ privilegedAnnotations,
11
+ type NativeConfirmationInput,
12
+ type NativeConfirmationPort,
13
+ } from "./interaction";
7
14
  import { ClaudeReviewHost, FileHookEventLog, parseHookStdin } from "./review_host";
8
15
  import { ClaudeRuntime, type ToolMeta } from "./kernel_ports";
9
16
  import type { AssuranceCoordinatorPorts } from "../assurance/coordinator";
10
17
 
18
+ export const MCP_PROTOCOL_VERSION = "2025-06-18";
19
+
11
20
  export const TOOLS = [
12
21
  { name: "status", description: "Read the Kernel Assurance Projection for an exact task.", privileged: false },
13
22
  { name: "enroll", description: "Enroll a Git-tracked TaskIntent after native confirmation.", privileged: true },
@@ -37,13 +46,17 @@ export function listMcpTools() {
37
46
  }));
38
47
  }
39
48
 
49
+ export function supportsElicitationProtocol(value: unknown): boolean {
50
+ return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) && value >= MCP_PROTOCOL_VERSION;
51
+ }
52
+
40
53
  export interface McpRuntimeOptions {
41
54
  cwd?: string;
42
55
  env?: Record<string, string | undefined>;
43
56
  ports?: AssuranceCoordinatorPorts;
44
57
  host?: ClaudeReviewHost;
45
58
  interactive?: boolean;
46
- decisions?: Map<string, NativeDecision>;
59
+ requestConfirmation?: NativeConfirmationPort;
47
60
  }
48
61
 
49
62
  export function createMcpRuntime(options: McpRuntimeOptions = {}) {
@@ -54,21 +67,26 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
54
67
  host,
55
68
  ports: options.ports,
56
69
  interactive: options.interactive,
57
- decisions: options.decisions,
70
+ requestConfirmation: options.requestConfirmation,
58
71
  });
59
72
  // Trusted Host evidence negotiated on this JSON-RPC connection. Absent
60
73
  // handshake evidence means unversioned and non-interactive: fail closed.
61
74
  let negotiatedVersion: string | undefined;
62
75
  let negotiatedInteractive = false;
76
+ const connectionId = randomUUID();
63
77
  return {
64
78
  runtime,
65
79
  host,
80
+ connectionId,
66
81
  listTools: listMcpTools,
67
- bindClientHandshake(identity: { version: string; interactive: boolean }) {
82
+ bindClientHandshake(identity: { version: string; interactive: boolean; protocolVersion?: string }) {
68
83
  negotiatedVersion = identity.version || undefined;
69
- negotiatedInteractive = identity.interactive;
84
+ negotiatedInteractive = identity.interactive && supportsElicitationProtocol(identity.protocolVersion);
70
85
  runtime.bindHostVersion(negotiatedVersion);
71
86
  },
87
+ bindNativeConfirmation(port: NativeConfirmationPort) {
88
+ runtime.bindNativeConfirmation(port);
89
+ },
72
90
  sessionInteractive: () => negotiatedInteractive,
73
91
  async callTool(name: string, args: Record<string, unknown>, meta: Partial<ToolMeta> = {}) {
74
92
  const taskId = String(args.task_id ?? "");
@@ -80,20 +98,20 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
80
98
  // The environment version fallback is disabled on this connection:
81
99
  // only a version bound from the trusted initialize handshake may
82
100
  // reach authority-mutating tools.
83
- if (!negotiatedVersion) throw new Error("Claude Code version is unavailable");
101
+ if (!negotiatedVersion) throw new NativeAuthorityError("unsupported_host", "Claude Code version is unavailable");
84
102
  // Automatic stale-claim repair is deterministic and needs no native
85
103
  // interaction; other mutations retain the Host capability requirement.
86
- if (!negotiatedInteractive && name !== "repair_authority_state") throw new Error("non-interactive host session cannot execute authority tools");
104
+ if (!negotiatedInteractive && name !== "repair_authority_state") {
105
+ throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
106
+ }
87
107
  const probe = probeHost(options.env ?? process.env, process.platform, negotiatedVersion);
88
- if (!probe.ok) throw new Error(probe.reason);
108
+ if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
89
109
  const toolMeta: ToolMeta = {
90
- sessionId: meta.sessionId ?? "session",
110
+ sessionId: meta.sessionId ?? connectionId,
91
111
  toolCallId: meta.toolCallId ?? `call-${name}`,
92
112
  taskId,
93
- requiresUserInteraction: meta.requiresUserInteraction ?? isPrivilegedOperation(name),
94
- permissionMode: meta.permissionMode ?? probe.permissionMode,
95
- interactive: meta.interactive ?? options.interactive ?? false,
96
- decision: meta.decision,
113
+ interactive: meta.interactive ?? options.interactive ?? negotiatedInteractive,
114
+ signal: meta.signal,
97
115
  };
98
116
  if (name === "enroll") return runtime.enroll(taskId, toolMeta);
99
117
  if (name === "advance_assurance") return runtime.advance(taskId, toolMeta.signal);
@@ -107,7 +125,6 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
107
125
  throw new Error(`unknown tool ${name}`);
108
126
  },
109
127
  observe: (event: Parameters<ClaudeReviewHost["observe"]>[0]) => host.observe(event),
110
- sessionOfElicitation: (toolCallId: string) => host.sessionOfElicitation(toolCallId),
111
128
  shutdown: () => runtime.shutdown(),
112
129
  aborts: new Map<string | number, AbortController>(),
113
130
  };
@@ -119,25 +136,24 @@ interface JsonRpc {
119
136
  method?: string;
120
137
  params?: unknown;
121
138
  result?: unknown;
122
- error?: { code: number; message: string };
139
+ error?: { code: number; message: string; data?: Record<string, unknown> };
123
140
  }
124
141
 
125
- function hostCallIdentity(
126
- meta: Record<string, unknown> | undefined,
127
- resolveSession?: (toolCallId: string) => string | undefined,
128
- ): { sessionId: string; toolCallId: string } | undefined {
142
+ function hostCallIdentity(meta: Record<string, unknown> | undefined): { toolCallId: string } | undefined {
129
143
  if (!meta) return undefined;
130
- // Real Claude Code wire correlation metadata carries ONLY the namespaced
131
- // claudecode/toolUseId key; any other correlation key (tool_use_id,
132
- // toolUseId, toolCallId) is noncanonical and fails closed. The Host session
133
- // binding is always derived from the sole unconsumed ElicitationResult
134
- // record for that exact call; no record, or an ambiguous record, means no
135
- // native interaction: fail closed. A supplied session_id is never trusted
136
- // and can never bypass ambiguity resolution.
137
144
  const toolCallId = meta["claudecode/toolUseId"];
138
- if (typeof toolCallId !== "string" || !toolCallId) return undefined;
139
- const sessionId = resolveSession?.(toolCallId);
140
- return sessionId ? { sessionId, toolCallId } : undefined;
145
+ return typeof toolCallId === "string" && toolCallId ? { toolCallId } : undefined;
146
+ }
147
+
148
+ function rpcError(error: unknown): { code: number; message: string; data?: Record<string, unknown> } {
149
+ if (error instanceof NativeAuthorityError) {
150
+ return {
151
+ code: -32000,
152
+ message: error.message,
153
+ data: { reason_code: error.reasonCode, recovery_action: error.recoveryAction },
154
+ };
155
+ }
156
+ return { code: -32000, message: error instanceof Error ? error.message : String(error) };
141
157
  }
142
158
 
143
159
  function encodeMessage(message: JsonRpc): Buffer {
@@ -147,6 +163,7 @@ function encodeMessage(message: JsonRpc): Buffer {
147
163
  export async function handleJsonRpc(message: JsonRpc, mcp = createMcpRuntime()): Promise<JsonRpc | null> {
148
164
  if (message.method === "initialize") {
149
165
  const params = (message.params ?? {}) as {
166
+ protocolVersion?: unknown;
150
167
  clientInfo?: { name?: unknown; version?: unknown };
151
168
  capabilities?: Record<string, unknown>;
152
169
  };
@@ -157,16 +174,14 @@ export async function handleJsonRpc(message: JsonRpc, mcp = createMcpRuntime()):
157
174
  const elicitation = params.capabilities?.elicitation;
158
175
  mcp.bindClientHandshake({
159
176
  version: trustedClient && typeof params.clientInfo?.version === "string" ? params.clientInfo.version : "",
160
- // A client that does not declare elicitation support cannot surface
161
- // the native interactions privileged authority requires; only a
162
- // valid capability object counts, not any non-null value.
177
+ protocolVersion: typeof params.protocolVersion === "string" ? params.protocolVersion : undefined,
163
178
  interactive: trustedClient && elicitation !== null && typeof elicitation === "object" && !Array.isArray(elicitation),
164
179
  });
165
180
  return {
166
181
  jsonrpc: "2.0",
167
182
  id: message.id ?? null,
168
183
  result: {
169
- protocolVersion: "2024-11-05",
184
+ protocolVersion: MCP_PROTOCOL_VERSION,
170
185
  serverInfo: {
171
186
  name: HOST_ID,
172
187
  version: PLUGIN_VERSION,
@@ -195,13 +210,13 @@ export async function handleJsonRpc(message: JsonRpc, mcp = createMcpRuntime()):
195
210
  const name = String(params.name ?? "");
196
211
  const interactive = mcp.sessionInteractive();
197
212
  if (isPrivilegedOperation(name) && !interactive) {
198
- throw new Error("non-interactive host session cannot mint authority");
213
+ throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
199
214
  }
200
- const identity = hostCallIdentity(params._meta, (toolCallId) => mcp.sessionOfElicitation(toolCallId));
215
+ const identity = hostCallIdentity(params._meta);
201
216
  if (isPrivilegedOperation(name) && !identity) {
202
- throw new Error("host correlation metadata missing");
217
+ throw new NativeAuthorityError("correlation_missing", "canonical claudecode/toolUseId metadata is missing");
203
218
  }
204
- const sessionId = identity?.sessionId ?? "stdio";
219
+ const sessionId = mcp.connectionId;
205
220
  const toolCallId = identity?.toolCallId ?? String(message.id ?? "stdio");
206
221
  const result = await mcp.callTool(name, params.arguments ?? {}, {
207
222
  sessionId,
@@ -216,11 +231,10 @@ export async function handleJsonRpc(message: JsonRpc, mcp = createMcpRuntime()):
216
231
  result: { content: [{ type: "text", text: JSON.stringify(result) }] },
217
232
  };
218
233
  } catch (error) {
219
- const reason = error instanceof Error ? error.message : String(error);
220
234
  return {
221
235
  jsonrpc: "2.0",
222
236
  id: message.id ?? null,
223
- error: { code: -32000, message: reason },
237
+ error: rpcError(error),
224
238
  };
225
239
  } finally {
226
240
  mcp.aborts.delete(requestId);
@@ -232,8 +246,69 @@ export async function handleJsonRpc(message: JsonRpc, mcp = createMcpRuntime()):
232
246
 
233
247
  async function writeReply(output: Writable, reply: JsonRpc): Promise<void> {
234
248
  const payload = encodeMessage(reply);
235
- if (output.write(payload)) return;
236
- await new Promise<void>((resolve) => output.once("drain", resolve));
249
+ await new Promise<void>((resolve, reject) => {
250
+ if (!output.writable) {
251
+ reject(new Error("output stream is not writable"));
252
+ return;
253
+ }
254
+ let settled = false;
255
+ const cleanup = () => {
256
+ output.off("drain", onDrain);
257
+ output.off("error", onError);
258
+ output.off("close", onClose);
259
+ };
260
+ const onDrain = () => {
261
+ if (settled) return;
262
+ settled = true;
263
+ cleanup();
264
+ resolve();
265
+ };
266
+ const onError = (error: Error) => {
267
+ if (settled) return;
268
+ settled = true;
269
+ cleanup();
270
+ reject(error);
271
+ };
272
+ const onClose = () => {
273
+ if (settled) return;
274
+ settled = true;
275
+ cleanup();
276
+ reject(new Error("output stream closed before write drained"));
277
+ };
278
+ output.once("error", onError);
279
+ output.once("close", onClose);
280
+ const ok = output.write(payload, (error) => {
281
+ if (settled) return;
282
+ if (error) {
283
+ settled = true;
284
+ cleanup();
285
+ reject(error);
286
+ }
287
+ });
288
+ if (ok) {
289
+ settled = true;
290
+ cleanup();
291
+ resolve();
292
+ return;
293
+ }
294
+ output.once("drain", onDrain);
295
+ });
296
+ }
297
+
298
+ export function elicitationParams(input: NativeConfirmationInput) {
299
+ const details = [
300
+ `Operation: ${input.operation}`,
301
+ `Task: ${input.taskId}`,
302
+ input.risk ? `Risk: ${input.risk}` : null,
303
+ input.intentRevision !== undefined ? `Intent revision: ${input.intentRevision}` : null,
304
+ input.intentContentHash ? `Intent hash: ${input.intentContentHash}` : null,
305
+ input.bindingDigest ? `Binding digest: ${input.bindingDigest}` : null,
306
+ ].filter(Boolean);
307
+ return {
308
+ mode: "form",
309
+ message: `Authorize this exact Immune-Brain operation?\n\n${details.join("\n")}`,
310
+ requestedSchema: { type: "object", properties: {} },
311
+ };
237
312
  }
238
313
 
239
314
  export async function serveStdio(options: {
@@ -248,17 +323,93 @@ export async function serveStdio(options: {
248
323
  const exit = options.exit ?? ((code: number) => { process.exit(code); });
249
324
  let buffer = Buffer.alloc(0);
250
325
  let accepting = true;
326
+ let requestSequence = 0;
251
327
  const inFlight = new Set<Promise<void>>();
328
+ const pending = new Map<string, {
329
+ resolve: (response: JsonRpc) => void;
330
+ reject: (error: Error) => void;
331
+ }>();
252
332
  let chain = Promise.resolve();
253
333
 
334
+ const rejectPending = (error: Error): void => {
335
+ for (const item of pending.values()) item.reject(error);
336
+ pending.clear();
337
+ };
338
+
339
+ mcp.bindNativeConfirmation(async (input) => {
340
+ if (!accepting) throw new NativeAuthorityError("interaction_not_opened", "MCP connection is closed");
341
+ if (input.signal?.aborted) throw new NativeAuthorityError("user_cancelled", "Tool call was cancelled");
342
+ const requestId = `immune-brain:elicitation:${mcp.connectionId}:${++requestSequence}`;
343
+ let abortListener: (() => void) | undefined;
344
+ const response = new Promise<JsonRpc>((resolve, reject) => {
345
+ pending.set(requestId, { resolve, reject });
346
+ abortListener = () => {
347
+ pending.delete(requestId);
348
+ reject(new NativeAuthorityError("user_cancelled", "Tool call was cancelled"));
349
+ };
350
+ input.signal?.addEventListener("abort", abortListener, { once: true });
351
+ });
352
+ try {
353
+ await writeReply(output, {
354
+ jsonrpc: "2.0",
355
+ id: requestId,
356
+ method: "elicitation/create",
357
+ params: elicitationParams(input),
358
+ });
359
+ const reply = await response;
360
+ if (reply.error) {
361
+ if (reply.error.code === -32601) {
362
+ throw new NativeAuthorityError("unsupported_host", "Claude Code rejected MCP elicitation/create");
363
+ }
364
+ throw new NativeAuthorityError("correlation_missing", `MCP elicitation failed: ${reply.error.message}`);
365
+ }
366
+ const result = reply.result;
367
+ const action = typeof result === "object" && result !== null && !Array.isArray(result)
368
+ ? (result as { action?: unknown }).action
369
+ : undefined;
370
+ if (action !== "accept" && action !== "decline" && action !== "cancel") {
371
+ throw new NativeAuthorityError("correlation_missing", "MCP elicitation returned an invalid action");
372
+ }
373
+ if (action === "accept") {
374
+ const content = (result as { content?: unknown }).content;
375
+ if (typeof content !== "object" || content === null || Array.isArray(content) || Object.keys(content).length !== 0) {
376
+ throw new NativeAuthorityError("correlation_missing", "MCP elicitation accept content did not match the requested schema");
377
+ }
378
+ }
379
+ return { decision: action, requestId };
380
+ } finally {
381
+ pending.delete(requestId);
382
+ if (abortListener) input.signal?.removeEventListener("abort", abortListener);
383
+ }
384
+ });
385
+
254
386
  const runCall = (parsed: JsonRpc): void => {
255
387
  const task = handleJsonRpc(parsed, mcp).then(async (reply) => {
256
388
  if (reply) await writeReply(output, reply);
389
+ }).catch(() => {
390
+ void executeShutdown(1);
257
391
  });
258
392
  inFlight.add(task);
259
393
  void task.finally(() => inFlight.delete(task));
260
394
  };
261
395
 
396
+ const routeResponse = (obj: Record<string, unknown>): boolean => {
397
+ const id = typeof obj.id === "string" ? obj.id : "";
398
+ const waiter = id ? pending.get(id) : undefined;
399
+ if (waiter) {
400
+ pending.delete(id);
401
+ const hasResult = Object.hasOwn(obj, "result");
402
+ const hasError = Object.hasOwn(obj, "error");
403
+ if (obj.jsonrpc !== "2.0" || obj.method !== undefined || hasResult === hasError) {
404
+ waiter.reject(new NativeAuthorityError("correlation_missing", "malformed MCP elicitation response"));
405
+ } else {
406
+ waiter.resolve(obj as unknown as JsonRpc);
407
+ }
408
+ return true;
409
+ }
410
+ return obj.method === undefined && obj.id !== undefined;
411
+ };
412
+
262
413
  const drainStdio = async (): Promise<void> => {
263
414
  while (true) {
264
415
  const newline = buffer.indexOf("\n");
@@ -278,10 +429,12 @@ export async function serveStdio(options: {
278
429
  continue;
279
430
  }
280
431
  const obj = parsed as Record<string, unknown>;
432
+ if (routeResponse(obj)) continue;
281
433
  if (obj.jsonrpc !== "2.0") {
282
434
  await writeReply(output, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Invalid Request: jsonrpc must be '2.0'" } });
283
435
  continue;
284
436
  }
437
+ if (routeResponse(obj)) continue;
285
438
  if (typeof obj.method !== "string" || !obj.method) {
286
439
  const id = obj.id !== undefined && (typeof obj.id === "string" || typeof obj.id === "number") ? obj.id : null;
287
440
  await writeReply(output, { jsonrpc: "2.0", id, error: { code: -32600, message: "Invalid Request: missing method" } });
@@ -291,7 +444,6 @@ export async function serveStdio(options: {
291
444
  await writeReply(output, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Invalid Request: invalid id type" } });
292
445
  continue;
293
446
  }
294
- // tools/call is a request requiring a reply; notification-form tools/call (no id) is an invalid request
295
447
  if (obj.method === "tools/call" && obj.id === undefined) {
296
448
  await writeReply(output, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Invalid Request: tools/call requires an id" } });
297
449
  continue;
@@ -299,10 +451,15 @@ export async function serveStdio(options: {
299
451
  const rpc = parsed as JsonRpc;
300
452
  if (rpc.method === "notifications/cancelled") {
301
453
  const requestId = (rpc.params as { requestId?: string | number } | undefined)?.requestId;
302
- if (requestId !== undefined) mcp.aborts.get(requestId)?.abort(new Error("notifications/cancelled"));
454
+ if (typeof requestId === "string" && pending.has(requestId)) {
455
+ pending.get(requestId)?.reject(new NativeAuthorityError("user_cancelled", "native interaction was cancelled"));
456
+ pending.delete(requestId);
457
+ } else if (requestId !== undefined) {
458
+ mcp.aborts.get(requestId)?.abort(new Error("notifications/cancelled"));
459
+ }
303
460
  continue;
304
461
  }
305
- if (parsed.method === "tools/call") {
462
+ if (rpc.method === "tools/call") {
306
463
  if (!accepting) {
307
464
  if (rpc.id !== undefined) await writeReply(output, { jsonrpc: "2.0", id: rpc.id, error: { code: -32000, message: "stdio closed" } });
308
465
  continue;
@@ -315,38 +472,55 @@ export async function serveStdio(options: {
315
472
  }
316
473
  };
317
474
 
475
+ let resolveStdio = () => {};
318
476
  let shutdownPromise: Promise<void> | null = null;
319
477
  const executeShutdown = (code = 0): Promise<void> => {
320
478
  if (shutdownPromise) return shutdownPromise;
321
479
  shutdownPromise = (async () => {
322
480
  accepting = false;
323
481
  for (const ac of mcp.aborts.values()) ac.abort(new Error("stdio closed"));
324
- await Promise.allSettled([...inFlight]);
482
+ rejectPending(new NativeAuthorityError("user_cancelled", "MCP connection closed during native interaction"));
483
+ await Promise.race([
484
+ Promise.allSettled([...inFlight]),
485
+ new Promise<void>((resolve) => setTimeout(resolve, 200)),
486
+ ]);
325
487
  await mcp.shutdown();
326
488
  if (output.writable && typeof output.end === "function") {
327
489
  await new Promise<void>((cb) => output.end(() => cb()));
328
490
  }
329
491
  process.exitCode = code;
330
492
  exit(code);
493
+ resolveStdio();
331
494
  })();
332
495
  return shutdownPromise;
333
496
  };
334
497
 
335
498
  await new Promise<void>((resolve) => {
499
+ resolveStdio = resolve;
500
+ output.on("error", () => {
501
+ void executeShutdown(1);
502
+ });
503
+ output.on("close", () => {
504
+ void executeShutdown(0);
505
+ });
336
506
  input.on("data", (chunk: Buffer | string) => {
507
+ if (!accepting) return;
337
508
  chain = chain.then(async () => {
509
+ if (!accepting) return;
338
510
  buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
339
511
  await drainStdio();
512
+ }).catch(() => {
513
+ void executeShutdown(1);
340
514
  });
341
515
  });
342
516
  input.on("end", () => {
343
- void chain.then(() => executeShutdown(0)).finally(resolve);
517
+ void chain.then(() => executeShutdown(0));
344
518
  });
345
519
  input.on("close", () => {
346
- void chain.then(() => executeShutdown(0)).finally(resolve);
520
+ void chain.then(() => executeShutdown(0));
347
521
  });
348
522
  input.on("error", () => {
349
- void chain.then(() => executeShutdown(1)).finally(resolve);
523
+ void executeShutdown(1);
350
524
  });
351
525
  });
352
526
  }