@evoclock/pi-agentic-driver 0.5.0 → 0.7.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/README.md +321 -215
- package/extensions/attended-authority-guard.ts +7 -0
- package/package.json +4 -1
- package/scripts/enforcement/attended_authority_guard.js +223 -0
- package/scripts/enforcement/guest_containment_taxonomy.v1.json +26 -0
- package/scripts/enforcement/herdr_async_dispatch_pi.js +209 -44
- package/scripts/enforcement/herdr_communication_pi.js +113 -15
- package/scripts/enforcement/linux_microvm_cutover_pi.js +184 -17
- package/scripts/enforcement/linux_microvm_remote_fixture.sh +2426 -19
|
@@ -131,6 +131,7 @@ function errorResult(operation, error) {
|
|
|
131
131
|
code: known.code,
|
|
132
132
|
reason: known.message,
|
|
133
133
|
...(known.diagnostic ? { diagnostic: known.diagnostic } : {}),
|
|
134
|
+
...(known.deliveryState ? { deliveryState: known.deliveryState } : {}),
|
|
134
135
|
nonAuthorizing: true,
|
|
135
136
|
authorityCreated: false,
|
|
136
137
|
};
|
|
@@ -676,7 +677,7 @@ async function invokeHerdr(action, params, context, options = {}, signal) {
|
|
|
676
677
|
timeoutMs: processTimeout,
|
|
677
678
|
maxOutputBytes: MAX_PROCESS_OUTPUT_BYTES,
|
|
678
679
|
});
|
|
679
|
-
} catch {
|
|
680
|
+
} catch (error) {
|
|
680
681
|
return { internalFailure: "spawn_error" };
|
|
681
682
|
}
|
|
682
683
|
raw = await awaitBounded(pending, processTimeout, signal);
|
|
@@ -992,15 +993,15 @@ function allMarkerOccurrences(text, standaloneOnly = false, additionalPair = und
|
|
|
992
993
|
return occurrences.sort((left, right) => left.index - right.index || left.end - right.end);
|
|
993
994
|
}
|
|
994
995
|
|
|
995
|
-
function promptContractRange(text, marker, occurrenceIndex) {
|
|
996
|
+
function promptContractRange(text, marker, occurrenceIndex, { boundEnd = false } = {}) {
|
|
996
997
|
const anchor = text.lastIndexOf(REPORT_CONTRACT_LINE, occurrenceIndex);
|
|
997
998
|
if (anchor < 0) return undefined;
|
|
998
999
|
const markerStart = anchor + REPORT_CONTRACT_LINE.length;
|
|
999
1000
|
const open = text.indexOf(marker.open, markerStart);
|
|
1000
1001
|
if (open < 0 || open > occurrenceIndex) return undefined;
|
|
1001
1002
|
const close = text.indexOf(marker.close, open + marker.open.length);
|
|
1002
|
-
if (close < occurrenceIndex) return undefined;
|
|
1003
1003
|
const end = close + marker.close.length;
|
|
1004
|
+
if (boundEnd && end > occurrenceIndex) return undefined;
|
|
1004
1005
|
if (Buffer.byteLength(text.slice(anchor, end), "utf8") > MAX_PROMPT_CONTRACT_ECHO_BYTES) return undefined;
|
|
1005
1006
|
if (!/^\s*$/.test(text.slice(markerStart, open)) || !/^\s*$/.test(text.slice(open + marker.open.length, close))) return undefined;
|
|
1006
1007
|
const otherMarkers = [...new Set(Object.values(REPORT_MARKERS)
|
|
@@ -1053,16 +1054,11 @@ function extractLatestReport(text, role) {
|
|
|
1053
1054
|
}
|
|
1054
1055
|
const prior = relevant.at(-3);
|
|
1055
1056
|
if (prior?.marker === marker.open) {
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
|
|
1061
|
-
const historicalPrefix = prefixOpenCount === 1
|
|
1062
|
-
&& text.slice(0, prior.index).trim().length > 0;
|
|
1063
|
-
if (!historicalPrefix) {
|
|
1064
|
-
throw communicationError("report_duplicate_open", "the latest role report contains a duplicate or nested opening marker");
|
|
1065
|
-
}
|
|
1057
|
+
// A duplicate or nested opening before the latest pair is never
|
|
1058
|
+
// reclassified as stale history: stale unmatched opens are surfaced as
|
|
1059
|
+
// bounded evidence in the prompt flow only, and a plain read stays
|
|
1060
|
+
// fail-closed.
|
|
1061
|
+
throw communicationError("report_duplicate_open", "the latest role report contains a duplicate or nested opening marker");
|
|
1066
1062
|
}
|
|
1067
1063
|
const rawBody = text.slice(open.end, close.index);
|
|
1068
1064
|
const nestedMarkers = allMarkerOccurrences(rawBody, false, marker)
|
|
@@ -1083,6 +1079,76 @@ function extractLatestReport(text, role) {
|
|
|
1083
1079
|
return body;
|
|
1084
1080
|
}
|
|
1085
1081
|
|
|
1082
|
+
function lineStarts(text) {
|
|
1083
|
+
const starts = [0];
|
|
1084
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
1085
|
+
if (text[index] === "\n") starts.push(index + 1);
|
|
1086
|
+
}
|
|
1087
|
+
return starts;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function provenancedReportSegment(pre, post, sentPrompt, role) {
|
|
1091
|
+
const marker = reportMarkersForRole(role);
|
|
1092
|
+
const preLines = pre.split("\n");
|
|
1093
|
+
const starts = lineStarts(post);
|
|
1094
|
+
const candidates = [];
|
|
1095
|
+
let from = 0;
|
|
1096
|
+
while (from <= post.length) {
|
|
1097
|
+
const start = post.indexOf(sentPrompt, from);
|
|
1098
|
+
if (start < 0) break;
|
|
1099
|
+
from = start + 1;
|
|
1100
|
+
if (!starts.includes(start)) continue;
|
|
1101
|
+
const range = marker && promptContractRange(post, marker, start + sentPrompt.length, { boundEnd: true });
|
|
1102
|
+
if (!range || range.end !== start + sentPrompt.length) continue;
|
|
1103
|
+
const prefix = post.slice(0, start);
|
|
1104
|
+
const prefixLines = prefix.endsWith("\n") ? prefix.slice(0, -1).split("\n") : prefix.split("\n");
|
|
1105
|
+
const shared = Math.min(prefixLines.length, preLines.length);
|
|
1106
|
+
if (shared < 1) continue;
|
|
1107
|
+
const preFirstLine = preLines[0];
|
|
1108
|
+
const prefixContainsPreSnapshot = prefixLines.some((line) => line === preFirstLine);
|
|
1109
|
+
const preTailMatchesPrefix = preLines.slice(-shared).every((line, index) => line === prefixLines[prefixLines.length - shared + index]);
|
|
1110
|
+
if (prefixContainsPreSnapshot || preTailMatchesPrefix) {
|
|
1111
|
+
candidates.push({ start, end: range.end, aligned: shared });
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
if (!candidates.length) throw communicationError("report_scope_unavailable", "the prompted exchange could not be proven from terminal history");
|
|
1115
|
+
const max = Math.max(...candidates.map((candidate) => candidate.aligned));
|
|
1116
|
+
const winners = candidates.filter((candidate) => candidate.aligned === max);
|
|
1117
|
+
if (winners.length !== 1) throw communicationError("report_scope_unavailable", "the prompted exchange boundary is ambiguous");
|
|
1118
|
+
return winners[0];
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function extractReportFromSegment(text, role, fromIndex = 0, fullText = text) {
|
|
1122
|
+
const marker = reportMarkersForRole(role);
|
|
1123
|
+
const segment = text.slice(fromIndex);
|
|
1124
|
+
const relevant = allMarkerOccurrences(segment, true, marker)
|
|
1125
|
+
.filter((item) => (item.marker === marker.open || item.marker === marker.close)
|
|
1126
|
+
&& !isPromptContractMarker(fullText, { index: item.index + fromIndex, end: item.end + fromIndex }, marker));
|
|
1127
|
+
if (!relevant.length) throw communicationError("report_missing", "no complete role-specific report was observed");
|
|
1128
|
+
const close = relevant.at(-1);
|
|
1129
|
+
if (close.marker === marker.open) {
|
|
1130
|
+
throw communicationError("report_truncated", "the latest role report has no closing marker");
|
|
1131
|
+
}
|
|
1132
|
+
const open = relevant.at(-2);
|
|
1133
|
+
if (!open || open.marker !== marker.open) {
|
|
1134
|
+
throw communicationError("report_reversed", "the latest role report has no matching opening marker");
|
|
1135
|
+
}
|
|
1136
|
+
const rawBody = segment.slice(open.end, close.index);
|
|
1137
|
+
const nestedMarkers = allMarkerOccurrences(rawBody, false, marker)
|
|
1138
|
+
.filter((item) => !isPromptContractMarker(fullText, { index: open.end + item.index, end: open.end + item.end }, marker));
|
|
1139
|
+
if (nestedMarkers.length) {
|
|
1140
|
+
throw communicationError("report_nested", "the latest role report contains a nested report marker");
|
|
1141
|
+
}
|
|
1142
|
+
const body = removePromptContractEchoes(rawBody, marker)
|
|
1143
|
+
.replace(/^[ \t]*\r?\n/, "")
|
|
1144
|
+
.replace(/\r?\n[ \t]*$/, "");
|
|
1145
|
+
if (!body.trim()) throw communicationError("report_empty", "the latest role report is empty");
|
|
1146
|
+
if (Buffer.byteLength(body, "utf8") > MAX_REPORT_BYTES) {
|
|
1147
|
+
throw communicationError("report_oversized", "the latest role report exceeds the bounded report size");
|
|
1148
|
+
}
|
|
1149
|
+
return body;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1086
1152
|
export function extractLatestHerdrReport(text, role) {
|
|
1087
1153
|
requireRole(role);
|
|
1088
1154
|
if (typeof text !== "string") throw communicationError("report_missing", "report history is not text");
|
|
@@ -1119,7 +1185,36 @@ export async function executeHerdrCommunication(params, context, options = {}, s
|
|
|
1119
1185
|
// change before it accepts settlement. A separate wait command can race
|
|
1120
1186
|
// and match the role's pre-existing idle state, reading the empty marker
|
|
1121
1187
|
// template before the new response exists.
|
|
1122
|
-
|
|
1188
|
+
let pre;
|
|
1189
|
+
try {
|
|
1190
|
+
pre = readText(await invokeHerdr("read", { action: "read", role }, context, options, signal));
|
|
1191
|
+
} catch {
|
|
1192
|
+
throw communicationError("report_scope_unavailable", "the pre-prompt terminal snapshot is unavailable");
|
|
1193
|
+
}
|
|
1194
|
+
const revalidated = await invokeHerdr("get", { action: "get", role }, context, options, signal);
|
|
1195
|
+
publicAgentObservation(extractAgent(revalidated, "agent_info"), role, repositories, { requirePromptable: true });
|
|
1196
|
+
let prompted;
|
|
1197
|
+
try {
|
|
1198
|
+
prompted = await invokeHerdr(operation, request, context, options, signal);
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
if (!(error instanceof HerdrCommunicationError) || error.code !== "prompt_stalled") throw error;
|
|
1201
|
+
try {
|
|
1202
|
+
const recovered = await invokeHerdr("get", { action: "get", role }, context, options, signal);
|
|
1203
|
+
const recovery = publicAgentObservation(extractAgent(recovered, "agent_info"), role, repositories);
|
|
1204
|
+
const recoveredSeq = stateChangeSeq(recovered);
|
|
1205
|
+
const initialSeq = stateChangeSeq(current);
|
|
1206
|
+
if (recovery.status === "idle" && initialSeq !== undefined && recoveredSeq !== undefined && recoveredSeq !== initialSeq) throw error;
|
|
1207
|
+
const unknown = communicationError("prompt_delivery_unknown", "prompt delivery is unknown; the adapter did not retry, and only the caller may issue a new explicit prompt");
|
|
1208
|
+
unknown.deliveryState = "unknown";
|
|
1209
|
+
throw unknown;
|
|
1210
|
+
} catch (recoveryError) {
|
|
1211
|
+
if (recoveryError instanceof HerdrCommunicationError && ["prompt_stalled", "prompt_delivery_unknown"].includes(recoveryError.code)) throw recoveryError;
|
|
1212
|
+
const unknown = communicationError("prompt_delivery_unknown", "prompt delivery is unknown; the adapter did not retry, and only the caller may issue a new explicit prompt");
|
|
1213
|
+
unknown.deliveryState = "unknown";
|
|
1214
|
+
unknown.diagnostic = boundedFailureDiagnostic(recoveryError?.message);
|
|
1215
|
+
throw unknown;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1123
1218
|
const observation = publicAgentObservation(extractAgent(prompted, "agent_prompted"), role, repositories);
|
|
1124
1219
|
const waitedStatus = observation.status;
|
|
1125
1220
|
if (!WAIT_STATUSES.has(waitedStatus)) {
|
|
@@ -1129,7 +1224,10 @@ export async function executeHerdrCommunication(params, context, options = {}, s
|
|
|
1129
1224
|
return errorResult(operation, communicationError("role_blocked", "the prompted role reached blocked state", "blocked"));
|
|
1130
1225
|
}
|
|
1131
1226
|
const rawReport = await invokeHerdr("read", { action: "read", role }, context, options, signal);
|
|
1132
|
-
const
|
|
1227
|
+
const post = readText(rawReport);
|
|
1228
|
+
const sentPrompt = promptWithReportRequirement(role, request.prompt);
|
|
1229
|
+
const boundary = provenancedReportSegment(pre, post, sentPrompt, role);
|
|
1230
|
+
const report = extractReportFromSegment(post, role, boundary.end);
|
|
1133
1231
|
return successResult(operation, {
|
|
1134
1232
|
status: "complete",
|
|
1135
1233
|
role,
|
|
@@ -11,6 +11,17 @@ import { isNativeTuiContext } from "./native_tui_context.js";
|
|
|
11
11
|
|
|
12
12
|
export const LINUX_MICROVM_CUTOVER_TOOL = "agentic_linux_microvm_cutover";
|
|
13
13
|
export const LINUX_MICROVM_CUTOVER_SCHEMA = "agentic-driver.linux-microvm-cutover.v1";
|
|
14
|
+
// Containment variant (design section 5): same receipt shape plus one closed
|
|
15
|
+
// `containment` sub-object. v1 consumers stay safe; v2 is required whenever a
|
|
16
|
+
// containment run was requested (fail-closed otherwise).
|
|
17
|
+
export const LINUX_MICROVM_CUTOVER_SCHEMA_V2 = "agentic-driver.linux-microvm-cutover.v2";
|
|
18
|
+
const RECEIPT_SCHEMAS = new Set([LINUX_MICROVM_CUTOVER_SCHEMA, LINUX_MICROVM_CUTOVER_SCHEMA_V2]);
|
|
19
|
+
// Envelope-only relaxation (design sections 5, 6): on the console pty the
|
|
20
|
+
// guest may emit the framed containment envelope after the marker; those
|
|
21
|
+
// lines are parsed separately and never count as unbound output. Everything
|
|
22
|
+
// else remains "unbound output = failure".
|
|
23
|
+
const CONTAINMENT_MARKER_LINE = /^AGENTIC_CONTAINMENT_(BEGIN|END):[A-Za-z0-9._-]+$/;
|
|
24
|
+
export const GUEST_CONTAINMENT_LOG_SCHEMA = "agentic-driver.guest-containment.log.v1";
|
|
14
25
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
15
26
|
const REMOTE_FIXTURE = join(SCRIPT_DIR, "linux_microvm_remote_fixture.sh");
|
|
16
27
|
const TARGET_EXAMPLE = join(SCRIPT_DIR, "..", "..", "config", "microvm-target.v1.example.json");
|
|
@@ -96,7 +107,13 @@ function fixtureDomainForId(fixtureId) {
|
|
|
96
107
|
// choose or change the target. Read order: the user's own config
|
|
97
108
|
// (~/.pi/pi/config/microvm-target.v1.json) first, then the package-local
|
|
98
109
|
// config/microvm-target.v1.json (shipped as a REPLACE-WITH template).
|
|
99
|
-
const TARGET_FIELDS = new Set(["schema", "sshTarget", "local", "_comment"]);
|
|
110
|
+
const TARGET_FIELDS = new Set(["schema", "sshTarget", "local", "vcpu", "memoryMiB", "jobPayload", "_comment"]);
|
|
111
|
+
// Elastic resource allocation bounds (design section 6.1). User-configured
|
|
112
|
+
// through the target config only; the model-visible tool surface stays closed.
|
|
113
|
+
const VCPU_MIN = 1, VCPU_MAX = 64;
|
|
114
|
+
const MEMORY_MIN = 64, MEMORY_MAX = 1048576;
|
|
115
|
+
function validVcpu(value) { return Number.isInteger(value) && value >= VCPU_MIN && value <= VCPU_MAX; }
|
|
116
|
+
function validMemory(value) { return Number.isInteger(value) && value >= MEMORY_MIN && value <= MEMORY_MAX; }
|
|
100
117
|
export function loadMicroVMTarget(options = {}) {
|
|
101
118
|
if (options.target && typeof options.target === "object") {
|
|
102
119
|
return normalizeTarget(options.target);
|
|
@@ -133,9 +150,28 @@ function normalizeTarget(parsed) {
|
|
|
133
150
|
&& !parsed.sshTarget.includes("REPLACE-WITH-");
|
|
134
151
|
const hasLocal = parsed.local === true;
|
|
135
152
|
if (hasSshTarget === hasLocal) return null;
|
|
153
|
+
// Optional elastic allocation: any invalid value rejects the whole config
|
|
154
|
+
// (fail-closed) rather than silently falling back.
|
|
155
|
+
if (parsed.vcpu !== undefined && !validVcpu(parsed.vcpu)) return null;
|
|
156
|
+
if (parsed.memoryMiB !== undefined && !validMemory(parsed.memoryMiB)) return null;
|
|
157
|
+
// Containment job payload (design sections 1.3, 3; M6 wiring): a
|
|
158
|
+
// user-configured job command/script. Present = containment mode; absent =
|
|
159
|
+
// plain proof mode (backward compatible). Placeholder or invalid shape
|
|
160
|
+
// rejects the whole config fail-closed. Never model-set.
|
|
161
|
+
if (parsed.jobPayload !== undefined) {
|
|
162
|
+
if (typeof parsed.jobPayload !== "string" || !parsed.jobPayload.trim()
|
|
163
|
+
|| parsed.jobPayload.includes("REPLACE-WITH-") || parsed.jobPayload.length > 8192) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const extras = {
|
|
168
|
+
...(parsed.vcpu !== undefined ? { vcpu: parsed.vcpu } : {}),
|
|
169
|
+
...(parsed.memoryMiB !== undefined ? { memoryMiB: parsed.memoryMiB } : {}),
|
|
170
|
+
...(parsed.jobPayload !== undefined ? { jobPayload: parsed.jobPayload } : {}),
|
|
171
|
+
};
|
|
136
172
|
return Object.freeze(hasSshTarget
|
|
137
|
-
? { mode: "ssh", sshTarget: parsed.sshTarget.trim() }
|
|
138
|
-
: { mode: "local" });
|
|
173
|
+
? { mode: "ssh", sshTarget: parsed.sshTarget.trim(), ...extras }
|
|
174
|
+
: { mode: "local", ...extras });
|
|
139
175
|
}
|
|
140
176
|
|
|
141
177
|
// Shape validation for the user-relayed target parameter (untrusted input).
|
|
@@ -263,7 +299,7 @@ function requireHash(value, label) {
|
|
|
263
299
|
function requireBoolean(value, label) {
|
|
264
300
|
if (typeof value !== "boolean") throw phaseError("evidence", "receipt-invalid", `${label} is not boolean evidence`);
|
|
265
301
|
}
|
|
266
|
-
function parseReceipt(stdout) {
|
|
302
|
+
export function parseReceipt(stdout) {
|
|
267
303
|
const candidates = [];
|
|
268
304
|
const unexpected = [];
|
|
269
305
|
for (const line of String(stdout || "").split(/\r?\n/)) {
|
|
@@ -277,7 +313,7 @@ function parseReceipt(stdout) {
|
|
|
277
313
|
}
|
|
278
314
|
try {
|
|
279
315
|
const value = JSON.parse(payload);
|
|
280
|
-
if (value?.schema
|
|
316
|
+
if (value?.schema && RECEIPT_SCHEMAS.has(value.schema)) candidates.push(value);
|
|
281
317
|
else unexpected.push(trimmed);
|
|
282
318
|
} catch {
|
|
283
319
|
unexpected.push(trimmed);
|
|
@@ -289,16 +325,84 @@ function parseReceipt(stdout) {
|
|
|
289
325
|
throw phaseError("evidence", "receipt-missing", detail);
|
|
290
326
|
}
|
|
291
327
|
if (candidates.length !== 1) throw phaseError("evidence", "receipt-ambiguous", "multiple structured microVM receipts were returned");
|
|
292
|
-
if (unexpected.length)
|
|
328
|
+
if (unexpected.length) {
|
|
329
|
+
// Relax receipt-extra-output ONLY for the containment envelope marker
|
|
330
|
+
// lines, and only when a v2 (containment) receipt is in force. Any other
|
|
331
|
+
// unbound output still fails closed.
|
|
332
|
+
const envelopeLines = unexpected.filter((line) => CONTAINMENT_MARKER_LINE.test(line));
|
|
333
|
+
if (!(envelopeLines.length === unexpected.length
|
|
334
|
+
&& candidates[0]?.schema === LINUX_MICROVM_CUTOVER_SCHEMA_V2)) {
|
|
335
|
+
throw phaseError("evidence", "receipt-extra-output", `unbound fixture output: ${unexpected.join(" ")}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
293
338
|
return candidates[0];
|
|
294
339
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
340
|
+
function requireCount(value, label) {
|
|
341
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
342
|
+
throw phaseError("evidence", "receipt-invalid", `${label} is not a non-negative integer count`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// Closed containment sub-object (design section 5). A verified killswitch
|
|
346
|
+
// trip with a verified log digest is still the success state for the
|
|
347
|
+
// containment proof: the proof is that the killswitch worked.
|
|
348
|
+
function validateContainmentBlock(receipt) {
|
|
349
|
+
const block = receipt.containment;
|
|
350
|
+
// Durable kill report (design section 5): the report path is present only
|
|
351
|
+
// on a tripped killswitch; a clean session must not carry one.
|
|
352
|
+
const blockKeys = ["schema", "taxonomySha256", "logSha256", "events", "denials", "probes", "concealmentIndex", "histogram", "killswitch"];
|
|
353
|
+
const trippedEarly = block?.killswitch?.tripped === true;
|
|
354
|
+
if (trippedEarly) blockKeys.push("killReportPath");
|
|
355
|
+
exactKeys(block, blockKeys, "containment block");
|
|
356
|
+
if (block.schema !== GUEST_CONTAINMENT_LOG_SCHEMA) {
|
|
357
|
+
throw phaseError("evidence", "receipt-invalid", "containment log schema is unexpected");
|
|
358
|
+
}
|
|
359
|
+
requireHash(block.taxonomySha256, "containment taxonomy digest");
|
|
360
|
+
requireHash(block.logSha256, "containment log digest");
|
|
361
|
+
requireCount(block.events, "containment event count");
|
|
362
|
+
requireCount(block.denials, "containment denial count");
|
|
363
|
+
requireCount(block.probes, "containment probe count");
|
|
364
|
+
requireCount(block.concealmentIndex, "containment concealment index");
|
|
365
|
+
// Compact class histogram (design section 4): class -> non-negative count.
|
|
366
|
+
if (!block.histogram || typeof block.histogram !== "object" || Array.isArray(block.histogram)
|
|
367
|
+
|| Object.values(block.histogram).some((count) => !Number.isInteger(count) || count < 0)) {
|
|
368
|
+
throw phaseError("evidence", "receipt-invalid", "containment histogram is not a class-count map");
|
|
369
|
+
}
|
|
370
|
+
exactKeys(block.killswitch, ["tripped", "rule", "class", "tier", "guestPoweroff", "final"], "containment killswitch");
|
|
371
|
+
requireBoolean(block.killswitch.tripped, "killswitch tripped");
|
|
372
|
+
if (block.killswitch.guestPoweroff !== true || block.killswitch.final !== true) {
|
|
373
|
+
throw phaseError("evidence", "receipt-invalid", "killswitch guest poweroff or final flag is unexpected");
|
|
374
|
+
}
|
|
375
|
+
if (block.killswitch.tripped) {
|
|
376
|
+
if (typeof block.killswitch.rule !== "string" || !block.killswitch.rule
|
|
377
|
+
|| typeof block.killswitch.class !== "string" || !block.killswitch.class
|
|
378
|
+
|| typeof block.killswitch.tier !== "string" || !block.killswitch.tier) {
|
|
379
|
+
throw phaseError("evidence", "receipt-invalid", "killswitch tripped without rule, class, or tier");
|
|
380
|
+
}
|
|
381
|
+
if (typeof block.killReportPath !== "string" || !block.killReportPath.endsWith("kill-report.json")) {
|
|
382
|
+
throw phaseError("evidence", "receipt-invalid", "killswitch trip without a kill report path");
|
|
383
|
+
}
|
|
384
|
+
} else if (block.killswitch.rule !== null || block.killswitch.class !== null || block.killswitch.tier !== null) {
|
|
385
|
+
throw phaseError("evidence", "receipt-invalid", "killswitch rule, class, and tier must be null when not tripped");
|
|
386
|
+
}
|
|
387
|
+
return block;
|
|
388
|
+
}
|
|
389
|
+
export function validateLinuxMicroVMReceipt(receipt, facts, fixtureId, scriptHash, options = {}) {
|
|
390
|
+
const containmentRun = options.containment === true;
|
|
391
|
+
// Fail-closed: a containment run without the v2 receipt and its evidence is
|
|
392
|
+
// containment-evidence-missing, never silently downgraded.
|
|
393
|
+
if (containmentRun && receipt.schema !== LINUX_MICROVM_CUTOVER_SCHEMA_V2) {
|
|
394
|
+
throw phaseError("evidence", "containment-evidence-missing",
|
|
395
|
+
"containment run returned no v2 containment receipt");
|
|
396
|
+
}
|
|
397
|
+
const expectedKeys = ["schema", "ok", "status", "authorityCreated", "runtimeActivated", "persisted",
|
|
398
|
+
"identity", "marker", "scriptHash", "initramfsSha256", "teardown", "context"];
|
|
399
|
+
if (containmentRun || receipt.schema === LINUX_MICROVM_CUTOVER_SCHEMA_V2) expectedKeys.push("containment");
|
|
400
|
+
exactKeys(receipt, expectedKeys, "receipt");
|
|
401
|
+
if (!RECEIPT_SCHEMAS.has(receipt.schema) || receipt.ok !== true || receipt.status !== "VERIFIED"
|
|
299
402
|
|| receipt.authorityCreated !== false || receipt.runtimeActivated !== false || receipt.persisted !== false) {
|
|
300
403
|
throw phaseError("evidence", "receipt-invalid", "receipt status or non-authorizing flags are unexpected");
|
|
301
404
|
}
|
|
405
|
+
if (receipt.schema === LINUX_MICROVM_CUTOVER_SCHEMA_V2) validateContainmentBlock(receipt);
|
|
302
406
|
const domain = fixtureDomainForId(fixtureId);
|
|
303
407
|
exactKeys(receipt.identity, ["remoteHost", "fixtureId", "domain"], "receipt identity");
|
|
304
408
|
if (receipt.identity.remoteHost !== facts.host || receipt.identity.fixtureId !== fixtureId || receipt.identity.domain !== domain) {
|
|
@@ -350,6 +454,16 @@ export function validateLinuxMicroVMReceipt(receipt, facts, fixtureId, scriptHas
|
|
|
350
454
|
}
|
|
351
455
|
return receipt;
|
|
352
456
|
}
|
|
457
|
+
// Elastic resource allocation (design section 6.1): user-configured through
|
|
458
|
+
// the target config only (never model-set); the model-visible tool surface
|
|
459
|
+
// stays closed. Defaults are the existing proof values.
|
|
460
|
+
function resourceAllocation(target) {
|
|
461
|
+
return { vcpu: target?.vcpu ?? 1, memoryMiB: target?.memoryMiB ?? 128 };
|
|
462
|
+
}
|
|
463
|
+
function resourceLine(target) {
|
|
464
|
+
const allocation = resourceAllocation(target);
|
|
465
|
+
return `${allocation.vcpu} vCPU, ${allocation.memoryMiB} MiB, BusyBox initramfs, no disk, network, host share, credentials, GPU, or serving access.`;
|
|
466
|
+
}
|
|
353
467
|
function normalizedForwardedStderr(result, fallbackPhase, fallbackCode, fallbackDetail) {
|
|
354
468
|
const text = String(result?.stderr || "");
|
|
355
469
|
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
@@ -365,6 +479,15 @@ function normalizedForwardedStderr(result, fallbackPhase, fallbackCode, fallback
|
|
|
365
479
|
primary?.[2] || (cleanup.length ? "cleanup-failed" : fallbackCode), details.join("; "));
|
|
366
480
|
}
|
|
367
481
|
|
|
482
|
+
// Containment run mode (design sections 1.3, 5, 6; M6 wired): the
|
|
483
|
+
// user-configured `jobPayload` in the target config is the only source of
|
|
484
|
+
// containment mode — present payload = containment run (v2 receipt with the
|
|
485
|
+
// containment block), absent = plain proof mode (v1, backward compatible).
|
|
486
|
+
// The model cannot set or alter it: the tool schema stays closed (target
|
|
487
|
+
// relay only) and the payload never crosses the model-visible surface.
|
|
488
|
+
function payloadRedacted(payload) {
|
|
489
|
+
return boundedText(String(payload).replace(/\s+/g, " ").trim(), "payload").slice(0, 160);
|
|
490
|
+
}
|
|
368
491
|
export async function runLinuxMicroVMCutover(context, options = {}) {
|
|
369
492
|
// Session-scoped user switch: only the explicit enable command can set this
|
|
370
493
|
// flag in memory; it never persists to settings and the model cannot set it.
|
|
@@ -399,9 +522,28 @@ export async function runLinuxMicroVMCutover(context, options = {}) {
|
|
|
399
522
|
if (confirmed !== true) {
|
|
400
523
|
return denied("stopped", reason("confirmation", "not-granted", "No target was saved; native confirmation was not granted."));
|
|
401
524
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
525
|
+
// Read-modify-write: the confirmation authorizes changing WHERE the
|
|
526
|
+
// microVM runs — nothing else. A fresh narrow object here would silently
|
|
527
|
+
// drop the user's other configured fields (jobPayload above all: a
|
|
528
|
+
// re-save stripped it and flipped the next run to plain proof mode).
|
|
529
|
+
// Preserve every schema field the existing config carries; the decision
|
|
530
|
+
// under confirmation replaces exactly one of sshTarget/local. Preserved
|
|
531
|
+
// fields are still fully validated fail-closed by loadMicroVMTarget.
|
|
532
|
+
let existing = {};
|
|
533
|
+
try {
|
|
534
|
+
const parsed = JSON.parse(readFileSync(writePath, "utf8"));
|
|
535
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) existing = parsed;
|
|
536
|
+
} catch {
|
|
537
|
+
// Absent or unreadable config: fresh write below.
|
|
538
|
+
}
|
|
539
|
+
const saved = { ...existing, schema: TARGET_SCHEMA };
|
|
540
|
+
if (targetParam === "local") {
|
|
541
|
+
delete saved.sshTarget;
|
|
542
|
+
saved.local = true;
|
|
543
|
+
} else {
|
|
544
|
+
delete saved.local;
|
|
545
|
+
saved.sshTarget = targetParam;
|
|
546
|
+
}
|
|
405
547
|
try {
|
|
406
548
|
mkdirSync(dirname(writePath), { recursive: true });
|
|
407
549
|
writeFileSync(writePath, `${JSON.stringify(saved, null, 2)}\n`);
|
|
@@ -423,6 +565,7 @@ export async function runLinuxMicroVMCutover(context, options = {}) {
|
|
|
423
565
|
return denied("blocked", reason("policy", "native-tui-required",
|
|
424
566
|
"Open the Linux microVM cutover in the interactive Pi TUI; headless runs are denied."));
|
|
425
567
|
}
|
|
568
|
+
const containmentRun = typeof target.jobPayload === "string";
|
|
426
569
|
if (inFlight) return denied("denied", reason("execution", "already-active", "another Linux microVM cutover is active in this host session"));
|
|
427
570
|
|
|
428
571
|
const fixtureId = `microvm-${randomBytes(12).toString("hex")}`;
|
|
@@ -447,7 +590,13 @@ export async function runLinuxMicroVMCutover(context, options = {}) {
|
|
|
447
590
|
`Fixture: ${fixtureId}; domain: ${fixtureDomain} (preflight absent)`,
|
|
448
591
|
`Versioned fixture SHA-256: ${scriptHash}`,
|
|
449
592
|
"Writes: one generated fixture below ~/agentic-driver-state/cutover-fixtures/microvm/.",
|
|
450
|
-
|
|
593
|
+
...(containmentRun
|
|
594
|
+
? [
|
|
595
|
+
"Guest runs a deny-by-default containment monitor; any kill decision kills the guest session and tears down the VM.",
|
|
596
|
+
`Guest job payload (user-configured): ${payloadRedacted(target.jobPayload)}`,
|
|
597
|
+
]
|
|
598
|
+
: []),
|
|
599
|
+
`Guest: ${resourceLine(target)}`,
|
|
451
600
|
"A temporary traverse-only ACL for libvirt-qemu is added to the remote home directory and the exact prior ACL is restored after exit or failure.",
|
|
452
601
|
"The transient domain prints one marker, powers off, and must disappear from libvirt.",
|
|
453
602
|
"No install, download, repository mutation, runtime authority, staging, commit, or push.",
|
|
@@ -465,14 +614,23 @@ export async function runLinuxMicroVMCutover(context, options = {}) {
|
|
|
465
614
|
}
|
|
466
615
|
inFlight = true;
|
|
467
616
|
try {
|
|
617
|
+
const allocation = resourceAllocation(target);
|
|
618
|
+
const fixtureArgs = [fixtureId, scriptHash, String(allocation.vcpu), String(allocation.memoryMiB)];
|
|
619
|
+
// The payload travels base64-encoded: ssh concatenates argv into one
|
|
620
|
+
// command string parsed by the remote login shell, so a raw payload
|
|
621
|
+
// (newlines, quotes, semicolons are allowed by design) would be word-
|
|
622
|
+
// split, reinterpreted, or injected as remote commands. Base64 is
|
|
623
|
+
// shell-safe (no whitespace/metacharacters) and round-trips exactly;
|
|
624
|
+
// the fixture decodes and validates it. Local mode benefits identically.
|
|
625
|
+
if (containmentRun) fixtureArgs.push(Buffer.from(target.jobPayload, "utf8").toString("base64"));
|
|
468
626
|
const result = target.mode === "local"
|
|
469
|
-
? execute("bash", ["-c", "bash -s -- " + shellQuote(
|
|
470
|
-
: execute("ssh", [target.sshTarget, "bash", "-s", "--",
|
|
627
|
+
? execute("bash", ["-c", "bash -s -- " + fixtureArgs.map(shellQuote).join(" ")], { input: script, timeout: 180000 })
|
|
628
|
+
: execute("ssh", [target.sshTarget, "bash", "-s", "--", ...fixtureArgs], { input: script, timeout: 180000 });
|
|
471
629
|
if (!result || result.code !== 0) {
|
|
472
630
|
return denied("blocked", normalizedForwardedStderr(result, "fixture", "execution-failed", "fixed microVM fixture failed"));
|
|
473
631
|
}
|
|
474
632
|
const receipt = parseReceipt(result.stdout);
|
|
475
|
-
return validateLinuxMicroVMReceipt(receipt, facts, fixtureId, scriptHash);
|
|
633
|
+
return validateLinuxMicroVMReceipt(receipt, facts, fixtureId, scriptHash, { containment: containmentRun });
|
|
476
634
|
} catch (error) {
|
|
477
635
|
return denied("blocked", reasonFromError(error, "execution", "fixture-failed"));
|
|
478
636
|
} finally { inFlight = false; }
|
|
@@ -516,6 +674,15 @@ export function registerLinuxMicroVMCutoverInterface(pi, options = {}) {
|
|
|
516
674
|
...options,
|
|
517
675
|
...(typeof params?.target === "string" ? { target: params.target } : {}),
|
|
518
676
|
});
|
|
677
|
+
// M2 (design section 5): a killswitch trip raises an error-severity
|
|
678
|
+
// notification naming the rule, class, and severity tier.
|
|
679
|
+
const killswitch = value?.containment?.killswitch;
|
|
680
|
+
if (value?.ok === true && killswitch?.tripped === true && typeof context?.ui?.notify === "function") {
|
|
681
|
+
context.ui.notify(
|
|
682
|
+
`MICROVM CONTAINMENT: KILLSWITCH TRIPPED — rule ${killswitch.rule}, class ${killswitch.class}, tier ${killswitch.tier}; guest session killed and VM torn down. Kill report: ${value?.containment?.killReportPath ?? "(unavailable)"}`,
|
|
683
|
+
"error",
|
|
684
|
+
);
|
|
685
|
+
}
|
|
519
686
|
notifyOutcome(context, value);
|
|
520
687
|
return { content: [{ type: "text", text: `${outcomeLine(value)}\n${JSON.stringify(value, null, 2)}` }], details: value };
|
|
521
688
|
};
|