immune-brain 3.6.2 → 3.6.3
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/package.json
CHANGED
|
@@ -42,7 +42,7 @@ function probeHost(env = process.env, platform = process.platform, hostVersion)
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
// plugins/immune-brain/runtime/plugin_version.ts
|
|
45
|
-
var PLUGIN_VERSION = "3.6.
|
|
45
|
+
var PLUGIN_VERSION = "3.6.3";
|
|
46
46
|
|
|
47
47
|
// plugins/immune-brain/runtime/claude/interaction.ts
|
|
48
48
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -99,7 +99,7 @@ function enrollmentNonce() {
|
|
|
99
99
|
|
|
100
100
|
// plugins/immune-brain/runtime/claude/review_host.ts
|
|
101
101
|
import { createHash as createHash2 } from "node:crypto";
|
|
102
|
-
import { constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, rmSync, writeSync, closeSync } from "node:fs";
|
|
102
|
+
import { constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, rmSync, writeSync, closeSync } from "node:fs";
|
|
103
103
|
import { dirname, join } from "node:path";
|
|
104
104
|
import { tmpdir } from "node:os";
|
|
105
105
|
var REVIEWER_AGENT = "immune-brain-reviewer";
|
|
@@ -183,7 +183,7 @@ function appendPrivate(path, dir, line) {
|
|
|
183
183
|
closeSync(fd);
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
|
-
function readPrivate(path) {
|
|
186
|
+
function readPrivate(path, maxBytes = Number.POSITIVE_INFINITY) {
|
|
187
187
|
let fd;
|
|
188
188
|
try {
|
|
189
189
|
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
@@ -194,6 +194,8 @@ function readPrivate(path) {
|
|
|
194
194
|
const stat = fstatSync(fd);
|
|
195
195
|
if (!stat.isFile() || !ownedByUs(stat) || (stat.mode & 511) !== 384)
|
|
196
196
|
return;
|
|
197
|
+
if (stat.size > maxBytes)
|
|
198
|
+
return;
|
|
197
199
|
const buf = Buffer.alloc(stat.size);
|
|
198
200
|
readSync(fd, buf, 0, stat.size, 0);
|
|
199
201
|
return buf.toString("utf8");
|
|
@@ -201,6 +203,67 @@ function readPrivate(path) {
|
|
|
201
203
|
closeSync(fd);
|
|
202
204
|
}
|
|
203
205
|
}
|
|
206
|
+
var MAX_TRANSCRIPT_BYTES = 32 * 1024 * 1024;
|
|
207
|
+
function parseAsyncAgentLaunch(result) {
|
|
208
|
+
let payload;
|
|
209
|
+
try {
|
|
210
|
+
payload = JSON.parse(result);
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
if (!payload || typeof payload !== "object")
|
|
215
|
+
return null;
|
|
216
|
+
const obj = payload;
|
|
217
|
+
if (obj.isAsync !== true && obj.status !== "async_launched")
|
|
218
|
+
return null;
|
|
219
|
+
const agentId = typeof obj.agentId === "string" ? obj.agentId : "";
|
|
220
|
+
const outputFile = typeof obj.outputFile === "string" ? obj.outputFile : "";
|
|
221
|
+
if (!agentId || !outputFile)
|
|
222
|
+
return null;
|
|
223
|
+
return { agentId, outputFile };
|
|
224
|
+
}
|
|
225
|
+
function readAgentTranscriptResult(transcript, agentId) {
|
|
226
|
+
let last = null;
|
|
227
|
+
for (const line of transcript.split(`
|
|
228
|
+
`)) {
|
|
229
|
+
if (!line.trim())
|
|
230
|
+
continue;
|
|
231
|
+
let row;
|
|
232
|
+
try {
|
|
233
|
+
row = JSON.parse(line);
|
|
234
|
+
} catch {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (row.type !== "assistant" || row.agentId !== agentId)
|
|
238
|
+
continue;
|
|
239
|
+
const message = row.message;
|
|
240
|
+
if (!message || typeof message !== "object")
|
|
241
|
+
continue;
|
|
242
|
+
const content = message.content;
|
|
243
|
+
if (!Array.isArray(content))
|
|
244
|
+
continue;
|
|
245
|
+
let text = "";
|
|
246
|
+
for (const block of content) {
|
|
247
|
+
if (!block || typeof block !== "object")
|
|
248
|
+
continue;
|
|
249
|
+
const part = block;
|
|
250
|
+
if (part.type === "text" && typeof part.text === "string")
|
|
251
|
+
text += part.text;
|
|
252
|
+
}
|
|
253
|
+
if (text.trim())
|
|
254
|
+
last = text;
|
|
255
|
+
}
|
|
256
|
+
return last;
|
|
257
|
+
}
|
|
258
|
+
function readAgentTranscript(path) {
|
|
259
|
+
let resolved;
|
|
260
|
+
try {
|
|
261
|
+
resolved = realpathSync(path);
|
|
262
|
+
} catch {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
return readPrivate(resolved, MAX_TRANSCRIPT_BYTES);
|
|
266
|
+
}
|
|
204
267
|
function hookEventPath(sessionId, root = tmpdir()) {
|
|
205
268
|
return join(cacheDir(root), `${sessionHash(sessionId)}.jsonl`);
|
|
206
269
|
}
|
|
@@ -288,6 +351,7 @@ class ClaudeReviewHost {
|
|
|
288
351
|
this.log = log;
|
|
289
352
|
}
|
|
290
353
|
prepareReview(request) {
|
|
354
|
+
this.drain();
|
|
291
355
|
const initialCursors = new Map;
|
|
292
356
|
const sessionCursors = new Map;
|
|
293
357
|
for (const sessionId of this.log.sessions()) {
|
|
@@ -325,15 +389,23 @@ ${request.prompt}`,
|
|
|
325
389
|
for (let i = start;i < events.length; i++) {
|
|
326
390
|
const event = events[i];
|
|
327
391
|
if (event.type === "SessionEnd") {
|
|
328
|
-
this.log.clear(event.sessionId);
|
|
329
|
-
ended = true;
|
|
330
|
-
this.appliedBySession.delete(event.sessionId);
|
|
331
392
|
for (const [id, state] of this.pending) {
|
|
332
393
|
if (state.startEvent?.sessionId === event.sessionId || state.postEvent?.sessionId === event.sessionId || state.stopEvent?.sessionId === event.sessionId) {
|
|
333
394
|
this.pending.delete(id);
|
|
334
395
|
}
|
|
335
396
|
}
|
|
336
|
-
|
|
397
|
+
for (const state of this.pending.values()) {
|
|
398
|
+
const current = state.initialCursors.get(sessionId) ?? 0;
|
|
399
|
+
if (i + 1 > current)
|
|
400
|
+
state.initialCursors.set(sessionId, i + 1);
|
|
401
|
+
}
|
|
402
|
+
if (i === events.length - 1) {
|
|
403
|
+
this.log.clear(event.sessionId);
|
|
404
|
+
ended = true;
|
|
405
|
+
this.appliedBySession.delete(event.sessionId);
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
continue;
|
|
337
409
|
}
|
|
338
410
|
for (const state of this.pending.values()) {
|
|
339
411
|
if (state.consumed || state.error || i < (state.initialCursors.get(sessionId) ?? 0))
|
|
@@ -460,13 +532,23 @@ ${request.prompt}`,
|
|
|
460
532
|
if (state.startEvent.sessionId !== state.postEvent.sessionId || state.startEvent.sessionId !== state.stopEvent.sessionId || state.startEvent.agentId !== state.postEvent.agentId || state.startEvent.agentId !== state.stopEvent.agentId) {
|
|
461
533
|
return { ok: false, reason: "foreground Agent terminal event correlation mismatch", release: true };
|
|
462
534
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
535
|
+
const actorId = `claude:${state.startEvent.agentId ?? reservation.id}`;
|
|
536
|
+
const launch = parseAsyncAgentLaunch(state.postEvent.result);
|
|
537
|
+
if (!launch) {
|
|
538
|
+
return { ok: true, receipt: { actorId, result: state.postEvent.result } };
|
|
539
|
+
}
|
|
540
|
+
if (launch.agentId !== state.postEvent.agentId) {
|
|
541
|
+
return { ok: false, reason: "async Agent launch envelope names a different agent", release: true };
|
|
542
|
+
}
|
|
543
|
+
const transcript = readAgentTranscript(launch.outputFile);
|
|
544
|
+
if (transcript === undefined) {
|
|
545
|
+
return { ok: false, reason: "async Agent transcript is not readable", release: false };
|
|
546
|
+
}
|
|
547
|
+
const verdict = readAgentTranscriptResult(transcript, launch.agentId);
|
|
548
|
+
if (!verdict?.trim()) {
|
|
549
|
+
return { ok: false, reason: "async Agent transcript carries no reviewer result", release: false };
|
|
550
|
+
}
|
|
551
|
+
return { ok: true, receipt: { actorId, result: verdict } };
|
|
470
552
|
}
|
|
471
553
|
consumeReview(reservation) {
|
|
472
554
|
const result = this.inspectReview(reservation);
|
|
@@ -514,6 +596,11 @@ function parseHookStdin(raw) {
|
|
|
514
596
|
if (typeof toolInputObj.prompt === "string")
|
|
515
597
|
prompt = toolInputObj.prompt;
|
|
516
598
|
}
|
|
599
|
+
if (!prompt && typeof payload.tool_response === "object" && payload.tool_response !== null) {
|
|
600
|
+
const toolResponseObj = payload.tool_response;
|
|
601
|
+
if (typeof toolResponseObj.prompt === "string")
|
|
602
|
+
prompt = toolResponseObj.prompt;
|
|
603
|
+
}
|
|
517
604
|
let extractedOpId = operationId;
|
|
518
605
|
let extractedTaskId = taskId;
|
|
519
606
|
for (const candidate of [payload.input, payload.tool_input, payload.tool_response, payload.toolResponse, payload.response, payload.result]) {
|
|
@@ -602,7 +689,7 @@ import { createHash as createHash5, randomUUID as randomUUID2 } from "node:crypt
|
|
|
602
689
|
// plugins/immune-brain/runtime/assurance/verification.ts
|
|
603
690
|
import { createHash as createHash3 } from "node:crypto";
|
|
604
691
|
import { execFileSync, spawn } from "node:child_process";
|
|
605
|
-
import { realpathSync, statSync } from "node:fs";
|
|
692
|
+
import { realpathSync as realpathSync2, statSync } from "node:fs";
|
|
606
693
|
import { isAbsolute as isAbsolute2, resolve, sep as sep2, relative } from "node:path";
|
|
607
694
|
|
|
608
695
|
// plugins/immune-brain/runtime/verification_descriptor.ts
|
|
@@ -705,7 +792,7 @@ function resolveBunRunner() {
|
|
|
705
792
|
}
|
|
706
793
|
let real;
|
|
707
794
|
try {
|
|
708
|
-
real =
|
|
795
|
+
real = realpathSync2(executable);
|
|
709
796
|
} catch {
|
|
710
797
|
throw new VerificationDescriptorError("bun runner realpath is unresolvable");
|
|
711
798
|
}
|
|
@@ -1710,7 +1797,7 @@ import {
|
|
|
1710
1797
|
writeFileSync,
|
|
1711
1798
|
statSync as statSync2,
|
|
1712
1799
|
readFileSync as readFileSync3,
|
|
1713
|
-
realpathSync as
|
|
1800
|
+
realpathSync as realpathSync4,
|
|
1714
1801
|
chmodSync
|
|
1715
1802
|
} from "node:fs";
|
|
1716
1803
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
@@ -1724,7 +1811,7 @@ import {
|
|
|
1724
1811
|
lstatSync as lstatSync2,
|
|
1725
1812
|
readFileSync as readFileSync2,
|
|
1726
1813
|
readlinkSync,
|
|
1727
|
-
realpathSync as
|
|
1814
|
+
realpathSync as realpathSync3
|
|
1728
1815
|
} from "node:fs";
|
|
1729
1816
|
import { resolve as resolve2 } from "node:path";
|
|
1730
1817
|
function git(root, args) {
|
|
@@ -1891,7 +1978,7 @@ function taskSnapshotOnce(root, scope) {
|
|
|
1891
1978
|
const head = git(root, ["rev-parse", "--verify", "HEAD^{commit}"])?.trim();
|
|
1892
1979
|
if (!repositoryRoot || !head || !GIT_OBJECT_ID.test(head))
|
|
1893
1980
|
throw new Error("cannot derive task snapshot outside a committed Git workspace");
|
|
1894
|
-
if (
|
|
1981
|
+
if (realpathSync3(resolve2(repositoryRoot)) !== root)
|
|
1895
1982
|
throw new Error("task snapshot repository root does not match the project root");
|
|
1896
1983
|
const sparseCheckout = git(root, ["config", "--bool", "core.sparseCheckout"])?.trim();
|
|
1897
1984
|
const sparseIndex = git(root, ["config", "--bool", "index.sparse"])?.trim();
|
|
@@ -1935,7 +2022,7 @@ function captureGitTaskSnapshot(projectRoot, scopeHint) {
|
|
|
1935
2022
|
const requestedStat = lstatSync2(requestedRoot);
|
|
1936
2023
|
if (requestedStat.isSymbolicLink() || !requestedStat.isDirectory())
|
|
1937
2024
|
throw new Error("task snapshot root must be a real directory");
|
|
1938
|
-
const root =
|
|
2025
|
+
const root = realpathSync3(requestedRoot);
|
|
1939
2026
|
const scope = assertCanonicalTaskScope(scopeHint);
|
|
1940
2027
|
const before = taskSnapshotOnce(root, scope);
|
|
1941
2028
|
gitTaskSnapshotTestHook?.();
|
|
@@ -1968,7 +2055,7 @@ function taskRevisionSnapshotOnce(root, scope, baseHead) {
|
|
|
1968
2055
|
const head = git(root, ["rev-parse", "--verify", "HEAD^{commit}"])?.trim();
|
|
1969
2056
|
if (!repositoryRoot || !head || !GIT_OBJECT_ID.test(head))
|
|
1970
2057
|
throw new Error("cannot derive a task revision outside a committed Git workspace");
|
|
1971
|
-
if (
|
|
2058
|
+
if (realpathSync3(resolve2(repositoryRoot)) !== root)
|
|
1972
2059
|
throw new Error("task revision repository root does not match the project root");
|
|
1973
2060
|
if (gitRequired(root, ["cat-file", "-t", baseHead], `task revision base is unreadable: ${baseHead}`) !== "commit")
|
|
1974
2061
|
throw new Error(`task revision base is not a commit: ${baseHead}`);
|
|
@@ -2025,7 +2112,7 @@ function captureGitTaskRevisionSnapshot(projectRoot, scopeHint, baseHead) {
|
|
|
2025
2112
|
const requestedStat = lstatSync2(requestedRoot);
|
|
2026
2113
|
if (requestedStat.isSymbolicLink() || !requestedStat.isDirectory())
|
|
2027
2114
|
throw new Error("task revision root must be a real directory");
|
|
2028
|
-
const root =
|
|
2115
|
+
const root = realpathSync3(requestedRoot);
|
|
2029
2116
|
if (typeof baseHead !== "string" || !GIT_OBJECT_ID.test(baseHead.toLowerCase()))
|
|
2030
2117
|
throw new Error("task revision base must be a Git commit id");
|
|
2031
2118
|
const scope = assertCanonicalTaskScope(scopeHint);
|
|
@@ -2403,7 +2490,7 @@ function ensureReviewRevision(root, input) {
|
|
|
2403
2490
|
function writeNativeReviewEvidence(payload) {
|
|
2404
2491
|
const rawDirectory = mkdtempSync(join4(tmpdir2(), "imm-canary-native-review-"));
|
|
2405
2492
|
try {
|
|
2406
|
-
const directory =
|
|
2493
|
+
const directory = realpathSync4(rawDirectory);
|
|
2407
2494
|
chmodSync(directory, 493);
|
|
2408
2495
|
const path = join4(directory, "evidence.json");
|
|
2409
2496
|
writeFileSync(path, JSON.stringify(payload), { encoding: "utf8", mode: 420, flag: "wx" });
|
|
@@ -2418,7 +2505,7 @@ function writeNativeReviewEvidence(payload) {
|
|
|
2418
2505
|
}
|
|
2419
2506
|
}
|
|
2420
2507
|
function assertReviewArtifact(path) {
|
|
2421
|
-
const targetPath =
|
|
2508
|
+
const targetPath = realpathSync4(path);
|
|
2422
2509
|
let stat;
|
|
2423
2510
|
try {
|
|
2424
2511
|
stat = statSync2(targetPath);
|
|
@@ -2612,7 +2699,7 @@ import {
|
|
|
2612
2699
|
mkdirSync as mkdirSync2,
|
|
2613
2700
|
openSync as openSync3,
|
|
2614
2701
|
readFileSync as readFileSync6,
|
|
2615
|
-
realpathSync as
|
|
2702
|
+
realpathSync as realpathSync6,
|
|
2616
2703
|
renameSync,
|
|
2617
2704
|
rmSync as rmSync3,
|
|
2618
2705
|
writeFileSync as writeFileSync2
|
|
@@ -2641,7 +2728,7 @@ import {
|
|
|
2641
2728
|
lstatSync as lstatSync4,
|
|
2642
2729
|
openSync as openSync2,
|
|
2643
2730
|
readFileSync as readFileSync5,
|
|
2644
|
-
realpathSync as
|
|
2731
|
+
realpathSync as realpathSync5
|
|
2645
2732
|
} from "node:fs";
|
|
2646
2733
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
2647
2734
|
import { join as join6, resolve as resolve4, sep as sep3 } from "node:path";
|
|
@@ -2939,7 +3026,7 @@ function resolveCanonicalRoot(root) {
|
|
|
2939
3026
|
const rootStat = lstatSync4(resolved);
|
|
2940
3027
|
if (rootStat.isSymbolicLink() || !rootStat.isDirectory())
|
|
2941
3028
|
throw new Error("project root must be a real directory, not a symlink");
|
|
2942
|
-
return
|
|
3029
|
+
return realpathSync5(resolved);
|
|
2943
3030
|
}
|
|
2944
3031
|
function resolveSidecarPath(canonicalRoot, activePath, archivedPath) {
|
|
2945
3032
|
if (sidecarPresent(canonicalRoot, activePath))
|
|
@@ -3016,7 +3103,7 @@ function readTaskIntent(root, taskId, requestedPath) {
|
|
|
3016
3103
|
const after = lstatSync4(target);
|
|
3017
3104
|
assertSameIdentity(statIdentity(before), after, "intent sidecar");
|
|
3018
3105
|
assertIdentitiesUnchanged(pathIdentities, canonicalRoot, sidecarPath);
|
|
3019
|
-
const canonicalAgain =
|
|
3106
|
+
const canonicalAgain = realpathSync5(root);
|
|
3020
3107
|
if (canonicalAgain !== canonicalRoot)
|
|
3021
3108
|
throw new Error("canonical project root drifted while being read");
|
|
3022
3109
|
if (lstatSync4(canonicalAgain).isSymbolicLink())
|
|
@@ -3759,7 +3846,7 @@ function revisionFor(content) {
|
|
|
3759
3846
|
}
|
|
3760
3847
|
function canonicalRoot(root) {
|
|
3761
3848
|
try {
|
|
3762
|
-
return
|
|
3849
|
+
return realpathSync6(root);
|
|
3763
3850
|
} catch {
|
|
3764
3851
|
throw new KernelStoreSecurityError("project root is unavailable");
|
|
3765
3852
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, rmSync, writeSync, closeSync } from "node:fs";
|
|
2
|
+
import { constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, rmSync, writeSync, closeSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import type {
|
|
@@ -122,12 +122,13 @@ function appendPrivate(path: string, dir: string, line: string): boolean {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
function readPrivate(path: string): string | undefined {
|
|
125
|
+
function readPrivate(path: string, maxBytes = Number.POSITIVE_INFINITY): string | undefined {
|
|
126
126
|
let fd: number;
|
|
127
127
|
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch { return; }
|
|
128
128
|
try {
|
|
129
129
|
const stat = fstatSync(fd);
|
|
130
130
|
if (!stat.isFile() || !ownedByUs(stat) || (stat.mode & 0o777) !== 0o600) return;
|
|
131
|
+
if (stat.size > maxBytes) return;
|
|
131
132
|
const buf = Buffer.alloc(stat.size);
|
|
132
133
|
readSync(fd, buf, 0, stat.size, 0);
|
|
133
134
|
return buf.toString("utf8");
|
|
@@ -136,6 +137,78 @@ function readPrivate(path: string): string | undefined {
|
|
|
136
137
|
}
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Upper bound on a reviewer transcript we are willing to load. A reviewer that
|
|
142
|
+
* produced more than this did not produce a verdict; refusing to allocate for it
|
|
143
|
+
* is safer than trusting whatever the tail happens to contain.
|
|
144
|
+
*/
|
|
145
|
+
const MAX_TRANSCRIPT_BYTES = 32 * 1024 * 1024;
|
|
146
|
+
|
|
147
|
+
export type AsyncAgentLaunch = { agentId: string; outputFile: string };
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Recognise the launch receipt this Claude Code build returns for `Agent`.
|
|
151
|
+
*
|
|
152
|
+
* Every `Agent` call here runs asynchronously — `run_in_background: false` in
|
|
153
|
+
* the dispatch envelope is not honoured, and there is no synchronous mode. The
|
|
154
|
+
* tool result is therefore
|
|
155
|
+
* `{"isAsync":true,"status":"async_launched","agentId":…,"outputFile":…}`:
|
|
156
|
+
* proof that a subagent started, never its answer. Treating it as the verdict
|
|
157
|
+
* would settle Review on a receipt for starting the reviewer, so the launch
|
|
158
|
+
* envelope is read only as a pointer to where the real bytes live.
|
|
159
|
+
*/
|
|
160
|
+
export function parseAsyncAgentLaunch(result: string): AsyncAgentLaunch | null {
|
|
161
|
+
let payload: unknown;
|
|
162
|
+
try { payload = JSON.parse(result); } catch { return null; }
|
|
163
|
+
if (!payload || typeof payload !== "object") return null;
|
|
164
|
+
const obj = payload as Record<string, unknown>;
|
|
165
|
+
if (obj.isAsync !== true && obj.status !== "async_launched") return null;
|
|
166
|
+
const agentId = typeof obj.agentId === "string" ? obj.agentId : "";
|
|
167
|
+
const outputFile = typeof obj.outputFile === "string" ? obj.outputFile : "";
|
|
168
|
+
if (!agentId || !outputFile) return null;
|
|
169
|
+
return { agentId, outputFile };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Extract the reviewer's terminal message from its own transcript.
|
|
174
|
+
*
|
|
175
|
+
* Each record carries the `agentId` that wrote it, so the caller's independently
|
|
176
|
+
* observed id is matched per record rather than trusted for the file as a whole;
|
|
177
|
+
* a transcript that interleaves another agent cannot contribute its text.
|
|
178
|
+
*/
|
|
179
|
+
export function readAgentTranscriptResult(transcript: string, agentId: string): string | null {
|
|
180
|
+
let last: string | null = null;
|
|
181
|
+
for (const line of transcript.split("\n")) {
|
|
182
|
+
if (!line.trim()) continue;
|
|
183
|
+
let row: Record<string, unknown>;
|
|
184
|
+
try { row = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
|
|
185
|
+
if (row.type !== "assistant" || row.agentId !== agentId) continue;
|
|
186
|
+
const message = row.message;
|
|
187
|
+
if (!message || typeof message !== "object") continue;
|
|
188
|
+
const content = (message as Record<string, unknown>).content;
|
|
189
|
+
if (!Array.isArray(content)) continue;
|
|
190
|
+
let text = "";
|
|
191
|
+
for (const block of content) {
|
|
192
|
+
if (!block || typeof block !== "object") continue;
|
|
193
|
+
const part = block as Record<string, unknown>;
|
|
194
|
+
if (part.type === "text" && typeof part.text === "string") text += part.text;
|
|
195
|
+
}
|
|
196
|
+
if (text.trim()) last = text;
|
|
197
|
+
}
|
|
198
|
+
return last;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Read the transcript the launch envelope names. The path is a symlink into the
|
|
203
|
+
* session store, so it is resolved once and then opened `O_NOFOLLOW` with the
|
|
204
|
+
* same ownership and mode checks the hook log gets.
|
|
205
|
+
*/
|
|
206
|
+
function readAgentTranscript(path: string): string | undefined {
|
|
207
|
+
let resolved: string;
|
|
208
|
+
try { resolved = realpathSync(path); } catch { return; }
|
|
209
|
+
return readPrivate(resolved, MAX_TRANSCRIPT_BYTES);
|
|
210
|
+
}
|
|
211
|
+
|
|
139
212
|
export function hookEventPath(sessionId: string, root = tmpdir()): string {
|
|
140
213
|
return join(cacheDir(root), `${sessionHash(sessionId)}.jsonl`);
|
|
141
214
|
}
|
|
@@ -229,6 +302,11 @@ export class ClaudeReviewHost implements AssuranceHostPort {
|
|
|
229
302
|
constructor(private readonly log: HookEventLog = new MemoryHookEventLog()) {}
|
|
230
303
|
|
|
231
304
|
prepareReview(request: ReviewRequest): HostReviewReservation {
|
|
305
|
+
// Retire whatever the log already holds before the cursors are taken. A
|
|
306
|
+
// resumed session inherits its predecessor's file, and an unprocessed
|
|
307
|
+
// `SessionEnd` sitting in it used to survive until the first inspection —
|
|
308
|
+
// long after this reservation's own events had been appended behind it.
|
|
309
|
+
this.drain();
|
|
232
310
|
const initialCursors = new Map<string, number>();
|
|
233
311
|
const sessionCursors = new Map<string, number>();
|
|
234
312
|
for (const sessionId of this.log.sessions()) {
|
|
@@ -266,17 +344,31 @@ export class ClaudeReviewHost implements AssuranceHostPort {
|
|
|
266
344
|
for (let i = start; i < events.length; i++) {
|
|
267
345
|
const event = events[i];
|
|
268
346
|
if (event.type === "SessionEnd") {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
this.appliedBySession.delete(event.sessionId);
|
|
347
|
+
// Any reservation that already bound evidence from the finished
|
|
348
|
+
// session loses it outright; that evidence can never be revived.
|
|
272
349
|
for (const [id, state] of this.pending) {
|
|
273
350
|
if (state.startEvent?.sessionId === event.sessionId || state.postEvent?.sessionId === event.sessionId || state.stopEvent?.sessionId === event.sessionId) {
|
|
274
351
|
this.pending.delete(id);
|
|
275
352
|
}
|
|
276
353
|
}
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
354
|
+
// A resumed session reuses its id, so the same log can hold events
|
|
355
|
+
// appended after an earlier end. Advancing every surviving cursor
|
|
356
|
+
// past this point keeps the pre-end events unusable — the property
|
|
357
|
+
// the old whole-log clear was protecting — while still letting the
|
|
358
|
+
// events that follow reach their reservation. Clearing and stopping
|
|
359
|
+
// here instead silently discarded a live Review receipt.
|
|
360
|
+
for (const state of this.pending.values()) {
|
|
361
|
+
const current = state.initialCursors.get(sessionId) ?? 0;
|
|
362
|
+
if (i + 1 > current) state.initialCursors.set(sessionId, i + 1);
|
|
363
|
+
}
|
|
364
|
+
if (i === events.length - 1) {
|
|
365
|
+
// Nothing followed the end, so the log is safe to reclaim.
|
|
366
|
+
this.log.clear(event.sessionId);
|
|
367
|
+
ended = true;
|
|
368
|
+
this.appliedBySession.delete(event.sessionId);
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
continue;
|
|
280
372
|
}
|
|
281
373
|
for (const state of this.pending.values()) {
|
|
282
374
|
if (state.consumed || state.error || i < (state.initialCursors.get(sessionId) ?? 0)) continue;
|
|
@@ -387,13 +479,29 @@ export class ClaudeReviewHost implements AssuranceHostPort {
|
|
|
387
479
|
) {
|
|
388
480
|
return { ok: false, reason: "foreground Agent terminal event correlation mismatch", release: true };
|
|
389
481
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
482
|
+
const actorId = `claude:${state.startEvent.agentId ?? reservation.id}`;
|
|
483
|
+
const launch = parseAsyncAgentLaunch(state.postEvent.result);
|
|
484
|
+
if (!launch) {
|
|
485
|
+
return { ok: true, receipt: { actorId, result: state.postEvent.result } };
|
|
486
|
+
}
|
|
487
|
+
// Invariant guard. Correlation already binds the PostToolUse through the
|
|
488
|
+
// same `agentId` this envelope carries, so a foreign id normally never
|
|
489
|
+
// reaches here; assert it anyway rather than read a transcript the three
|
|
490
|
+
// observed events did not agree on.
|
|
491
|
+
if (launch.agentId !== state.postEvent.agentId) {
|
|
492
|
+
return { ok: false, reason: "async Agent launch envelope names a different agent", release: true };
|
|
493
|
+
}
|
|
494
|
+
const transcript = readAgentTranscript(launch.outputFile);
|
|
495
|
+
if (transcript === undefined) {
|
|
496
|
+
// Fail closed rather than falling back to bytes the Parent supplied:
|
|
497
|
+
// an optional weaker path is a path the Parent can choose to force.
|
|
498
|
+
return { ok: false, reason: "async Agent transcript is not readable", release: false };
|
|
499
|
+
}
|
|
500
|
+
const verdict = readAgentTranscriptResult(transcript, launch.agentId);
|
|
501
|
+
if (!verdict?.trim()) {
|
|
502
|
+
return { ok: false, reason: "async Agent transcript carries no reviewer result", release: false };
|
|
503
|
+
}
|
|
504
|
+
return { ok: true, receipt: { actorId, result: verdict } };
|
|
397
505
|
}
|
|
398
506
|
|
|
399
507
|
consumeReview(reservation: HostReviewReservation): ConsumeReviewResult {
|
|
@@ -437,6 +545,14 @@ export function parseHookStdin(raw: string): ClaudeHookEvent | null {
|
|
|
437
545
|
const toolInputObj = payload.tool_input as Record<string, unknown>;
|
|
438
546
|
if (typeof toolInputObj.prompt === "string") prompt = toolInputObj.prompt;
|
|
439
547
|
}
|
|
548
|
+
// The async launch envelope echoes the dispatched prompt. It is the only
|
|
549
|
+
// place the reservation marker appears when a payload omits `tool_input`,
|
|
550
|
+
// and it is written by the Host, not by the Parent, like every other field
|
|
551
|
+
// read here.
|
|
552
|
+
if (!prompt && typeof payload.tool_response === "object" && payload.tool_response !== null) {
|
|
553
|
+
const toolResponseObj = payload.tool_response as Record<string, unknown>;
|
|
554
|
+
if (typeof toolResponseObj.prompt === "string") prompt = toolResponseObj.prompt;
|
|
555
|
+
}
|
|
440
556
|
let extractedOpId = operationId;
|
|
441
557
|
let extractedTaskId = taskId;
|
|
442
558
|
// Extract nested operation_id/task_id from tool_input or tool_response (e.g. { tool_input: { operation_id, task_id } })
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by scripts/plugin_versioning.ts from the root package.json.
|
|
2
|
-
export const PLUGIN_VERSION = "3.6.
|
|
2
|
+
export const PLUGIN_VERSION = "3.6.3" as const;
|