immune-brain 5.1.1 → 5.1.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/package.json +1 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +20 -0
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +66 -11
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +15 -0
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +8256 -7417
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
- package/plugins/immune-brain/dist/imm-brainstorm.md +4 -4
- package/plugins/immune-brain/dist/imm-planner.md +37 -4
- package/plugins/immune-brain/dist/imm-run.md +123 -9
- package/plugins/immune-brain/dist/role-prompts/code-review.md +15 -0
- package/plugins/immune-brain/dist/role-prompts/executor.md +7 -0
- package/plugins/immune-brain/hooks/hooks.json +22 -1
- package/plugins/immune-brain/runtime/assurance/active_task_notice.ts +42 -0
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +9 -0
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +4 -1
- package/plugins/immune-brain/runtime/claude/interaction.ts +5 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +32 -6
- package/plugins/immune-brain/runtime/claude/lane_guard.ts +33 -0
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +66 -6
- package/plugins/immune-brain/runtime/commands/kernel.ts +54 -1
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +235 -7
- package/plugins/immune-brain/runtime/kernel/runtime_contracts.json +7 -0
- package/plugins/immune-brain/runtime/kernel/runtime_contracts.ts +75 -0
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +15 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +7 -0
- package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +160 -0
- package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +72 -0
- package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +396 -23
- package/plugins/immune-brain/runtime/unattended/batch_leak_restore.ts +131 -0
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +25 -4
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +3 -2
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +42 -0
- package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +40 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +155 -0
- package/plugins/immune-brain/runtime/unattended/lane_workspace.ts +58 -0
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +1 -1
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The contract identifiers one runtime reads and writes in a worktree's
|
|
2
|
+
// Authority Store. A lane-mode batch reads every Lane's Kernel state with the
|
|
3
|
+
// coordinator's own runtime, so the coordinator and the runtime a Lane
|
|
4
|
+
// Executor loads must agree on these identifiers; a plugin version number
|
|
5
|
+
// proves nothing (two different builds can carry the same one).
|
|
6
|
+
//
|
|
7
|
+
// `runtime_contracts.json` is the same set as data, shipped next to this
|
|
8
|
+
// module, so a coordinator can read another runtime's identifiers without
|
|
9
|
+
// executing its code. A test keeps the two in step.
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { TASK_TOMBSTONE_CONTRACT } from "./backend_claim";
|
|
14
|
+
import { KERNEL_STORE_SCHEMA_VERSION } from "./storage_paths";
|
|
15
|
+
import { TASK_RECORD_CONTRACT_V4 } from "./types";
|
|
16
|
+
|
|
17
|
+
export const RUNTIME_CONTRACTS_MANIFEST = "runtime_contracts.json";
|
|
18
|
+
|
|
19
|
+
export interface RuntimeContracts {
|
|
20
|
+
contract: "immune_brain/runtime_contracts/v1";
|
|
21
|
+
task_record: string;
|
|
22
|
+
task_tombstone: string;
|
|
23
|
+
assurance_projection: string;
|
|
24
|
+
kernel_store_schema: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The identifiers this running runtime supports. */
|
|
28
|
+
export const RUNTIME_CONTRACTS: RuntimeContracts = {
|
|
29
|
+
contract: "immune_brain/runtime_contracts/v1",
|
|
30
|
+
task_record: TASK_RECORD_CONTRACT_V4,
|
|
31
|
+
task_tombstone: TASK_TOMBSTONE_CONTRACT,
|
|
32
|
+
assurance_projection: "assurance_kernel/assurance_projection/v1",
|
|
33
|
+
kernel_store_schema: KERNEL_STORE_SCHEMA_VERSION,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const KEYS = ["task_record", "task_tombstone", "assurance_projection", "kernel_store_schema"] as const;
|
|
37
|
+
|
|
38
|
+
/** Where this running runtime's code was loaded from, for a refusal that names both sides. */
|
|
39
|
+
export function runningRuntimeSource(): string {
|
|
40
|
+
try {
|
|
41
|
+
return dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
} catch {
|
|
43
|
+
return "unknown";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read another runtime's identifiers from its manifest. `runtimeDir` is either
|
|
49
|
+
* the `runtime/kernel` directory or a plugin root that contains one. Null when
|
|
50
|
+
* no manifest exists there; a malformed manifest throws.
|
|
51
|
+
*/
|
|
52
|
+
export function readRuntimeContracts(runtimeDir: string): { contracts: RuntimeContracts; path: string } | null {
|
|
53
|
+
const candidates = [
|
|
54
|
+
join(runtimeDir, RUNTIME_CONTRACTS_MANIFEST),
|
|
55
|
+
join(runtimeDir, "kernel", RUNTIME_CONTRACTS_MANIFEST),
|
|
56
|
+
join(runtimeDir, "runtime", "kernel", RUNTIME_CONTRACTS_MANIFEST),
|
|
57
|
+
join(runtimeDir, "plugins", "immune-brain", "runtime", "kernel", RUNTIME_CONTRACTS_MANIFEST),
|
|
58
|
+
];
|
|
59
|
+
const path = candidates.find((candidate) => existsSync(candidate));
|
|
60
|
+
if (!path) return null;
|
|
61
|
+
const raw = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
62
|
+
if (raw.contract !== "immune_brain/runtime_contracts/v1")
|
|
63
|
+
throw new Error(`${path} is not an immune_brain/runtime_contracts/v1 manifest`);
|
|
64
|
+
for (const key of KEYS) {
|
|
65
|
+
const value = raw[key];
|
|
66
|
+
if (key === "kernel_store_schema" ? !Number.isSafeInteger(value) : typeof value !== "string" || !value)
|
|
67
|
+
throw new Error(`${path} has an invalid ${key}`);
|
|
68
|
+
}
|
|
69
|
+
return { contracts: raw as unknown as RuntimeContracts, path: resolve(path) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The identifiers on which two runtimes disagree; empty when they can read each other's state. */
|
|
73
|
+
export function runtimeContractDifferences(a: RuntimeContracts, b: RuntimeContracts): string[] {
|
|
74
|
+
return KEYS.filter((key) => a[key] !== b[key]).map((key) => `${key}: ${String(a[key])} != ${String(b[key])}`);
|
|
75
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by scripts/plugin_versioning.ts from the root package.json.
|
|
2
|
-
export const PLUGIN_VERSION = "5.1.
|
|
2
|
+
export const PLUGIN_VERSION = "5.1.2" as const;
|
|
@@ -34,6 +34,13 @@ about the fix's scope, not a new bug. A trigger the accepted contract explicitly
|
|
|
34
34
|
excludes is reported as an advisory note against that boundary, never as blocking
|
|
35
35
|
rework.
|
|
36
36
|
|
|
37
|
+
A defect that lies inside the TaskIntent goal and `scope_hint` is a blocking
|
|
38
|
+
finding even when no acceptance assertion names its exact case word for word:
|
|
39
|
+
cite the acceptance whose principle it violates as `acceptance_id` and
|
|
40
|
+
`violated.ref`. Ask for the fix and a test that pins the case, never for the case
|
|
41
|
+
to be written into the acceptance text; a missing literal clause is not itself a
|
|
42
|
+
finding and does not call for an Intent revision.
|
|
43
|
+
|
|
37
44
|
## Invariant and Evidence Coverage
|
|
38
45
|
|
|
39
46
|
Judge whether the change actually closes the invariant its acceptance names,
|
|
@@ -53,6 +60,14 @@ seam, or a seam silently replaced by a different one, is a finding carrying
|
|
|
53
60
|
named, its absence is not a finding: candidates written before this rule carries
|
|
54
61
|
no defect for lacking one.
|
|
55
62
|
|
|
63
|
+
When the Spec referenced by an acceptance maps an `automated` user scenario to
|
|
64
|
+
it, check that the delivered tests at that acceptance's agreed seam assert the
|
|
65
|
+
scenario's `Then`: a scenario whose observable result is asserted nowhere is a
|
|
66
|
+
finding carrying `violated.kind` of `acceptance` with `ref` the acceptance id.
|
|
67
|
+
A `manual` scenario, and an acceptance with no mapped scenario, produce no such
|
|
68
|
+
finding. This adds no finding kind and no verdict branch: it is the same
|
|
69
|
+
`acceptance` finding the seam rule above already defines.
|
|
70
|
+
|
|
56
71
|
Before pass, enumerate the immutable change set and read each complete per-file
|
|
57
72
|
diff, including deletions. Page bounded reads until every hunk is visible; a
|
|
58
73
|
truncated aggregate output or summary is incomplete coverage. Trace changed
|
|
@@ -54,6 +54,13 @@ Record missing evidence as blocked and repair it before handoff. The Loop owns
|
|
|
54
54
|
verdict submission, finding disposition and the second-rework generalize-or-refute
|
|
55
55
|
rule.
|
|
56
56
|
|
|
57
|
+
A finding whose defect lies inside the TaskIntent goal and `scope_hint` is
|
|
58
|
+
ordinary rework even when no acceptance names its exact case: fix the code and
|
|
59
|
+
add the test that pins the case. Do not propose an Intent revision for it, and
|
|
60
|
+
never append a single input case to an acceptance assertion to close a finding;
|
|
61
|
+
acceptance states principles, tests enumerate cases. Route to `imm-planner` only
|
|
62
|
+
when the goal, the scope, or a user-visible commitment must change.
|
|
63
|
+
|
|
57
64
|
## Code Quality Guard
|
|
58
65
|
|
|
59
66
|
Before handoff, check the implementation for real implementation rather than
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Delegated in-Lane Intent revision (ADR 0018). A lane batch whose literal-user
|
|
2
|
+
// confirmation granted `revision_delegation` lets the coordinator approve a
|
|
3
|
+
// Lane child's breaking Intent revision that stays inside the child's TaskIntent
|
|
4
|
+
// as authorized for the batch. The approval is the ordinary Kernel
|
|
5
|
+
// `approve_breaking_intent_revision`, applied in the Lane's own Authority Store
|
|
6
|
+
// with user authority whose actor and confirmation reference name the batch, so
|
|
7
|
+
// the audit history tells it apart from an approval by the user in person.
|
|
8
|
+
// Out-of-bounds revisions are refused here and stay with the user's own gate.
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { writeFileSync } from "node:fs";
|
|
12
|
+
import { projectAssurance } from "../kernel/assurance_projection";
|
|
13
|
+
import { createMutationAuthorityRegistry, digestOfAction, type CapabilityBindingV2 } from "../kernel/authority_port";
|
|
14
|
+
import { capabilityActionFor, createCanaryApplication } from "../kernel/canary_application";
|
|
15
|
+
import { canonicalIntentHash, classifyIntentRevision, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
|
|
16
|
+
import { boundSpecPath } from "../kernel/spec_binding";
|
|
17
|
+
import { readTaskRecordRaw, recoverKernelStoreFollowUps } from "../kernel/storage";
|
|
18
|
+
import type { TaskIntentV1 } from "../kernel/types";
|
|
19
|
+
import { stagePlanningArtifactTransition } from "../assurance/verdict_authority";
|
|
20
|
+
import { captureStagedIntent, restoreStagedIntent } from "../staged_intent";
|
|
21
|
+
import { taskDeliveryIdentity } from "../workspace_scope";
|
|
22
|
+
|
|
23
|
+
/** The grant and its bounds, stated in the gate that grants it (both Hosts). */
|
|
24
|
+
export const REVISION_DELEGATION_TEXT =
|
|
25
|
+
"Optional, off by default: also let the batch coordinator approve a Lane child's breaking Intent revisions that stay inside that child's authorized TaskIntent (acceptance changes, a narrower scope_hint). Widening scope, changing risk, goal or the bound Spec still needs your own confirmation. The grant ends with this batch.";
|
|
26
|
+
|
|
27
|
+
/** The actor every delegated approval records; never the literal user. */
|
|
28
|
+
export const DELEGATED_ACTOR_ID = "batch-coordinator";
|
|
29
|
+
|
|
30
|
+
export interface RevisionDelegationGrant {
|
|
31
|
+
/** The confirmation that granted it; a fresh confirmation replaces it. */
|
|
32
|
+
confirmation_time: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DelegatedRevisionRecord {
|
|
36
|
+
at: string;
|
|
37
|
+
task_id: string;
|
|
38
|
+
from_revision: number;
|
|
39
|
+
to_revision: number;
|
|
40
|
+
intent_content_hash: string;
|
|
41
|
+
confirmation_ref: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface LaneRevisionRequest {
|
|
45
|
+
task_id: string;
|
|
46
|
+
next_intent: TaskIntentV1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The confirmation reference that traces a delegated approval to its batch authorization. */
|
|
50
|
+
export function delegatedConfirmationRef(batchId: string, grant: RevisionDelegationGrant): string {
|
|
51
|
+
return `delegated-batch:${batchId}@${grant.confirmation_time}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Untrusted tool input: shape only; the bounds are decided against Kernel facts. */
|
|
55
|
+
export function parseLaneRevision(value: unknown): LaneRevisionRequest | undefined {
|
|
56
|
+
if (value === undefined || value === null) return undefined;
|
|
57
|
+
if (typeof value !== "object" || Array.isArray(value)) throw new Error("invalid lane_revision: expected an object");
|
|
58
|
+
const { task_id: taskId, next_intent: nextIntent, ...rest } = value as Record<string, unknown>;
|
|
59
|
+
if (Object.keys(rest).length > 0) throw new Error("invalid lane_revision: unknown field");
|
|
60
|
+
if (typeof taskId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId))
|
|
61
|
+
throw new Error("invalid lane_revision: task_id is not a valid task id");
|
|
62
|
+
const intent = parseTaskIntentV1(nextIntent);
|
|
63
|
+
if (intent.task_id !== taskId) throw new Error("invalid lane_revision: next_intent belongs to another task");
|
|
64
|
+
return { task_id: taskId, next_intent: intent };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether the delegation covers this revision. `authorized` is the child's
|
|
69
|
+
* TaskIntent as the batch authorized it; `current` is the Lane's enrolled one.
|
|
70
|
+
* Null means covered.
|
|
71
|
+
*/
|
|
72
|
+
export function delegatedRevisionRefusal(authorized: TaskIntentV1, current: TaskIntentV1, next: TaskIntentV1): string | null {
|
|
73
|
+
if (classifyIntentRevision(current, next) !== "breaking") return "only a breaking revision needs this approval; apply a compatible one with revise_intent";
|
|
74
|
+
if (next.revision <= current.revision) return "the revision number must increase";
|
|
75
|
+
if (next.goal !== authorized.goal || next.owner !== authorized.owner || next.task_id !== authorized.task_id)
|
|
76
|
+
return "goal, owner and task identity are outside the delegation";
|
|
77
|
+
if (next.risk !== authorized.risk || next.risk !== current.risk) return "a risk change is outside the delegation";
|
|
78
|
+
if ((boundSpecPath(next) ?? null) !== (boundSpecPath(authorized) ?? null)) return "a change of the bound Spec is outside the delegation";
|
|
79
|
+
const allowed = new Set(authorized.scope_hint);
|
|
80
|
+
const widened = next.scope_hint.filter((entry) => !allowed.has(entry));
|
|
81
|
+
if (widened.length > 0) return `widening scope_hint is outside the delegation: ${widened.join(", ")}`;
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Apply one delegated breaking revision in the Lane, exactly as a Host applies
|
|
87
|
+
* an approved one: write and stage the next sidecar, re-read the projection,
|
|
88
|
+
* issue a user capability bound to that snapshot, execute, and stage the
|
|
89
|
+
* planning transition. The prior sidecar bytes and index entry come back on any
|
|
90
|
+
* failure before commit.
|
|
91
|
+
*/
|
|
92
|
+
export function applyDelegatedIntentRevision(input: {
|
|
93
|
+
lane_root: string;
|
|
94
|
+
task_id: string;
|
|
95
|
+
next_intent: TaskIntentV1;
|
|
96
|
+
confirmation_ref: string;
|
|
97
|
+
now: string;
|
|
98
|
+
}): Promise<{ from_revision: number; to_revision: number; intent_content_hash: string }> {
|
|
99
|
+
return (async () => {
|
|
100
|
+
const { lane_root: root, task_id: taskId, next_intent: nextIntent, now } = input;
|
|
101
|
+
recoverKernelStoreFollowUps(root, taskId);
|
|
102
|
+
const prior = readTaskIntent(root, taskId, readTaskRecordRaw(root, taskId).record?.intent_ref.path);
|
|
103
|
+
const before = await projectAssurance(root, taskId, taskDeliveryIdentity);
|
|
104
|
+
if (before.error || before.claim?.task_id !== taskId || before.projection.lifecycle !== "active")
|
|
105
|
+
throw new Error(`the Lane does not hold an active run of ${taskId}`);
|
|
106
|
+
const nextHash = canonicalIntentHash(nextIntent);
|
|
107
|
+
const nextRef = { path: `docs/plans/${taskId}.intent.json`, content_hash: nextHash };
|
|
108
|
+
const snapshot = captureStagedIntent(root, prior.intent_ref.path);
|
|
109
|
+
try {
|
|
110
|
+
writeFileSync(join(root, prior.intent_ref.path), `${JSON.stringify(nextIntent, null, 2)}\n`);
|
|
111
|
+
execFileSync("git", ["add", "--", prior.intent_ref.path], { cwd: root, stdio: ["ignore", "pipe", "pipe"] });
|
|
112
|
+
const prepared = await projectAssurance(root, taskId, taskDeliveryIdentity);
|
|
113
|
+
if (prepared.error || prepared.projection.record_revision !== before.projection.record_revision)
|
|
114
|
+
throw new Error("the Lane's TaskRecord changed while the delegated revision was prepared");
|
|
115
|
+
const registry = createMutationAuthorityRegistry();
|
|
116
|
+
const action = capabilityActionFor({
|
|
117
|
+
op: "approve_breaking_intent_revision",
|
|
118
|
+
task_id: taskId,
|
|
119
|
+
at: now,
|
|
120
|
+
actor_id: DELEGATED_ACTOR_ID,
|
|
121
|
+
next_intent: nextIntent,
|
|
122
|
+
next_intent_ref: nextRef,
|
|
123
|
+
});
|
|
124
|
+
const binding: CapabilityBindingV2 = {
|
|
125
|
+
authority_kind: "user",
|
|
126
|
+
task_id: taskId,
|
|
127
|
+
...(prepared.projection.run_id ? { run_id: prepared.projection.run_id } : {}),
|
|
128
|
+
action_digest: digestOfAction(action),
|
|
129
|
+
expected_record_hash: prepared.projection.record_revision,
|
|
130
|
+
intent_revision: nextIntent.revision,
|
|
131
|
+
intent_content_hash: nextHash,
|
|
132
|
+
diff_hash: prepared.projection.diff_hash,
|
|
133
|
+
actor_id: DELEGATED_ACTOR_ID,
|
|
134
|
+
confirmation_ref: input.confirmation_ref,
|
|
135
|
+
findings_digest: null,
|
|
136
|
+
};
|
|
137
|
+
const capability = registry.issue(binding);
|
|
138
|
+
const result = createCanaryApplication(registry).execute({
|
|
139
|
+
root,
|
|
140
|
+
task_id: taskId,
|
|
141
|
+
operation: {
|
|
142
|
+
op: "approve_breaking_intent_revision",
|
|
143
|
+
capability,
|
|
144
|
+
actor_id: DELEGATED_ACTOR_ID,
|
|
145
|
+
next_intent: nextIntent,
|
|
146
|
+
next_intent_ref: nextRef,
|
|
147
|
+
} as never,
|
|
148
|
+
prior_intent_token: prior.token,
|
|
149
|
+
diffProvider: taskDeliveryIdentity,
|
|
150
|
+
now,
|
|
151
|
+
});
|
|
152
|
+
stagePlanningArtifactTransition(root, result.record);
|
|
153
|
+
return { from_revision: prior.intent.revision, to_revision: nextIntent.revision, intent_content_hash: nextHash };
|
|
154
|
+
} catch (error) {
|
|
155
|
+
const current = readTaskRecordRaw(root, taskId);
|
|
156
|
+
if (current.record?.intent_snapshot.revision === prior.intent.revision) restoreStagedIntent(root, snapshot);
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
})();
|
|
160
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Full verification of the batch branch once every child is integrated. The
|
|
2
|
+
// commands are the project's own convention, resolved by the Parent from its
|
|
3
|
+
// instruction files and recorded on the batch record when the batch starts, so
|
|
4
|
+
// the completing tick runs exactly what the literal user saw at confirmation.
|
|
5
|
+
// A failure marks the completion report as not passed; integrated commits are
|
|
6
|
+
// never rolled back. Only exit status and timing are recorded, never output.
|
|
7
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
export const MAX_FINAL_VERIFICATION_COMMANDS = 8;
|
|
10
|
+
const MAX_COMMAND_LENGTH = 512;
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
export interface FinalVerificationResult {
|
|
14
|
+
command: string;
|
|
15
|
+
exit_code: number | null;
|
|
16
|
+
signal: string | null;
|
|
17
|
+
passed: boolean;
|
|
18
|
+
duration_ms: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FinalVerificationReport {
|
|
22
|
+
passed: boolean;
|
|
23
|
+
head: string | null;
|
|
24
|
+
results: FinalVerificationResult[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Untrusted tool input: a short list of plain commands, split on whitespace and never run through a shell. */
|
|
28
|
+
export function parseFinalVerification(value: unknown): string[] | undefined {
|
|
29
|
+
if (value === undefined || value === null) return undefined;
|
|
30
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_FINAL_VERIFICATION_COMMANDS)
|
|
31
|
+
throw new Error(`invalid final_verification: expected 1 to ${MAX_FINAL_VERIFICATION_COMMANDS} commands`);
|
|
32
|
+
return value.map((command) => {
|
|
33
|
+
if (typeof command !== "string" || !command.trim() || command.length > MAX_COMMAND_LENGTH || /[\0\n\r]/.test(command))
|
|
34
|
+
throw new Error("invalid final_verification: each command must be one non-empty line");
|
|
35
|
+
if (/[|&;<>`$(){}"'\\]/.test(command))
|
|
36
|
+
throw new Error("invalid final_verification: commands run without a shell and are split on spaces, so shell syntax and quoting are not allowed");
|
|
37
|
+
return command.trim().replace(/\s+/g, " ");
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One command, asynchronously, so a long suite never blocks the Host's event loop. */
|
|
42
|
+
function runOne(root: string, command: string, timeoutMs: number): Promise<FinalVerificationResult> {
|
|
43
|
+
const [executable, ...argv] = command.split(" ");
|
|
44
|
+
const started = Date.now();
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
let settled = false;
|
|
47
|
+
const finish = (exitCode: number | null, signal: string | null) => {
|
|
48
|
+
if (settled) return;
|
|
49
|
+
settled = true;
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
resolve({ command, exit_code: exitCode, signal, passed: exitCode === 0, duration_ms: Date.now() - started });
|
|
52
|
+
};
|
|
53
|
+
const child = spawn(executable!, argv, { cwd: root, stdio: "ignore", env: process.env });
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
child.kill("SIGKILL");
|
|
56
|
+
finish(null, "timeout");
|
|
57
|
+
}, timeoutMs);
|
|
58
|
+
child.once("error", (error) => finish(null, String((error as NodeJS.ErrnoException).code ?? "spawn_failed")));
|
|
59
|
+
child.once("close", (code, signal) => finish(code, signal));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function runFinalVerification(
|
|
64
|
+
root: string,
|
|
65
|
+
commands: readonly string[],
|
|
66
|
+
options: { timeoutMs?: number } = {},
|
|
67
|
+
): Promise<FinalVerificationReport> {
|
|
68
|
+
const head = spawnSync("git", ["-C", root, "rev-parse", "HEAD"], { encoding: "utf8" });
|
|
69
|
+
const results: FinalVerificationResult[] = [];
|
|
70
|
+
for (const command of commands) results.push(await runOne(root, command, options.timeoutMs ?? DEFAULT_TIMEOUT_MS));
|
|
71
|
+
return { passed: results.every((result) => result.passed), head: head.status === 0 ? head.stdout.trim() : null, results };
|
|
72
|
+
}
|