immune-brain 2.8.3 → 3.0.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.
- package/.claude-plugin/marketplace.json +16 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/package.json +9 -2
- package/plugins/immune-brain/.claude-plugin/plugin.json +8 -0
- package/plugins/immune-brain/.mcp.json +8 -0
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +354 -64
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +74 -706
- package/plugins/immune-brain/.pi-extension/pi-canary-invocations.ts +1 -90
- package/plugins/immune-brain/.pi-extension/pi-canary-native-review.ts +13 -160
- package/plugins/immune-brain/.pi-extension/pi-canary-qa-findings.ts +1 -50
- package/plugins/immune-brain/.pi-extension/pi-canary-review-bundle.ts +1 -262
- package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +3 -2
- package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +8 -229
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +23 -5
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +11 -0
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +7514 -0
- package/plugins/immune-brain/dist/docs/reference/code-quality-guard.md +58 -0
- package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +1 -1
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +19 -13
- package/plugins/immune-brain/dist/imm-loop.md +6 -7
- package/plugins/immune-brain/dist/imm-planner.md +1 -1
- package/plugins/immune-brain/dist/imm-pr-fix.md +9 -0
- package/plugins/immune-brain/dist/role-prompts/code-review.md +33 -13
- package/plugins/immune-brain/dist/role-prompts/executor.md +14 -0
- package/plugins/immune-brain/dist/role-prompts/pr-fix.md +9 -0
- package/plugins/immune-brain/dist/role-prompts/test-fixer.md +7 -0
- package/plugins/immune-brain/hooks/hooks.json +55 -0
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +836 -0
- package/plugins/immune-brain/runtime/assurance/enrollment.ts +6 -0
- package/plugins/immune-brain/runtime/assurance/host_port.ts +18 -0
- package/plugins/immune-brain/runtime/assurance/invocations.ts +90 -0
- package/plugins/immune-brain/runtime/assurance/qa_findings.ts +50 -0
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +596 -0
- package/plugins/immune-brain/runtime/assurance/verification.ts +233 -0
- package/plugins/immune-brain/runtime/claude/capability.ts +67 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +70 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +789 -0
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +363 -0
- package/plugins/immune-brain/runtime/claude/review_host.ts +645 -0
- package/plugins/immune-brain/runtime/commands/kernel.ts +221 -3
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +2 -2
- package/plugins/immune-brain/runtime/kernel/application.ts +8 -5
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +24 -13
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +78 -115
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +6 -4
- package/plugins/immune-brain/runtime/kernel/capability_registry.ts +89 -0
- package/plugins/immune-brain/runtime/kernel/completion.ts +64 -6
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +189 -100
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +37 -80
- package/plugins/immune-brain/runtime/kernel/intent.ts +24 -0
- package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +31 -0
- package/plugins/immune-brain/runtime/kernel/reducer.ts +36 -13
- package/plugins/immune-brain/runtime/kernel/storage.ts +16 -16
- package/plugins/immune-brain/runtime/kernel/types.ts +32 -2
- package/plugins/immune-brain/runtime/kernel/validation.ts +107 -16
- package/plugins/immune-brain/runtime/loop_contract.ts +17 -2
- package/plugins/immune-brain/runtime/prompts/code-review.md +33 -13
- package/plugins/immune-brain/runtime/prompts/executor.md +14 -0
- package/plugins/immune-brain/runtime/prompts/pr-fix.md +9 -0
- package/plugins/immune-brain/runtime/prompts/test-fixer.md +7 -0
- package/plugins/immune-brain/runtime/v4_runtime.ts +5 -2
- package/plugins/immune-brain/runtime/workspace_scope.ts +191 -5
- package/plugins/immune-brain/skills/imm-loop/SKILL.md +3 -4
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +2 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, rmSync, writeSync, closeSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import type {
|
|
6
|
+
AssuranceHostPort,
|
|
7
|
+
HostReviewReservation,
|
|
8
|
+
ReviewRequest,
|
|
9
|
+
} from "../assurance/host_port";
|
|
10
|
+
|
|
11
|
+
export type ObservedReviewReceipt = { actorId: string; result: string };
|
|
12
|
+
export type ConsumeReviewResult =
|
|
13
|
+
| { ok: true; receipt: ObservedReviewReceipt }
|
|
14
|
+
| { ok: false; reason: string; release: boolean };
|
|
15
|
+
|
|
16
|
+
export const REVIEWER_AGENT = "immune-brain-reviewer";
|
|
17
|
+
export const CLAUDE_REVIEWER_AGENT = "immune-brain:immune-brain-reviewer";
|
|
18
|
+
export const AGENT_TOOL = "Agent";
|
|
19
|
+
|
|
20
|
+
export type ClaudeHookEvent =
|
|
21
|
+
| {
|
|
22
|
+
type: "SubagentStart";
|
|
23
|
+
sessionId: string;
|
|
24
|
+
agent: string;
|
|
25
|
+
agentId: string;
|
|
26
|
+
taskId?: string;
|
|
27
|
+
operationId?: string;
|
|
28
|
+
prompt?: string;
|
|
29
|
+
}
|
|
30
|
+
| {
|
|
31
|
+
type: "PostToolUse";
|
|
32
|
+
sessionId: string;
|
|
33
|
+
agentId: string;
|
|
34
|
+
toolName: string;
|
|
35
|
+
result: string;
|
|
36
|
+
taskId?: string;
|
|
37
|
+
operationId?: string;
|
|
38
|
+
prompt?: string;
|
|
39
|
+
}
|
|
40
|
+
| {
|
|
41
|
+
type: "SubagentStop";
|
|
42
|
+
sessionId: string;
|
|
43
|
+
agent: string;
|
|
44
|
+
agentId: string;
|
|
45
|
+
taskId?: string;
|
|
46
|
+
operationId?: string;
|
|
47
|
+
}
|
|
48
|
+
| { type: "SessionEnd"; sessionId: string }
|
|
49
|
+
| { type: "ElicitationResult"; sessionId: string; toolCallId: string; decision: "accept" | "deny" | "cancel" }
|
|
50
|
+
| { type: "ElicitationConsumed"; sessionId: string; toolCallId: string };
|
|
51
|
+
|
|
52
|
+
export interface HookEventLog {
|
|
53
|
+
append(event: ClaudeHookEvent): boolean;
|
|
54
|
+
list(sessionId?: string): ClaudeHookEvent[];
|
|
55
|
+
sessions(): string[];
|
|
56
|
+
clear(sessionId?: string): void;
|
|
57
|
+
/** Persists consumed identity durably; true only after the record is verified. */
|
|
58
|
+
consumeElicitation(sessionId: string, toolCallId: string): boolean;
|
|
59
|
+
consumedKeys(): string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class MemoryHookEventLog implements HookEventLog {
|
|
63
|
+
private readonly events: ClaudeHookEvent[] = [];
|
|
64
|
+
private readonly consumed: string[] = [];
|
|
65
|
+
append(event: ClaudeHookEvent): boolean { this.events.push(event); return true; }
|
|
66
|
+
list(sessionId?: string): ClaudeHookEvent[] {
|
|
67
|
+
return sessionId ? this.events.filter((event) => event.sessionId === sessionId) : [...this.events];
|
|
68
|
+
}
|
|
69
|
+
sessions(): string[] {
|
|
70
|
+
return [...new Set(this.events.map((event) => event.sessionId))];
|
|
71
|
+
}
|
|
72
|
+
clear(sessionId?: string): void {
|
|
73
|
+
if (!sessionId) {
|
|
74
|
+
this.events.length = 0;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (let i = this.events.length - 1; i >= 0; i--) {
|
|
78
|
+
if (this.events[i].sessionId === sessionId) this.events.splice(i, 1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
consumeElicitation(sessionId: string, toolCallId: string): boolean {
|
|
82
|
+
this.consumed.push(`${sessionId}\0${toolCallId}`);
|
|
83
|
+
this.events.push({ type: "ElicitationConsumed", sessionId, toolCallId });
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
consumedKeys(): string[] { return [...this.consumed]; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const CACHE_DIR = "immune-brain-claude";
|
|
90
|
+
const CONSUMED_FILE = "consumed.jsonl";
|
|
91
|
+
const CONSUMED_DIR = "consumed";
|
|
92
|
+
|
|
93
|
+
function sessionHash(sessionId: string): string {
|
|
94
|
+
return createHash("sha256").update(sessionId).digest("hex");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cacheDir(root: string): string {
|
|
98
|
+
return join(root, CACHE_DIR);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function ownedByUs(stat: { uid: number; isSymbolicLink(): boolean }): boolean {
|
|
102
|
+
if (stat.isSymbolicLink()) return false;
|
|
103
|
+
const uid = process.getuid?.();
|
|
104
|
+
return uid === undefined || stat.uid === uid;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function ensurePrivateDir(dir: string): boolean {
|
|
108
|
+
try {
|
|
109
|
+
const existing = lstatSync(dir);
|
|
110
|
+
return existing.isDirectory() && ownedByUs(existing) && (existing.mode & 0o777) === 0o700;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false;
|
|
113
|
+
}
|
|
114
|
+
try { mkdirSync(dir, { mode: 0o700 }); } catch { return false; }
|
|
115
|
+
try {
|
|
116
|
+
const created = lstatSync(dir);
|
|
117
|
+
return created.isDirectory() && ownedByUs(created) && (created.mode & 0o777) === 0o700;
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function claimAtomicKey(dir: string, keyName: string): boolean {
|
|
124
|
+
const claimsDir = join(dir, CONSUMED_DIR);
|
|
125
|
+
if (!ensurePrivateDir(claimsDir)) return false;
|
|
126
|
+
const claimPath = join(claimsDir, `${keyName}.claim`);
|
|
127
|
+
const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_NONBLOCK;
|
|
128
|
+
let fd: number;
|
|
129
|
+
try {
|
|
130
|
+
fd = openSync(claimPath, flags, 0o600);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const stat = fstatSync(fd);
|
|
136
|
+
if (!stat.isFile() || !ownedByUs(stat) || (stat.mode & 0o777) !== 0o600) return false;
|
|
137
|
+
return true;
|
|
138
|
+
} finally {
|
|
139
|
+
closeSync(fd);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function appendPrivate(path: string, dir: string, line: string): boolean {
|
|
143
|
+
if (!ensurePrivateDir(dir)) return false;
|
|
144
|
+
const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK;
|
|
145
|
+
let fd: number;
|
|
146
|
+
try { fd = openSync(path, flags, 0o600); } catch { return false; }
|
|
147
|
+
try {
|
|
148
|
+
const stat = fstatSync(fd);
|
|
149
|
+
if (!stat.isFile() || !ownedByUs(stat) || (stat.mode & 0o777) !== 0o600) return false;
|
|
150
|
+
return writeSync(fd, line) === Buffer.byteLength(line);
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
} finally {
|
|
154
|
+
closeSync(fd);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readPrivate(path: string): string | undefined {
|
|
159
|
+
let fd: number;
|
|
160
|
+
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch { return; }
|
|
161
|
+
try {
|
|
162
|
+
const stat = fstatSync(fd);
|
|
163
|
+
if (!stat.isFile() || !ownedByUs(stat) || (stat.mode & 0o777) !== 0o600) return;
|
|
164
|
+
const buf = Buffer.alloc(stat.size);
|
|
165
|
+
readSync(fd, buf, 0, stat.size, 0);
|
|
166
|
+
return buf.toString("utf8");
|
|
167
|
+
} finally {
|
|
168
|
+
closeSync(fd);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function hookEventPath(sessionId: string, root = tmpdir()): string {
|
|
173
|
+
return join(cacheDir(root), `${sessionHash(sessionId)}.jsonl`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export class FileHookEventLog implements HookEventLog {
|
|
177
|
+
constructor(private readonly root = tmpdir()) {}
|
|
178
|
+
append(event: ClaudeHookEvent): boolean {
|
|
179
|
+
const path = hookEventPath(event.sessionId, this.root);
|
|
180
|
+
return appendPrivate(path, dirname(path), `${JSON.stringify(event)}\n`);
|
|
181
|
+
}
|
|
182
|
+
list(sessionId?: string): ClaudeHookEvent[] {
|
|
183
|
+
if (sessionId) return this.readSession(sessionId);
|
|
184
|
+
const dir = cacheDir(this.root);
|
|
185
|
+
if (!ensurePrivateDir(dir)) return [];
|
|
186
|
+
try {
|
|
187
|
+
return readdirSync(dir)
|
|
188
|
+
.filter((name) => name.endsWith(".jsonl") && name !== CONSUMED_FILE)
|
|
189
|
+
.flatMap((name) => this.readFile(join(dir, name)));
|
|
190
|
+
} catch {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
sessions(): string[] {
|
|
195
|
+
const ids = new Set<string>();
|
|
196
|
+
for (const event of this.list()) if (event.sessionId) ids.add(event.sessionId);
|
|
197
|
+
return [...ids];
|
|
198
|
+
}
|
|
199
|
+
private readFile(path: string): ClaudeHookEvent[] {
|
|
200
|
+
const text = readPrivate(path);
|
|
201
|
+
if (!text) return [];
|
|
202
|
+
try {
|
|
203
|
+
return text.split("\n").filter(Boolean).map((line) => JSON.parse(line) as ClaudeHookEvent);
|
|
204
|
+
} catch {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
private readSession(sessionId: string): ClaudeHookEvent[] {
|
|
209
|
+
if (!ensurePrivateDir(cacheDir(this.root))) return [];
|
|
210
|
+
return this.readFile(hookEventPath(sessionId, this.root));
|
|
211
|
+
}
|
|
212
|
+
clear(sessionId?: string): void {
|
|
213
|
+
if (!sessionId || !ensurePrivateDir(cacheDir(this.root))) return;
|
|
214
|
+
const path = hookEventPath(sessionId, this.root);
|
|
215
|
+
try {
|
|
216
|
+
const stat = lstatSync(path);
|
|
217
|
+
if (!ownedByUs(stat) || stat.isSymbolicLink() || !stat.isFile() || (stat.mode & 0o777) !== 0o600) return;
|
|
218
|
+
rmSync(path, { force: true });
|
|
219
|
+
} catch { /* cleanup cannot create authority */ }
|
|
220
|
+
}
|
|
221
|
+
consumeElicitation(sessionId: string, toolCallId: string): boolean {
|
|
222
|
+
// The durable consumed identity is claimed atomically (O_EXCL per-key)
|
|
223
|
+
// and committed to consumed.jsonl BEFORE any session evidence is touched:
|
|
224
|
+
// concurrent consumers cannot double-claim the same identity.
|
|
225
|
+
const dir = cacheDir(this.root);
|
|
226
|
+
const claimKey = createHash("sha256").update(`${sessionId}\0${toolCallId}`).digest("hex");
|
|
227
|
+
const keyClaimed = claimAtomicKey(dir, claimKey);
|
|
228
|
+
if (!keyClaimed) return false;
|
|
229
|
+
if (!appendPrivate(join(dir, CONSUMED_FILE), dir, `${JSON.stringify({ sessionId, toolCallId })}\n`)) {
|
|
230
|
+
// Roll back the claim file if the durable record append failed
|
|
231
|
+
try { rmSync(join(dir, CONSUMED_DIR, `${claimKey}.claim`), { force: true }); } catch { /* ignore */ }
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
if (!this.consumedKeys().includes(`${sessionId}\0${toolCallId}`)) return false;
|
|
235
|
+
return this.append({ type: "ElicitationConsumed", sessionId, toolCallId });
|
|
236
|
+
}
|
|
237
|
+
consumedKeys(): string[] {
|
|
238
|
+
if (!ensurePrivateDir(cacheDir(this.root))) return [];
|
|
239
|
+
const text = readPrivate(join(cacheDir(this.root), CONSUMED_FILE));
|
|
240
|
+
if (!text) return [];
|
|
241
|
+
try {
|
|
242
|
+
return text.split("\n").filter(Boolean)
|
|
243
|
+
.map((line) => JSON.parse(line) as { sessionId: string; toolCallId: string })
|
|
244
|
+
.map((item) => `${item.sessionId}\0${item.toolCallId}`);
|
|
245
|
+
} catch {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
interface PendingReview {
|
|
252
|
+
request: ReviewRequest;
|
|
253
|
+
initialCursors: Map<string, number>;
|
|
254
|
+
sessionCursors: Map<string, number>;
|
|
255
|
+
startEvent?: Extract<ClaudeHookEvent, { type: "SubagentStart" }>;
|
|
256
|
+
postEvent?: Extract<ClaudeHookEvent, { type: "PostToolUse" }>;
|
|
257
|
+
stopEvent?: Extract<ClaudeHookEvent, { type: "SubagentStop" }>;
|
|
258
|
+
consumed: boolean;
|
|
259
|
+
error?: string;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function matchesReservation(event: ClaudeHookEvent, pending: PendingReview): boolean {
|
|
263
|
+
if ("operationId" in event && event.operationId && event.operationId !== pending.request.operationId) return false;
|
|
264
|
+
if ("taskId" in event && event.taskId && event.taskId !== pending.request.taskId) return false;
|
|
265
|
+
if (pending.sessionId && event.sessionId !== pending.sessionId) return false;
|
|
266
|
+
const eventAgentId = "agentId" in event ? event.agentId : "";
|
|
267
|
+
if (event.type === "SubagentStop" && (!eventAgentId || !pending.agentId || eventAgentId !== pending.agentId)) return false;
|
|
268
|
+
if (pending.agentId && eventAgentId && eventAgentId !== pending.agentId) return false;
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function bindsStart(event: Extract<ClaudeHookEvent, { type: "SubagentStart" }>, pending: PendingReview): boolean {
|
|
273
|
+
if (event.taskId && event.taskId !== pending.request.taskId) return false;
|
|
274
|
+
if (event.prompt !== undefined) {
|
|
275
|
+
const match = /<!-- immune-brain:operation_id=([^\s]+)\s+task_id=([^\s]+)\s+-->/.exec(event.prompt);
|
|
276
|
+
if (match) {
|
|
277
|
+
if (match[1] !== pending.request.operationId || match[2] !== pending.request.taskId) return false;
|
|
278
|
+
} else if (event.prompt !== pending.request.prompt) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (event.operationId) return event.operationId === pending.request.operationId;
|
|
283
|
+
return event.prompt !== undefined && event.prompt === pending.request.prompt;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export class ClaudeReviewHost implements AssuranceHostPort {
|
|
287
|
+
readonly host = "claude-code" as const;
|
|
288
|
+
private readonly pending = new Map<string, PendingReview>();
|
|
289
|
+
private readonly appliedBySession = new Map<string, number>();
|
|
290
|
+
private readonly consumedElicitations = new Set<string>();
|
|
291
|
+
constructor(private readonly log: HookEventLog = new MemoryHookEventLog()) {}
|
|
292
|
+
|
|
293
|
+
private readonly confirmations = new Map<string, "accept" | "deny" | "cancel">();
|
|
294
|
+
|
|
295
|
+
prepareReview(request: ReviewRequest): HostReviewReservation {
|
|
296
|
+
const initialCursors = new Map<string, number>();
|
|
297
|
+
const sessionCursors = new Map<string, number>();
|
|
298
|
+
for (const sessionId of this.log.sessions()) {
|
|
299
|
+
const len = this.log.list(sessionId).length;
|
|
300
|
+
initialCursors.set(sessionId, len);
|
|
301
|
+
sessionCursors.set(sessionId, len);
|
|
302
|
+
}
|
|
303
|
+
this.pending.set(request.operationId, {
|
|
304
|
+
request,
|
|
305
|
+
initialCursors,
|
|
306
|
+
sessionCursors,
|
|
307
|
+
consumed: false,
|
|
308
|
+
});
|
|
309
|
+
return {
|
|
310
|
+
id: request.operationId,
|
|
311
|
+
dispatch: {
|
|
312
|
+
name: CLAUDE_REVIEWER_AGENT,
|
|
313
|
+
prompt: `<!-- immune-brain:operation_id=${request.operationId} task_id=${request.taskId} -->\n${request.prompt}`,
|
|
314
|
+
max_turns: request.maxTurns,
|
|
315
|
+
run_in_background: false,
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
observe(event: ClaudeHookEvent): void {
|
|
321
|
+
this.log.append(event);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private drain(): void {
|
|
325
|
+
for (const key of this.log.consumedKeys()) this.consumedElicitations.add(key);
|
|
326
|
+
for (const sessionId of this.log.sessions()) {
|
|
327
|
+
const events = this.log.list(sessionId);
|
|
328
|
+
let start = this.appliedBySession.get(sessionId) ?? 0;
|
|
329
|
+
if (start > events.length) start = 0;
|
|
330
|
+
let ended = false;
|
|
331
|
+
for (let i = start; i < events.length; i++) {
|
|
332
|
+
const event = events[i];
|
|
333
|
+
if (event.type === "ElicitationResult") {
|
|
334
|
+
const key = `${event.sessionId}\0${event.toolCallId}`;
|
|
335
|
+
if (this.consumedElicitations.has(key)) continue;
|
|
336
|
+
if (this.confirmations.has(key)) {
|
|
337
|
+
if (this.log.consumeElicitation(event.sessionId, event.toolCallId)) {
|
|
338
|
+
this.confirmations.delete(key);
|
|
339
|
+
this.consumedElicitations.add(key);
|
|
340
|
+
}
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
this.confirmations.set(key, event.decision);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (event.type === "ElicitationConsumed") {
|
|
347
|
+
const key = `${event.sessionId}\0${event.toolCallId}`;
|
|
348
|
+
this.consumedElicitations.add(key);
|
|
349
|
+
this.confirmations.delete(key);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (event.type === "SessionEnd") {
|
|
353
|
+
this.log.clear(event.sessionId);
|
|
354
|
+
ended = true;
|
|
355
|
+
this.appliedBySession.delete(event.sessionId);
|
|
356
|
+
// A confirmation cached before the session ended must never
|
|
357
|
+
// authorize a later privileged call.
|
|
358
|
+
for (const key of this.confirmations.keys()) {
|
|
359
|
+
if (key.startsWith(`${event.sessionId}\0`)) this.confirmations.delete(key);
|
|
360
|
+
}
|
|
361
|
+
for (const [id, state] of this.pending) {
|
|
362
|
+
if (state.startEvent?.sessionId === event.sessionId || state.postEvent?.sessionId === event.sessionId || state.stopEvent?.sessionId === event.sessionId) {
|
|
363
|
+
this.pending.delete(id);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// The session ended: stop processing this captured batch so a
|
|
367
|
+
// later event cannot repopulate confirmations after cleanup.
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
for (const state of this.pending.values()) {
|
|
371
|
+
if (state.consumed || state.error || i < (state.initialCursors.get(sessionId) ?? 0)) continue;
|
|
372
|
+
this.applyReviewEvent(event, state);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (ended) {
|
|
376
|
+
this.appliedBySession.delete(sessionId);
|
|
377
|
+
for (const state of this.pending.values()) state.sessionCursors.delete(sessionId);
|
|
378
|
+
} else {
|
|
379
|
+
this.appliedBySession.set(sessionId, events.length);
|
|
380
|
+
for (const state of this.pending.values()) state.sessionCursors.set(sessionId, events.length);
|
|
381
|
+
// Second pass for out-of-order correlation: if postEvent arrived during this batch,
|
|
382
|
+
// run earlier events >= initialCursor so startEvent/stopEvent are correlated.
|
|
383
|
+
for (const state of this.pending.values()) {
|
|
384
|
+
if (state.consumed || state.error || !state.postEvent || state.postEvent.sessionId !== sessionId) continue;
|
|
385
|
+
if (!state.startEvent || !state.stopEvent) {
|
|
386
|
+
const from = state.initialCursors.get(sessionId) ?? 0;
|
|
387
|
+
for (let j = from; j < events.length; j++) {
|
|
388
|
+
this.applyReviewEvent(events[j], state);
|
|
389
|
+
if (state.error) break;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
peekConfirmation(sessionId: string, toolCallId: string): boolean {
|
|
398
|
+
this.drain();
|
|
399
|
+
return this.confirmations.has(`${sessionId}\0${toolCallId}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
takeConfirmation(sessionId: string, toolCallId: string): "accept" | "deny" | "cancel" | undefined {
|
|
403
|
+
this.drain();
|
|
404
|
+
const key = `${sessionId}\0${toolCallId}`;
|
|
405
|
+
const decision = this.confirmations.get(key);
|
|
406
|
+
if (!decision) return undefined;
|
|
407
|
+
if (!this.log.consumeElicitation(sessionId, toolCallId)) {
|
|
408
|
+
this.confirmations.delete(key);
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
this.confirmations.delete(key);
|
|
412
|
+
this.consumedElicitations.add(key);
|
|
413
|
+
this.drain();
|
|
414
|
+
return decision;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
sessionOfElicitation(toolCallId: string): string | undefined {
|
|
418
|
+
this.drain();
|
|
419
|
+
const sessions: string[] = [];
|
|
420
|
+
for (const key of this.confirmations.keys()) {
|
|
421
|
+
const sep = key.lastIndexOf("\0");
|
|
422
|
+
if (sep >= 0 && key.slice(sep + 1) === toolCallId) sessions.push(key.slice(0, sep));
|
|
423
|
+
}
|
|
424
|
+
// Ambiguous correlation across sessions fails closed.
|
|
425
|
+
if (sessions.length !== 1) return undefined;
|
|
426
|
+
return sessions[0];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private applyReviewEvent(event: ClaudeHookEvent, state: PendingReview): void {
|
|
430
|
+
if (state.error) return;
|
|
431
|
+
if (event.type === "SubagentStart") {
|
|
432
|
+
if (event.agent !== REVIEWER_AGENT && event.agent !== CLAUDE_REVIEWER_AGENT) return;
|
|
433
|
+
// If operationId or prompt is explicit on Start, it must bind to this reservation
|
|
434
|
+
if (event.operationId || event.prompt !== undefined) {
|
|
435
|
+
if (!bindsStart(event, state)) return;
|
|
436
|
+
}
|
|
437
|
+
// If PostToolUse already bound to this reservation, match its agentId and session
|
|
438
|
+
if (state.postEvent) {
|
|
439
|
+
if (event.sessionId !== state.postEvent.sessionId || event.agentId !== state.postEvent.agentId) return;
|
|
440
|
+
}
|
|
441
|
+
if (state.stopEvent) {
|
|
442
|
+
if (event.sessionId !== state.stopEvent.sessionId || event.agentId !== state.stopEvent.agentId) return;
|
|
443
|
+
}
|
|
444
|
+
// If neither postEvent nor stopEvent is present yet and this Start lacks operationId/prompt,
|
|
445
|
+
// it cannot be bound proactively to a reservation without evidence; skip until correlated.
|
|
446
|
+
if (!event.operationId && event.prompt === undefined && !state.postEvent && !state.stopEvent) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (state.startEvent) {
|
|
450
|
+
if (state.startEvent === event) return;
|
|
451
|
+
state.error = "reserved Review already has a native tool call";
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
state.startEvent = event;
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (event.type === "PostToolUse") {
|
|
458
|
+
if (event.toolName !== AGENT_TOOL) return;
|
|
459
|
+
if (!event.agentId || !event.operationId || event.operationId !== state.request.operationId) return;
|
|
460
|
+
if ("taskId" in event && event.taskId && event.taskId !== state.request.taskId) return;
|
|
461
|
+
if (state.startEvent && event.sessionId !== state.startEvent.sessionId) return;
|
|
462
|
+
if (state.startEvent && event.agentId !== state.startEvent.agentId) return;
|
|
463
|
+
if (state.stopEvent && event.sessionId !== state.stopEvent.sessionId) return;
|
|
464
|
+
if (state.stopEvent && event.agentId !== state.stopEvent.agentId) return;
|
|
465
|
+
if (state.postEvent !== undefined) {
|
|
466
|
+
if (state.postEvent === event) return;
|
|
467
|
+
state.error = "duplicate PostToolUse result observed for review reservation";
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
state.postEvent = event;
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (event.type === "SubagentStop") {
|
|
474
|
+
if (event.agent !== REVIEWER_AGENT && event.agent !== CLAUDE_REVIEWER_AGENT) return;
|
|
475
|
+
if (!event.agentId) return;
|
|
476
|
+
if ("operationId" in event && event.operationId && event.operationId !== state.request.operationId) return;
|
|
477
|
+
if ("taskId" in event && event.taskId && event.taskId !== state.request.taskId) return;
|
|
478
|
+
if (state.startEvent && (event.sessionId !== state.startEvent.sessionId || event.agentId !== state.startEvent.agentId)) return;
|
|
479
|
+
if (state.postEvent && (event.sessionId !== state.postEvent.sessionId || event.agentId !== state.postEvent.agentId)) return;
|
|
480
|
+
if (!event.operationId && !state.startEvent && !state.postEvent) {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (state.stopEvent) {
|
|
484
|
+
if (state.stopEvent === event) return;
|
|
485
|
+
state.error = "duplicate SubagentStop observed for review reservation";
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
state.stopEvent = event;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
inspectReview(reservation: HostReviewReservation): ConsumeReviewResult {
|
|
493
|
+
this.drain();
|
|
494
|
+
const state = this.pending.get(reservation.id);
|
|
495
|
+
if (!state) return { ok: false, reason: "reserved foreground Agent was not observed", release: false };
|
|
496
|
+
if (state.consumed) return { ok: false, reason: "review receipt already consumed", release: true };
|
|
497
|
+
if (state.error) return { ok: false, reason: state.error, release: true };
|
|
498
|
+
if (!state.startEvent) return { ok: false, reason: "reserved foreground Agent was not observed", release: false };
|
|
499
|
+
if (!state.stopEvent || !state.postEvent?.result?.trim()) {
|
|
500
|
+
return { ok: false, reason: "foreground Agent terminal event order is incomplete", release: false };
|
|
501
|
+
}
|
|
502
|
+
// Reconcile: all 3 events must belong to the exact same session and agentId
|
|
503
|
+
if (
|
|
504
|
+
state.startEvent.sessionId !== state.postEvent.sessionId ||
|
|
505
|
+
state.startEvent.sessionId !== state.stopEvent.sessionId ||
|
|
506
|
+
state.startEvent.agentId !== state.postEvent.agentId ||
|
|
507
|
+
state.startEvent.agentId !== state.stopEvent.agentId
|
|
508
|
+
) {
|
|
509
|
+
return { ok: false, reason: "foreground Agent terminal event correlation mismatch", release: true };
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
ok: true,
|
|
513
|
+
receipt: {
|
|
514
|
+
actorId: `claude:${state.startEvent.agentId ?? reservation.id}`,
|
|
515
|
+
result: state.postEvent.result,
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
consumeReview(reservation: HostReviewReservation): ConsumeReviewResult {
|
|
521
|
+
const result = this.inspectReview(reservation);
|
|
522
|
+
if (result.ok) {
|
|
523
|
+
const pending = this.pending.get(reservation.id);
|
|
524
|
+
if (pending) pending.consumed = true;
|
|
525
|
+
}
|
|
526
|
+
return result;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
inspectReviewForTask(taskId: string): ConsumeReviewResult {
|
|
530
|
+
this.drain();
|
|
531
|
+
const entry = [...this.pending.entries()].find(([, state]) => state.request.taskId === taskId);
|
|
532
|
+
if (!entry) return { ok: false, reason: "reserved foreground Agent was not observed", release: false };
|
|
533
|
+
return this.inspectReview({ id: entry[0], dispatch: entry[1].request });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
releaseReview(reservation: HostReviewReservation): void {
|
|
537
|
+
this.pending.delete(reservation.id);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export function parseHookStdin(raw: string): ClaudeHookEvent | null {
|
|
542
|
+
let payload: Record<string, unknown>;
|
|
543
|
+
try { payload = JSON.parse(raw) as Record<string, unknown>; }
|
|
544
|
+
catch { return null; }
|
|
545
|
+
const hookType = String(payload.hook_event_name ?? payload.type ?? "");
|
|
546
|
+
// ElicitationResult is native authority evidence: it must carry the Host's
|
|
547
|
+
// own session_id verbatim. Alias or environment fallbacks are never
|
|
548
|
+
// accepted for this event; anything else is malformed and fails closed.
|
|
549
|
+
if (hookType === "ElicitationResult" && (typeof payload.session_id !== "string" || !payload.session_id)) return null;
|
|
550
|
+
const sessionId = String(payload.session_id ?? payload.sessionId ?? process.env.CLAUDE_SESSION_ID ?? "");
|
|
551
|
+
if (!sessionId) return null;
|
|
552
|
+
const agent = String(payload.agent_type ?? payload.agent ?? payload.subagent_type ?? "");
|
|
553
|
+
const agentId = String(payload.agent_id ?? payload.agentId ?? "");
|
|
554
|
+
const taskId = typeof payload.task_id === "string" ? payload.task_id : undefined;
|
|
555
|
+
const operationId = typeof payload.operation_id === "string" ? payload.operation_id : undefined;
|
|
556
|
+
let prompt = typeof payload.prompt === "string" ? payload.prompt : undefined;
|
|
557
|
+
if (!prompt && typeof payload.input === "object" && payload.input !== null) {
|
|
558
|
+
const inputObj = payload.input as Record<string, unknown>;
|
|
559
|
+
if (typeof inputObj.prompt === "string") prompt = inputObj.prompt;
|
|
560
|
+
}
|
|
561
|
+
if (!prompt && typeof payload.tool_input === "object" && payload.tool_input !== null) {
|
|
562
|
+
const toolInputObj = payload.tool_input as Record<string, unknown>;
|
|
563
|
+
if (typeof toolInputObj.prompt === "string") prompt = toolInputObj.prompt;
|
|
564
|
+
}
|
|
565
|
+
let extractedOpId = operationId;
|
|
566
|
+
let extractedTaskId = taskId;
|
|
567
|
+
// Extract nested operation_id/task_id from tool_input or tool_response (e.g. { tool_input: { operation_id, task_id } })
|
|
568
|
+
for (const candidate of [payload.input, payload.tool_input, payload.tool_response, payload.toolResponse, payload.response, payload.result]) {
|
|
569
|
+
if (candidate && typeof candidate === "object" && candidate !== null) {
|
|
570
|
+
const obj = candidate as Record<string, unknown>;
|
|
571
|
+
const nestedOp = typeof obj.operation_id === "string" ? obj.operation_id : typeof obj.operationId === "string" ? obj.operationId : undefined;
|
|
572
|
+
const nestedTask = typeof obj.task_id === "string" ? obj.task_id : typeof obj.taskId === "string" ? obj.taskId : undefined;
|
|
573
|
+
if (nestedOp) {
|
|
574
|
+
if (extractedOpId && extractedOpId !== nestedOp) return null;
|
|
575
|
+
extractedOpId = nestedOp;
|
|
576
|
+
}
|
|
577
|
+
if (nestedTask) {
|
|
578
|
+
if (extractedTaskId && extractedTaskId !== nestedTask) return null;
|
|
579
|
+
extractedTaskId = nestedTask;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (prompt) {
|
|
584
|
+
const match = /<!-- immune-brain:operation_id=([^\s]+)\s+task_id=([^\s]+)\s+-->/.exec(prompt);
|
|
585
|
+
if (match) {
|
|
586
|
+
if (extractedOpId && extractedOpId !== match[1]) return null;
|
|
587
|
+
if (extractedTaskId && extractedTaskId !== match[2]) return null;
|
|
588
|
+
extractedOpId = match[1];
|
|
589
|
+
extractedTaskId = match[2];
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (hookType === "SubagentStart" || hookType === "SubagentStop") {
|
|
593
|
+
return {
|
|
594
|
+
type: hookType,
|
|
595
|
+
sessionId,
|
|
596
|
+
agent,
|
|
597
|
+
agentId,
|
|
598
|
+
taskId: extractedTaskId,
|
|
599
|
+
operationId: extractedOpId,
|
|
600
|
+
...(hookType === "SubagentStart" ? { prompt } : {}),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
if (hookType === "PostToolUse") {
|
|
604
|
+
const toolName = String(payload.tool_name ?? payload.toolName ?? payload.tool ?? "");
|
|
605
|
+
const response = payload.tool_response ?? payload.toolResponse ?? payload.response ?? payload.result;
|
|
606
|
+
let result = typeof response === "string" ? response : JSON.stringify(response ?? "");
|
|
607
|
+
let extractedAgentId = agentId;
|
|
608
|
+
if (response && typeof response === "object" && response !== null) {
|
|
609
|
+
const respObj = response as Record<string, unknown>;
|
|
610
|
+
if (typeof respObj.agentId === "string" && respObj.agentId) {
|
|
611
|
+
if (extractedAgentId && extractedAgentId !== respObj.agentId) return null;
|
|
612
|
+
extractedAgentId = respObj.agentId;
|
|
613
|
+
}
|
|
614
|
+
if (Array.isArray(respObj.content)) {
|
|
615
|
+
const textItem = respObj.content.find((c: unknown) => typeof c === "object" && c !== null && (c as { type?: unknown }).type === "text");
|
|
616
|
+
if (textItem && typeof (textItem as { text?: unknown }).text === "string") {
|
|
617
|
+
result = (textItem as { text: string }).text;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
type: "PostToolUse",
|
|
623
|
+
sessionId,
|
|
624
|
+
agentId: extractedAgentId,
|
|
625
|
+
toolName,
|
|
626
|
+
result,
|
|
627
|
+
taskId: extractedTaskId,
|
|
628
|
+
operationId: extractedOpId,
|
|
629
|
+
prompt,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
if (hookType === "SessionEnd") return { type: "SessionEnd", sessionId };
|
|
633
|
+
if (hookType === "ElicitationResult") {
|
|
634
|
+
const rawToolCallId = payload.tool_use_id ?? payload.toolCallId;
|
|
635
|
+
if (typeof rawToolCallId !== "string" || !rawToolCallId) return null;
|
|
636
|
+
const toolCallId = rawToolCallId;
|
|
637
|
+
const raw = payload.decision ?? payload.result ?? payload.action;
|
|
638
|
+
// Only the exact MCP elicitation decision strings are native evidence;
|
|
639
|
+
// booleans and aliases like "yes" are malformed and fail closed.
|
|
640
|
+
const decision = raw === "accept" ? "accept" : raw === "deny" ? "deny" : raw === "cancel" ? "cancel" : null;
|
|
641
|
+
if (!toolCallId || !decision) return null;
|
|
642
|
+
return { type: "ElicitationResult", sessionId, toolCallId, decision };
|
|
643
|
+
}
|
|
644
|
+
return null;
|
|
645
|
+
}
|