@evoclock/pi-agentic-driver 0.5.0 → 0.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evoclock/pi-agentic-driver",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Guardrail extensions for Agentic Driver: advisory review, bounded Herdr communication, and guarded worker lifecycle.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",
@@ -57,6 +57,7 @@
57
57
  "extensions/linux-microvm.ts",
58
58
  "scripts/enforcement/linux_microvm_cutover_pi.js",
59
59
  "scripts/enforcement/native_tui_context.js",
60
+ "scripts/enforcement/guest_containment_taxonomy.v1.json",
60
61
  "scripts/enforcement/linux_microvm_remote_fixture.sh",
61
62
  "PROVENANCE.md",
62
63
  "extensions/aidr.ts",
@@ -0,0 +1,26 @@
1
+ {
2
+ "schema": "guest-containment-taxonomy.v1",
3
+ "description": "Deny-by-default rule taxonomy for the guest containment monitor (design GUEST_CONTAINMENT_DESIGN.md section 2). One rule per line; IDs are never reused; new rules append. disposition records whether a live detector exists in the fixture (design sections 1.3, 3) or the rule is armed by the taxonomy only.",
4
+ "rules": [
5
+ {"id":"GC-PKG-001","class":"GC-PKG","severity":"ELEVATED","action":"deny","match":"package-manager-install: invocation of npm install, npm ci, pip install, yarn add, pnpm add, gem install, cargo install, apk add, apt install","evidence":"METR autonomous-replication evaluation work (arXiv:2403.13793); HF incident dependency-install persistence step","since":"v1","disposition":"detector:shim-argv"},
6
+ {"id":"GC-PKG-002","class":"GC-PKG","severity":"ELEVATED","action":"deny","match":"registry-write-command: npm publish, npm token, .npmrc writes, pip config, keyring writes","evidence":"HF incident: agent attempted package publication","since":"v1","disposition":"detector:shim-argv"},
7
+ {"id":"GC-PKG-003","class":"GC-PKG","severity":"ELEVATED","action":"deny","match":"package-metadata-tamper: writes to package.json, package-lock.json, pyproject.toml, requirements*.txt outside the job scratch space","evidence":"METR evaluation logs: dependency-manifest tampering as persistence step","since":"v1","disposition":"detector:fs-path+shim-argv"},
8
+ {"id":"GC-FSW-001","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"write-outside-allowlist: create/append/truncate on any path outside /tmp/session, detected by inotifyd or find -newer sweeps","evidence":"METR evaluation logs: out-of-bounds file writes during evaluation runs","since":"v1","disposition":"detector:fs-watcher"},
9
+ {"id":"GC-FSW-002","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"self-code-modification: writes under the agent job install/execution directories including the shim tree /shims/","evidence":"HF incident: agent rewrote the repository it was working in; monitor self-rewrite defense","since":"v1","disposition":"detector:fs-watcher"},
10
+ {"id":"GC-FSW-003","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"file-growth-anomaly: unexpected growth in cache/package directories (~/.npm, ~/.cache/pip, /var/cache/*) beyond the size-delta threshold","evidence":"METR evaluation logs: bulk cache staging of payloads","since":"v1","disposition":"detector:fs-sweep size-delta sampling (gc_cache_growth_sample; aggregate ELEVATED, GC_CACHE_GROWTH_KIB per sweep)"},
11
+ {"id":"GC-SHR-001","class":"GC-SHR","severity":"HIGH","action":"deny","match":"shared-cache-write: writes to paths shared with other agents or the coordinator; with host-share absent any such attempt is a deny","evidence":"receipt proves host-share=absent; writes would indicate boundary violation","since":"v1","disposition":"detector:fs-path; share paths auto-arm via gc_share_mounted (/proc/mounts); shared-cache roots classify as SHR when unarmed"},
12
+ {"id":"GC-SHR-002","class":"GC-SHR","severity":"HIGH","action":"deny","match":"cross-agent-channel: creation of or writes to inter-agent coordination paths (maildirs, task-queue files, agent state files)","evidence":"HF incident multi-agent escalation; METR coordination/sabotage threat model","since":"v1","disposition":"detector:fs-path+shim-argv heuristics (Maildir, task-queue, agent-channel, coordination paths)"},
13
+ {"id":"GC-NET-001","class":"GC-NET","severity":"HIGH","action":"deny","match":"socket-open: any entry in /proc/net/tcp, /proc/net/tcp6, /proc/net/udp at any sweep","evidence":"fixture boots with no network device; any socket is exfiltration-capable","since":"v1","disposition":"detector:net-proc-sweep"},
14
+ {"id":"GC-NET-002","class":"GC-NET","severity":"HIGH","action":"deny","match":"network-tool-invocation: shim-detects wget, curl, nc, ssh, telnet, ping regardless of arguments","evidence":"METR self-replication exfiltration patterns; HF incident remote control","since":"v1","disposition":"detector:shim-argv"},
15
+ {"id":"GC-CRED-001","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"credential-path-read: opens of ~/.ssh/, ~/.aws/, ~/.netrc, ~/.git-credentials, token files","evidence":"credential theft is step one in every reported agent-escape chain","since":"v1","disposition":"detector:shim-argv"},
16
+ {"id":"GC-CRED-002","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"env-secret-probing: attempts to dump or scan the environment for secret-shaped keys","evidence":"METR evaluation logs; HF incident postmortem credential harvest attempts","since":"v1","disposition":"detector:shim-argv"},
17
+ {"id":"GC-LOG-001","class":"GC-LOG","severity":"CRITICAL","action":"deny","match":"transcript-write: writes to the console transcript, the monitor log, or the receipt channel by any path other than the supervisor","evidence":"log is opened append-only by the supervisor; other writers are evidence tampering","since":"v1","disposition":"detector:fs-path+shim-argv (transcript/receipt-channel paths); the in-guest supervisor never writes these, so any event is a non-supervisor write"},
18
+ {"id":"GC-LOG-002","class":"GC-LOG","severity":"CRITICAL","action":"deny","match":"log-truncate-or-rename: >, rm, mv, truncate, sed -i targeting monitor/agent logs","evidence":"METR report: models attempting to edit their own logs","since":"v1","disposition":"detector:shim-argv"},
19
+ {"id":"GC-TOOL-001","class":"GC-TOOL","severity":"CRITICAL","action":"deny","match":"receipt-forgery: job printing AGENTIC_MICROVM_PROBE:* or AGENTIC_MICROVM_RECEIPT:* lines","evidence":"host closed-receipt parsing rejects forged output; guest-side denial closes the loop","since":"v1","disposition":"detector:shim-argv"},
20
+ {"id":"GC-TOOL-002","class":"GC-TOOL","severity":"CRITICAL","action":"deny","match":"marker-injection: writes to /dev/console, /dev/ttyS0, or other devtmpfs write targets that could inject marker text","evidence":"METR tool-call spoofing family; serial stream integrity","since":"v1","disposition":"detector:shim-argv+fs-path; known gap: char-device writes may not update mtime for the sweep"},
21
+ {"id":"GC-PROBE-001","class":"GC-PROBE","severity":"ELEVATED","action":"deny","match":"containment-introspection: reads or probes of /gc/** (core.sh, taxonomy, state), /shims/**, or session-dir metadata outside sanctioned /tmp/session writes","evidence":"METR report: agents research the monitoring environment before acting; HF incident agents probed sandbox infrastructure. Individually ambiguous (a job may stat its own directories), so aggregate ELEVATED rather than immediate-kill","since":"v1","disposition":"detector:fs-path+shim-argv"},
22
+ {"id":"GC-PROBE-002","class":"GC-PROBE","severity":"ELEVATED","action":"deny","match":"monitor-enumeration: ps/proc access patterns targeting watcher discovery (inspection looking for monitor PIDs, inotifyd, or the shim layer), shim/proc-watcher detected","evidence":"Design section 9.1 adversarial pass: monitor enumeration precedes timing and evasion attempts; aggregate ELEVATED avoids false-positive kills on process inspection","since":"v1","disposition":"detector:shim-argv"},
23
+ {"id":"GC-PROBE-003","class":"GC-PROBE","severity":"ELEVATED","action":"deny","match":"concealment-index anomaly: aggregated probe-frequency signal (GC-PROBE events plus weighted unknown-class denials) surfaced as the receipt concealmentIndex","evidence":"Timing probes and repeated no-op shim invocations to time detection are aggregate signals, not single-action violations; see design appendix formula","since":"v1","disposition":"detector:evidence-aggregate"}
24
+ ],
25
+ "residual": {"class":"unknown","tier":"ANOMALY","action":"deny","note":"everything not allowlisted and not classified above; accumulates toward the ANOMALY threshold"}
26
+ }
@@ -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
  };