@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.
@@ -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
- // `recent-unwrapped` can retain one older unmatched opening before the
1057
- // newer pair. Ignore that prefix only when it is preceded by terminal
1058
- // history; an opening at the window boundary remains fail-closed so a
1059
- // nested/duplicate opening cannot be reclassified as stale history.
1060
- const prefixOpenCount = relevant.slice(0, -2).filter((item) => item.marker === marker.open).length;
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
- const prompted = await invokeHerdr(operation, request, context, options, signal);
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 report = extractLatestHerdrReport(readText(rawReport), role);
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 === LINUX_MICROVM_CUTOVER_SCHEMA) candidates.push(value);
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) throw phaseError("evidence", "receipt-extra-output", `unbound fixture output: ${unexpected.join(" ")}`);
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
- export function validateLinuxMicroVMReceipt(receipt, facts, fixtureId, scriptHash) {
296
- exactKeys(receipt, ["schema", "ok", "status", "authorityCreated", "runtimeActivated", "persisted",
297
- "identity", "marker", "scriptHash", "initramfsSha256", "teardown", "context"], "receipt");
298
- if (receipt.schema !== LINUX_MICROVM_CUTOVER_SCHEMA || receipt.ok !== true || receipt.status !== "VERIFIED"
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
- const saved = targetParam === "local"
403
- ? { schema: TARGET_SCHEMA, local: true }
404
- : { schema: TARGET_SCHEMA, sshTarget: targetParam };
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
- "Guest: 1 vCPU, 128 MiB, BusyBox initramfs, no disk, network, host share, credentials, GPU, or serving access.",
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(fixtureId) + " " + shellQuote(scriptHash)], { input: script, timeout: 180000 })
470
- : execute("ssh", [target.sshTarget, "bash", "-s", "--", fixtureId, scriptHash], { input: script, timeout: 180000 });
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
  };