immune-brain 3.6.9 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +7587 -5049
- package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
- package/plugins/immune-brain/dist/imm-loop.md +27 -25
- package/plugins/immune-brain/dist/imm-planner.md +34 -27
- package/plugins/immune-brain/dist/imm-review-retro.md +2 -2
- package/plugins/immune-brain/dist/role-prompts/code-review.md +3 -1
- package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
- package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
- package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
- package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
- package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +1 -1
- package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
- package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
- package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
- package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
- package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
- package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
- package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
- package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
- package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
- package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
- package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
- package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
- package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
- package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
- package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
- package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
- package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
- package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
- package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
- package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
- package/plugins/immune-brain/runtime/plan_core.ts +27 -65
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +3 -1
- package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
- package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
- package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
- package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
- package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
- package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
- package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
- package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
- package/plugins/immune-brain/bin/imm-retired +0 -4
- package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
- package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
- package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
- package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
- package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
- package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +0 -132
|
@@ -1,244 +1,284 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
63
|
+
throw new VerificationDescriptorError("verification tool is unavailable on this host");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function identity(path: string): ExecutableIdentity {
|
|
45
67
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
94
|
+
if (parts.length > 1) throw new VerificationDescriptorError("verification interpreter arguments are ambiguous");
|
|
95
|
+
interpreter = identity(name);
|
|
96
|
+
interpreter_args = parts;
|
|
57
97
|
}
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
options: { signal?: AbortSignal
|
|
112
|
-
): Promise<
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
125
|
-
let
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
|
249
|
+
const failLaunch = () => {
|
|
155
250
|
if (settled) return;
|
|
156
251
|
settled = true;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
/**
|
|
232
|
-
export function findingsDigest(
|
|
233
|
-
findings
|
|
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
|
+
}
|