immune-brain 3.6.9 → 4.0.1

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.
Files changed (76) hide show
  1. package/package.json +3 -2
  2. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  3. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
  4. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
  5. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
  6. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
  7. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
  8. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
  9. package/plugins/immune-brain/dist/claude/mcp-server.mjs +7587 -5049
  10. package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
  11. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
  12. package/plugins/immune-brain/dist/imm-loop.md +33 -25
  13. package/plugins/immune-brain/dist/imm-planner.md +41 -27
  14. package/plugins/immune-brain/dist/imm-review-retro.md +2 -2
  15. package/plugins/immune-brain/dist/role-prompts/code-review.md +11 -1
  16. package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
  17. package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
  18. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
  19. package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
  20. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
  21. package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
  22. package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
  23. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
  24. package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
  25. package/plugins/immune-brain/runtime/github_issue_tracker.ts +1 -1
  26. package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
  27. package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
  28. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
  29. package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
  30. package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
  31. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
  32. package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
  33. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
  34. package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
  35. package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
  36. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
  37. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  38. package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
  39. package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
  40. package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
  41. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
  42. package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
  43. package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
  44. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
  45. package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
  46. package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
  47. package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
  48. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
  49. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
  50. package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
  51. package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
  52. package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
  53. package/plugins/immune-brain/runtime/plan_core.ts +27 -65
  54. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  55. package/plugins/immune-brain/runtime/prompts/code-review.md +11 -1
  56. package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
  57. package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
  58. package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
  59. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
  60. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
  61. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
  62. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
  63. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
  64. package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
  65. package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
  66. package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
  67. package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
  68. package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
  69. package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
  70. package/plugins/immune-brain/bin/imm-retired +0 -4
  71. package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
  72. package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
  73. package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
  74. package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
  75. package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
  76. package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +0 -132
@@ -1,244 +1,284 @@
1
- // Host-neutral verification descriptor runner.
2
- //
3
- // The strict canonical parser for `assurance_kernel/verification_descriptor/v1`
4
- // is `runtime/verification_descriptor.ts`. This module binds the frozen Bun
5
- // runner, compatibility gate, fixed execution, and findings digest.
1
+ // Shared, bounded execution of project commands. No language/tool registry.
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { execFileSync, spawn } from "node:child_process";
4
+ import { accessSync, constants, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
5
+ import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { VERIFICATION_DESCRIPTOR_BOUNDS, VerificationDescriptorError, type VerificationCommand } from "../verification_descriptor";
7
+ export * from "../verification_descriptor";
6
8
 
7
- import { createHash } from "node:crypto";
8
- import { execFileSync, spawn, spawnSync } from "node:child_process";
9
- import { realpathSync, statSync } from "node:fs";
10
- import { isAbsolute, join, resolve, sep, relative } from "node:path";
11
-
12
-
13
- export {
14
- VERIFICATION_DESCRIPTOR_CONTRACT,
15
- parseVerificationDescriptor,
16
- canonicalDescriptorBytes,
17
- VerificationDescriptorError,
18
- VERIFICATION_DESCRIPTOR_BOUNDS,
19
- type VerificationDescriptor,
20
- } from "../verification_descriptor";
21
- import {
22
- VERIFICATION_DESCRIPTOR_BOUNDS,
23
- VerificationDescriptorError,
24
- type VerificationDescriptor,
25
- } from "../verification_descriptor";
26
-
27
- /** Host-owned frozen runner identity: absolute realpath, device/inode, content hash, version. */
28
- export interface FrozenRunner {
29
- runner_id: "bun";
9
+ export interface ExecutableIdentity {
30
10
  path: string;
11
+ invocation_path: string;
31
12
  dev: number;
32
13
  ino: number;
33
14
  content_hash: string;
34
- version: string;
15
+ }
16
+ export interface FrozenCommand {
17
+ entry: ExecutableIdentity;
18
+ interpreter: ExecutableIdentity | null;
19
+ interpreter_args: string[];
20
+ }
21
+ export interface VerificationResult {
22
+ exit_code: number;
23
+ stdout: string;
24
+ stderr: string;
25
+ timed_out: boolean;
26
+ output_limited: boolean;
35
27
  }
36
28
 
37
- export function resolveBunRunner(): FrozenRunner {
38
- let executable: string;
39
- try {
40
- executable = execFileSync("which", ["bun"], { encoding: "utf8" }).trim();
41
- } catch {
42
- throw new VerificationDescriptorError("bun runner is unavailable on this host");
29
+ export class VerificationAbortedError extends Error {
30
+ constructor() { super("fixed verification aborted"); this.name = "VerificationAbortedError"; }
31
+ }
32
+ export class VerificationLaunchError extends Error {
33
+ constructor() { super("verification process failed to start"); this.name = "VerificationLaunchError"; }
34
+ }
35
+ export class VerificationCleanupError extends Error {
36
+ constructor() { super("verification process cleanup failed"); this.name = "VerificationCleanupError"; }
37
+ }
38
+
39
+ // Bounded window for proving that a signalled process is gone. SIGKILL delivery
40
+ // is not proof of exit, so the set is re-scanned and re-checked until it drains.
41
+ export const CLEANUP_CONFIRM_TIMEOUT_MS = 2_000;
42
+ export const CLEANUP_CONFIRM_POLL_MS = 25;
43
+
44
+ export function insideVerificationRoot(root: string, candidate: string): string {
45
+ const realRoot = realpathSync(root);
46
+ const real = realpathSync(candidate);
47
+ const rel = relative(realRoot, real);
48
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
49
+ throw new VerificationDescriptorError("verification path escapes the materialization");
50
+ return real;
51
+ }
52
+
53
+ export function verificationPath(): string {
54
+ return (process.env.PATH ?? "").split(delimiter).filter(path => isAbsolute(path)).join(delimiter);
55
+ }
56
+
57
+ function toolPath(name: string, path: string): string {
58
+ for (const dir of path.split(delimiter).filter(isAbsolute)) {
59
+ const candidate = join(dir, name);
60
+ try { accessSync(candidate, constants.X_OK); if (statSync(candidate).isFile()) return candidate; }
61
+ catch { /* Search the remaining host-owned PATH entries. */ }
43
62
  }
44
- let real: string;
63
+ throw new VerificationDescriptorError("verification tool is unavailable on this host");
64
+ }
65
+
66
+ function identity(path: string): ExecutableIdentity {
45
67
  try {
46
- // When which returns a version-manager shim (e.g. mise or asdf),
47
- // realpathSync on the shim resolves to the version-manager binary rather
48
- // than bun. Ask bun itself for its real process.execPath.
49
- const execPath = spawnSync(executable, ["-e", "console.log(process.execPath)"], {
50
- encoding: "utf8",
51
- stdio: ["ignore", "pipe", "ignore"],
52
- });
53
- if (execPath.status === 0 && execPath.stdout.trim().length > 0) {
54
- real = realpathSync(execPath.stdout.trim());
68
+ const real = realpathSync(path);
69
+ accessSync(real, constants.X_OK);
70
+ const stat = statSync(real);
71
+ if (!stat.isFile()) throw new Error("not a file");
72
+ return { path: real, invocation_path: path, dev: stat.dev, ino: stat.ino, content_hash: `sha256:${createHash("sha256").update(readFileSync(real)).digest("hex")}` };
73
+ } catch { throw new VerificationDescriptorError("verification executable is unavailable or not executable"); }
74
+ }
75
+
76
+ export function resolveVerificationCommand(root: string, command: VerificationCommand, path = verificationPath()): FrozenCommand {
77
+ insideVerificationRoot(root, resolve(root, command.cwd));
78
+ const entry = identity(command.executable.startsWith("./")
79
+ ? insideVerificationRoot(root, resolve(root, command.executable)) : toolPath(command.executable, path));
80
+ const prefix = readFileSync(entry.path).subarray(0, 512).toString("utf8");
81
+ let interpreter: ExecutableIdentity | null = null;
82
+ let interpreter_args: string[] = [];
83
+ if (prefix.startsWith("#!")) {
84
+ const lineEnd = prefix.indexOf("\n");
85
+ if (lineEnd < 0) throw new VerificationDescriptorError("verification interpreter declaration is unbounded");
86
+ const parts = prefix.slice(2, lineEnd).trim().split(/\s+/);
87
+ const name = parts.shift()!;
88
+ if (!isAbsolute(name)) throw new VerificationDescriptorError("verification interpreter must be absolute");
89
+ if (name === "/usr/bin/env" || name === "/bin/env") {
90
+ if (parts.length !== 1 || !/^[A-Za-z0-9_][A-Za-z0-9_.+-]*$/.test(parts[0]!))
91
+ throw new VerificationDescriptorError("verification interpreter resolution is ambiguous; use a direct interpreter command");
92
+ interpreter = identity(toolPath(parts[0]!, path));
55
93
  } else {
56
- real = realpathSync(executable);
94
+ if (parts.length > 1) throw new VerificationDescriptorError("verification interpreter arguments are ambiguous");
95
+ interpreter = identity(name);
96
+ interpreter_args = parts;
57
97
  }
58
- } catch {
59
- throw new VerificationDescriptorError("bun runner realpath is unresolvable");
60
- }
61
- let stat: ReturnType<typeof statSync>;
62
- try {
63
- stat = statSync(real);
64
- } catch {
65
- throw new VerificationDescriptorError("bun runner is unreadable");
66
- }
67
- if (!stat.isFile())
68
- throw new VerificationDescriptorError("bun runner is not a regular file");
69
- const { readFileSync } = require("node:fs") as typeof import("node:fs");
70
- const contentHash = `sha256:${createHash("sha256").update(readFileSync(real)).digest("hex")}`;
71
- let version = "";
72
- try {
73
- version = execFileSync(real, ["--version"], { encoding: "utf8" }).trim();
74
- } catch {
75
- throw new VerificationDescriptorError("bun runner version is unreadable");
98
+ if (readFileSync(interpreter.path).subarray(0, 2).toString() === "#!")
99
+ throw new VerificationDescriptorError("verification interpreter is itself a script; resolve the host tool explicitly");
76
100
  }
77
- return {
78
- runner_id: "bun",
79
- path: real,
80
- dev: stat.dev,
81
- ino: stat.ino,
82
- content_hash: contentHash,
83
- version,
84
- };
101
+ return { entry, interpreter, interpreter_args };
85
102
  }
86
103
 
87
- /** The descriptor's claimed runner version must equal the frozen host runner version. */
88
- export function assertRunnerCompatible(
89
- descriptor: VerificationDescriptor,
90
- runner: FrozenRunner,
91
- ): void {
92
- if (descriptor.runner_version !== runner.version)
93
- throw new VerificationDescriptorError(
94
- `frozen runner version mismatch: descriptor claims ${descriptor.runner_version}, host has ${runner.version}; assurance unavailable`,
95
- );
96
- }
97
-
98
- /** Raised when the host cancels a running fixed verification. */
99
- export class VerificationAbortedError extends Error {
100
- constructor() {
101
- super("fixed verification aborted");
102
- this.name = "VerificationAbortedError";
104
+ export function assertCommandIdentity(frozen: FrozenCommand): void {
105
+ for (const item of [frozen.entry, frozen.interpreter]) {
106
+ if (item && JSON.stringify(identity(item.invocation_path)) !== JSON.stringify(item))
107
+ throw new VerificationDescriptorError("verification executable identity changed");
103
108
  }
104
109
  }
105
110
 
106
- /** Run the frozen runner with the descriptor argv/cwd under minimal environment. */
107
111
  export async function runFixedVerification(
108
112
  root: string,
109
- descriptor: VerificationDescriptor,
110
- runner: FrozenRunner,
111
- options: { signal?: AbortSignal } = {},
112
- ): Promise<{ exit_code: number; stdout: string; stderr: string; timed_out: boolean }> {
113
- const canonicalRoot = resolve(root);
114
- const cwd = resolve(canonicalRoot, descriptor.cwd);
115
- if (cwd === canonicalRoot ? false : !relative(canonicalRoot, cwd).startsWith(".") === false)
116
- throw new VerificationDescriptorError("verification cwd escapes the repository");
117
- if (isAbsolute(descriptor.cwd) || relative(canonicalRoot, cwd).startsWith(`..${sep}`) || cwd === resolve(canonicalRoot, ".."))
118
- throw new VerificationDescriptorError("verification cwd escapes the repository");
113
+ command: VerificationCommand,
114
+ frozen: FrozenCommand,
115
+ options: { signal?: AbortSignal; home: string; path: string; _processScanner?: string; _procRoot?: string } ,
116
+ ): Promise<VerificationResult> {
119
117
  if (options.signal?.aborted) throw new VerificationAbortedError();
120
- if (process.platform === "win32")
121
- throw new VerificationDescriptorError("fixed verification process-group isolation requires a POSIX host");
122
- const maxOutput = Math.min(descriptor.max_output_bytes, VERIFICATION_DESCRIPTOR_BOUNDS.max_output_bytes);
118
+ if (process.platform === "win32") throw new VerificationDescriptorError("fixed verification process-group isolation requires a POSIX host");
119
+ const cwd = insideVerificationRoot(root, resolve(root, command.cwd));
120
+ assertCommandIdentity(frozen);
121
+ // Token discovery is platform native. Only the BSD path needs `ps`, whose
122
+ // environment modifier procps-ng rejects on Linux, and resolving it is a
123
+ // hard requirement there but pointless here.
124
+ const processScanner = process.platform === "linux" ? null
125
+ : identity(options._processScanner ?? toolPath("ps", verificationPath()));
126
+ const procRoot = options._procRoot ?? "/proc";
127
+ const maxOutput = Math.min(command.max_output_bytes, VERIFICATION_DESCRIPTOR_BOUNDS.max_output_bytes);
123
128
  return new Promise((resolvePromise, rejectPromise) => {
124
- let settled = false;
125
- let stdout = Buffer.alloc(0);
126
- let stderr = Buffer.alloc(0);
127
- let capturedBytes = 0;
128
- const child = spawn(runner.path, descriptor.argv, {
129
- cwd,
130
- env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
131
- detached: process.platform !== "win32",
129
+ let stdout = Buffer.alloc(0), stderr = Buffer.alloc(0), captured = 0;
130
+ let aborted = false, timed_out = false, output_limited = false, settled = false;
131
+ const processToken = randomBytes(24).toString("hex");
132
+ const args = frozen.interpreter
133
+ ? [...frozen.interpreter_args, frozen.entry.path, ...command.argv] : command.argv;
134
+ const child = spawn(frozen.interpreter?.invocation_path ?? frozen.entry.invocation_path, args, {
135
+ cwd, shell: false, detached: true,
136
+ env: { PATH: options.path, HOME: options.home, TMPDIR: options.home, XDG_CACHE_HOME: options.home,
137
+ IMM_VERIFICATION_PROCESS_TOKEN: processToken },
132
138
  stdio: ["ignore", "pipe", "pipe"],
133
139
  });
134
-
135
- const killTree = () => {
136
- if (child.pid === undefined) return;
137
- try {
138
- if (process.platform !== "win32") process.kill(-child.pid, "SIGKILL");
139
- else child.kill("SIGKILL");
140
- } catch {
141
- // The process already exited.
140
+ const killGroup = () => {
141
+ if (child.pid !== undefined) try { process.kill(-child.pid, "SIGKILL"); } catch { /* Already exited. */ }
142
+ };
143
+ // Signal every candidate and its process group. A refusal is not evidence in
144
+ // either direction, so the result is ignored and liveness below decides.
145
+ const signalPids = (pids: Iterable<number>): void => {
146
+ for (const pid of pids) for (const target of [-pid, pid]) {
147
+ try { process.kill(target, "SIGKILL"); } catch { /* Re-checked by liveness. */ }
142
148
  }
143
149
  };
144
- const cleanup = () => {
145
- clearTimeout(timeout);
146
- options.signal?.removeEventListener("abort", onAbort);
150
+ // ESRCH is the only evidence a process is gone: an EPERM refusal leaves it
151
+ // unknown, so it counts as still present and fails the cleanup closed.
152
+ const alive = (pid: number): boolean => {
153
+ try { process.kill(pid, 0); return true; }
154
+ catch (error) { return (error as NodeJS.ErrnoException | undefined)?.code !== "ESRCH"; }
155
+ };
156
+ // An environment we cannot read is only missing evidence about a process that
157
+ // already left this run's session; in-session processes are cleaned without it.
158
+ const procEnviron = (pid: number): string | null => {
159
+ try { return readFileSync(join(procRoot, String(pid), "environ"), "utf8"); }
160
+ catch { return null; }
161
+ };
162
+ // Session membership is the attribution evidence: /proc/<pid>/stat needs no
163
+ // ptrace privilege and stays truthful when a process changes its uid, so a
164
+ // descendant that drops privileges is still recognised as ours while an
165
+ // unrelated privileged process never blocks verification.
166
+ const procSession = (entry: string): number | null | undefined => {
167
+ let stat: string;
168
+ try { stat = readFileSync(join(procRoot, entry, "stat"), "utf8"); }
169
+ catch (error) {
170
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
171
+ return undefined;
172
+ }
173
+ // The quoted comm field may contain spaces and parentheses, so parse after it.
174
+ const fields = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/);
175
+ const session = Number(fields[3]);
176
+ // Session zero is a legitimate value that kernel threads carry, so only a
177
+ // missing or unobservable number means the session could not be read.
178
+ return Number.isSafeInteger(session) && session >= 0 ? session : undefined;
179
+ };
180
+ const scanTokenPids = (): Set<number> | null => {
181
+ if (child.pid === undefined) return null;
182
+ try {
183
+ // The direct child is known without discovery, so it is always a candidate.
184
+ const pids = new Set<number>([child.pid]);
185
+ if (process.platform === "linux") {
186
+ const marker = `IMM_VERIFICATION_PROCESS_TOKEN=${processToken}`;
187
+ for (const entry of readdirSync(procRoot)) {
188
+ if (!/^\d+$/.test(entry)) continue;
189
+ const session = procSession(entry);
190
+ if (session === undefined) throw new Error("verification process session is unreadable");
191
+ if (session === null) continue; // the process vanished mid-scan
192
+ // The command runs as its own session leader, so everything still in that
193
+ // session belongs to it whatever uid it has taken on since.
194
+ if (session === child.pid) { pids.add(Number(entry)); continue; }
195
+ // A process that left the session is only ours if it still carries the
196
+ // inherited token, which is checkable whenever the host lets us read it.
197
+ const environ = procEnviron(Number(entry));
198
+ if (environ !== null && environ.split("\0").includes(marker)) pids.add(Number(entry));
199
+ }
200
+ return pids;
201
+ }
202
+ if (processScanner === null || JSON.stringify(identity(processScanner.invocation_path)) !== JSON.stringify(processScanner))
203
+ throw new Error("process scanner identity changed");
204
+ for (const line of execFileSync(processScanner.invocation_path, ["eww", "-axo", "pid=,command="], {
205
+ encoding: "utf8", maxBuffer: 16 * 1024 * 1024, timeout: 5000,
206
+ }).split("\n")) {
207
+ if (!line.includes(processToken)) continue;
208
+ const pid = Number(/^\s*(\d+)/.exec(line)?.[1]);
209
+ if (Number.isSafeInteger(pid) && pid > 1) pids.add(pid);
210
+ }
211
+ return pids;
212
+ } catch { return null; }
147
213
  };
148
- const resolveOnce = (result: { exit_code: number; stdout: string; stderr: string; timed_out: boolean }) => {
214
+ // Cleanup must be proved before any outcome settles: a scan that fails and
215
+ // descendants still alive are both re-checked within the bounded window,
216
+ // and anything left unproven when it closes fails the run closed.
217
+ const killTree = async (): Promise<boolean> => {
218
+ const deadline = Date.now() + CLEANUP_CONFIRM_TIMEOUT_MS;
219
+ for (;;) {
220
+ // The direct child needs no discovery to be known, so it is signalled on
221
+ // every pass: a scanner failure must never return a running process.
222
+ if (child.pid !== undefined) signalPids([child.pid]);
223
+ const pids = scanTokenPids();
224
+ if (pids !== null) {
225
+ signalPids(pids);
226
+ if ([...pids].every((pid) => !alive(pid))) return true;
227
+ }
228
+ if (Date.now() >= deadline) return false;
229
+ await new Promise((wake) => setTimeout(wake, CLEANUP_CONFIRM_POLL_MS));
230
+ }
231
+ };
232
+ let timeout: ReturnType<typeof setTimeout> | undefined;
233
+ const closePipes = () => { child.stdout?.destroy(); child.stderr?.destroy(); };
234
+ const finish = async (code: number | null) => {
149
235
  if (settled) return;
150
236
  settled = true;
151
- cleanup();
152
- resolvePromise(result);
237
+ if (timeout) clearTimeout(timeout);
238
+ options.signal?.removeEventListener("abort", onAbort);
239
+ const cleaned = await killTree();
240
+ // An unproven cleanup outranks every outcome, cancellation included: any
241
+ // other error would hand the caller back a delivery whose process may
242
+ // still be running against it.
243
+ if (!cleaned) { rejectPromise(new VerificationCleanupError()); return; }
244
+ if (aborted) { rejectPromise(new VerificationAbortedError()); return; }
245
+ try { assertCommandIdentity(frozen); } catch (error) { rejectPromise(error); return; }
246
+ resolvePromise({ exit_code: timed_out || output_limited ? 1 : code ?? 1,
247
+ stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8"), timed_out, output_limited });
153
248
  };
154
- const rejectOnce = (error: Error) => {
249
+ const failLaunch = () => {
155
250
  if (settled) return;
156
251
  settled = true;
157
- cleanup();
158
- rejectPromise(error);
159
- };
160
- const appendBounded = (current: Buffer<ArrayBuffer>, chunk: Buffer<ArrayBuffer>): { content: Buffer<ArrayBuffer>; exceeded: boolean } => {
161
- const available = Math.max(0, maxOutput - capturedBytes);
162
- const accepted = chunk.subarray(0, available);
163
- capturedBytes += accepted.length;
164
- return {
165
- content: accepted.length > 0 ? Buffer.concat([current, accepted]) : current,
166
- exceeded: chunk.length > available,
167
- };
168
- };
169
- const outputLimitExceeded = () => {
170
- const marker = Buffer.from("verification output limit exceeded\n", "utf8");
171
- stdout = stdout.subarray(0, Math.max(0, maxOutput - marker.length));
172
- stderr = marker.subarray(0, maxOutput - stdout.length);
173
- capturedBytes = stdout.length + stderr.length;
174
- killTree();
175
- child.stdout?.destroy();
176
- child.stderr?.destroy();
177
- resolveOnce({
178
- exit_code: 1,
179
- stdout: stdout.toString("utf8"),
180
- stderr: stderr.toString("utf8"),
181
- timed_out: false,
182
- });
183
- };
184
- child.stdout?.on("data", (chunk: Buffer<ArrayBuffer>) => {
185
- const appended = appendBounded(stdout, chunk);
186
- stdout = appended.content;
187
- if (appended.exceeded) outputLimitExceeded();
188
- });
189
- child.stderr?.on("data", (chunk: Buffer<ArrayBuffer>) => {
190
- const appended = appendBounded(stderr, chunk);
191
- stderr = appended.content;
192
- if (appended.exceeded) outputLimitExceeded();
193
- });
194
- child.once("error", (error) => {
195
- resolveOnce({
196
- exit_code: 1,
197
- stdout: stdout.toString("utf8"),
198
- stderr: `${stderr.toString("utf8")}runner spawn failed: ${error.message}`.slice(0, maxOutput),
199
- timed_out: false,
200
- });
201
- });
202
- child.once("close", (code) => {
203
- resolveOnce({
204
- exit_code: code ?? 1,
205
- stdout: stdout.toString("utf8"),
206
- stderr: stderr.toString("utf8"),
207
- timed_out: false,
208
- });
209
- });
210
- const onAbort = () => {
211
- killTree();
212
- child.stdout?.destroy();
213
- child.stderr?.destroy();
214
- rejectOnce(new VerificationAbortedError());
252
+ if (timeout) clearTimeout(timeout);
253
+ options.signal?.removeEventListener("abort", onAbort);
254
+ closePipes();
255
+ // A launch failure still owes a confirmed cleanup before it settles.
256
+ void killTree().then((cleaned) => rejectPromise(
257
+ cleaned ? new VerificationLaunchError() : new VerificationCleanupError()));
215
258
  };
259
+ const onAbort = () => { aborted = true; closePipes(); void finish(null); };
216
260
  options.signal?.addEventListener("abort", onAbort, { once: true });
217
- const timeout = setTimeout(() => {
218
- killTree();
219
- child.stdout?.destroy();
220
- child.stderr?.destroy();
221
- resolveOnce({
222
- exit_code: 1,
223
- stdout: stdout.toString("utf8"),
224
- stderr: stderr.toString("utf8"),
225
- timed_out: true,
226
- });
227
- }, descriptor.timeout_ms);
261
+ if (options.signal?.aborted) onAbort();
262
+ if (!settled) timeout = setTimeout(() => { timed_out = true; closePipes(); void finish(null); }, command.timeout_ms);
263
+ const append = (chunk: Buffer, channel: "stdout" | "stderr") => {
264
+ if (output_limited) return;
265
+ const accepted = chunk.subarray(0, Math.max(0, maxOutput - captured));
266
+ captured += accepted.length;
267
+ if (channel === "stdout") stdout = Buffer.concat([stdout, accepted]);
268
+ else stderr = Buffer.concat([stderr, accepted]);
269
+ if (accepted.length < chunk.length) { output_limited = true; closePipes(); void finish(null); }
270
+ };
271
+ child.stdout?.on("data", (chunk: Buffer) => append(chunk, "stdout"));
272
+ child.stderr?.on("data", (chunk: Buffer) => append(chunk, "stderr"));
273
+ child.once("error", failLaunch);
274
+ // A successful parent must not leave descendants holding pipes open.
275
+ child.once("exit", killGroup);
276
+ child.once("close", (code) => { void finish(code); });
228
277
  });
229
278
  }
230
279
 
231
- /** Normalized findings digest: stable-sorted keys over id/kind/acceptance_id/summary. */
232
- export function findingsDigest(
233
- findings: Array<{ id: string; kind: string; acceptance_id: string | null; summary: string }>,
234
- ): string {
235
- const normalized = findings.map((f) =>
236
- JSON.stringify({
237
- acceptance_id: f.acceptance_id,
238
- id: f.id,
239
- kind: f.kind,
240
- summary: f.summary,
241
- }),
242
- );
280
+ /** Must remain byte-identical to the Kernel findings digest. */
281
+ export function findingsDigest(findings: Array<{ id: string; kind: string; acceptance_id: string | null; summary: string }>): string {
282
+ const normalized = findings.map(f => JSON.stringify({ acceptance_id: f.acceptance_id, id: f.id, kind: f.kind, summary: f.summary }));
243
283
  return `sha256:${createHash("sha256").update(`[${normalized.join(",")}]`).digest("hex")}`;
244
284
  }
@@ -0,0 +1,20 @@
1
+ // Which authority operation a Host may request is Kernel projection policy, not
2
+ // Host policy: the Kernel's own readiness is the sole source. Both Hosts derive
3
+ // the operation here, so the derivation cannot drift between them — Claude used
4
+ // to re-implement this mapping inline.
5
+
6
+ import type { AssuranceAuthorizationReadiness } from "./kernel/assurance_projection";
7
+
8
+ export type DerivedAuthorizationOperation =
9
+ | "resolve-user-decision"
10
+ | "authorize-rework";
11
+
12
+ // Kernel projection is the sole source of authorization readiness.
13
+ export function deriveAuthorizationOperation(input: {
14
+ readiness: AssuranceAuthorizationReadiness;
15
+ }): { operation: DerivedAuthorizationOperation } | { blocked: string } {
16
+ if (input.readiness.state === "resolve_user_decision") return { operation: "resolve-user-decision" };
17
+ if (input.readiness.state === "authorize_rework") return { operation: "authorize-rework" };
18
+ if (input.readiness.blocked) return { blocked: input.readiness.blocked };
19
+ return { blocked: "no unique host-derived authorization operation" };
20
+ }