@evoclock/pi-agentic-driver 0.4.3 → 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.
@@ -2,20 +2,57 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
4
  import { createHash, randomBytes } from "node:crypto";
5
- import { readFileSync } from "node:fs";
5
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { spawnSync } from "node:child_process";
9
+ import { homedir } from "node:os";
9
10
  import { isNativeTuiContext } from "./native_tui_context.js";
10
11
 
11
12
  export const LINUX_MICROVM_CUTOVER_TOOL = "agentic_linux_microvm_cutover";
12
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";
13
25
  const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
14
26
  const REMOTE_FIXTURE = join(SCRIPT_DIR, "linux_microvm_remote_fixture.sh");
27
+ const TARGET_EXAMPLE = join(SCRIPT_DIR, "..", "..", "config", "microvm-target.v1.example.json");
28
+ const TARGET_PACKAGE_CONFIG = join(SCRIPT_DIR, "..", "..", "config", "microvm-target.v1.json");
29
+ // The saved target config lives in the Pi coding-agent config directory
30
+ // (PI_CODING_AGENT_DIR when set, otherwise ~/.pi/agent), matching how Pi
31
+ // resolves its own config.
32
+ function resolveTargetUserConfigPath(env = process.env) {
33
+ const agentDir = env.PI_CODING_AGENT_DIR?.trim()
34
+ ? join(env.PI_CODING_AGENT_DIR.trim(), "config")
35
+ : join(homedir(), ".pi", "agent", "config");
36
+ return join(agentDir, "microvm-target.v1.json");
37
+ }
38
+ const TARGET_USER_CONFIG = resolveTargetUserConfigPath();
39
+ const TARGET_SCHEMA = "agentic-driver.microvm-target.v1";
15
40
  const REGISTRATIONS = new WeakSet();
16
- const PLANNED_ISOLATION_MODES = new Set(["planned", "planned-interactive", "planned-autonomous"]);
41
+ const SWITCH_REGISTRATIONS = new WeakSet();
17
42
  const HASH = /^[0-9a-f]{64}$/;
18
43
  const MAX_DETAIL = 512;
44
+ // Isolation switch state is per-registration: each registerIsolationSwitchCommands
45
+ // call creates a fresh flag in the registration closure, so a new extension
46
+ // registration (session) starts disabled. It is held in memory only, never
47
+ // read from or written to settings, and never settable by the model (no tool
48
+ // exposes it; only the native TUI enable/disable commands mutate it).
49
+ export function createIsolationSwitch() {
50
+ let enabled = false;
51
+ return {
52
+ get() { return enabled === true; },
53
+ set(value) { enabled = value === true; },
54
+ };
55
+ }
19
56
  let inFlight = false;
20
57
 
21
58
  function boundedText(value, fallback = "unknown failure") {
@@ -61,7 +98,109 @@ function fixtureDomainForId(fixtureId) {
61
98
  }
62
99
  return `agentic-driver-${fixtureId}`;
63
100
  }
64
- function parseFacts(stdout, fixtureId) {
101
+ // --- User-configured trusted target (deny-by-default) -----------------------
102
+ // The user makes one decision: where the microVM runs. Either
103
+ // { "sshTarget": "user@host-or-ip" } for a remote machine, or { "local": true }
104
+ // when this session runs directly on a Linux machine. Nothing else is
105
+ // user-supplied: arch, libvirt URI, and kernel are auto-discovered from the
106
+ // target at probe time (informational, not configured). The model cannot
107
+ // choose or change the target. Read order: the user's own config
108
+ // (~/.pi/pi/config/microvm-target.v1.json) first, then the package-local
109
+ // config/microvm-target.v1.json (shipped as a REPLACE-WITH template).
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; }
117
+ export function loadMicroVMTarget(options = {}) {
118
+ if (options.target && typeof options.target === "object") {
119
+ return normalizeTarget(options.target);
120
+ }
121
+ const paths = [
122
+ ...(typeof options.targetPath === "string" ? [options.targetPath] : []),
123
+ ...(typeof options.targetUserConfigPath === "string" ? [options.targetUserConfigPath] : []),
124
+ // A test/override seam can replace the default user-config location so
125
+ // tests stay hermetic regardless of the developer's own machine.
126
+ ...(typeof options.userConfigPath === "string" ? [options.userConfigPath] : [TARGET_USER_CONFIG]),
127
+ TARGET_PACKAGE_CONFIG,
128
+ ];
129
+ for (const path of paths) {
130
+ try {
131
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
132
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
133
+ if (Object.keys(parsed).some((key) => !TARGET_FIELDS.has(key))) continue;
134
+ if (parsed.schema !== TARGET_SCHEMA) continue;
135
+ const normalized = normalizeTarget(parsed);
136
+ if (!normalized) continue;
137
+ return Object.freeze({ ...normalized, __path: path });
138
+ } catch {
139
+ // Missing or unreadable candidate: fall through to the next path.
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+
145
+ // Exactly one user decision: sshTarget XOR local:true. Returns the frozen
146
+ // normalized target or null when the decision is absent, ambiguous, or a
147
+ // REPLACE-WITH template placeholder.
148
+ function normalizeTarget(parsed) {
149
+ const hasSshTarget = typeof parsed.sshTarget === "string" && parsed.sshTarget.trim()
150
+ && !parsed.sshTarget.includes("REPLACE-WITH-");
151
+ const hasLocal = parsed.local === true;
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
+ };
172
+ return Object.freeze(hasSshTarget
173
+ ? { mode: "ssh", sshTarget: parsed.sshTarget.trim(), ...extras }
174
+ : { mode: "local", ...extras });
175
+ }
176
+
177
+ // Shape validation for the user-relayed target parameter (untrusted input).
178
+ // Accepts user@host, an ip (optionally :port), a plain hostname, or "local".
179
+ export function targetArgumentError(value) {
180
+ const text = typeof value === "string" ? value.trim() : "";
181
+ if (!text) {
182
+ return { code: "target-argument-required", detail: "Provide the target: user@host, an ip, or local." };
183
+ }
184
+ if (text.includes("REPLACE-WITH-")) {
185
+ return { code: "target-argument-placeholder", detail: "Placeholder values are not valid targets." };
186
+ }
187
+ if (text === "local") return null;
188
+ // Accepts a bare hostname/ssh-config alias, user@host, user@ip, ip[:port].
189
+ // ssh config resolves aliases; the string is passed verbatim in fixed argv.
190
+ const pattern = /^(?:[a-zA-Z0-9._-]+@)?(?:\d{1,3}(?:\.\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?)(?::\d{1,5})?$/;
191
+ const bareToken = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
192
+ if (/\s/.test(text) || (!pattern.test(text) && !bareToken.test(text))) {
193
+ return { code: "target-argument-invalid", detail: `${JSON.stringify(text)} is not a plausible ssh target (alias, user@host, or ip) or the literal 'local'.` };
194
+ }
195
+ return null;
196
+ }
197
+
198
+ function targetNotConfigured() {
199
+ return denied("blocked", reason("policy", "target-not-configured",
200
+ "No trusted microVM target is configured. Copy config/microvm-target.v1.example.json to ~/.pi/pi/config/microvm-target.v1.json and set either sshTarget (user@host or ip of the machine that runs the microVM) or local:true (this session runs on a Linux machine). The model cannot choose the target."));
201
+ }
202
+
203
+ function parseFacts(stdout, fixtureId, target) {
65
204
  const values = {};
66
205
  for (const line of String(stdout || "").split("\n")) {
67
206
  if (!line) continue;
@@ -75,37 +214,76 @@ function parseFacts(stdout, fixtureId) {
75
214
  kernel: values.kernel,
76
215
  libvirt: values.libvirt,
77
216
  qemu: values.qemu,
217
+ qemuBinaryPath: values.qemu_binary_path,
78
218
  fixtureDomain: values.fixture_domain_name,
79
219
  fixtureDomainState: values.fixture_domain_state,
220
+ kvmAccessible: values.kvm_accessible === "yes",
80
221
  };
81
222
  const domain = fixtureDomainForId(fixtureId);
82
- if (facts.host !== "ubuntu-backend" || facts.arch !== "x86_64" || facts.libvirt !== "qemu:///system"
83
- || facts.fixtureDomain !== domain || facts.fixtureDomainState !== "absent"
84
- || typeof facts.kernel !== "string" || !facts.kernel
85
- || typeof facts.qemu !== "string" || !facts.qemu) {
86
- throw phaseError("preflight", "facts-unexpected", "fixed host facts or the exact fixture-domain absence check failed");
223
+ if (facts.fixtureDomain !== domain || facts.fixtureDomainState !== "absent") {
224
+ throw phaseError("preflight", "facts-unexpected", "the exact fixture-domain absence check failed");
225
+ }
226
+ return validateFacts(facts, fixtureId);
227
+ }
228
+ // Honest safety checks over auto-discovered facts: the target must expose
229
+ // KVM, a system-level libvirt connection, and a qemu-system binary matching
230
+ // the discovered architecture. No user-predicted values are involved.
231
+ function validateFacts(facts, _fixtureId) {
232
+ if (!facts || typeof facts !== "object" || Array.isArray(facts)) {
233
+ throw phaseError("preflight", "facts-invalid", "trusted host facts are not an object");
234
+ }
235
+ if (typeof facts.host !== "string" || !facts.host || typeof facts.arch !== "string" || !facts.arch
236
+ || typeof facts.kernel !== "string" || !facts.kernel || typeof facts.qemu !== "string" || !facts.qemu
237
+ || typeof facts.libvirt !== "string" || !facts.libvirt) {
238
+ throw phaseError("preflight", "facts-unexpected", "the target did not report a complete set of discoverable facts");
239
+ }
240
+ if (facts.kvmAccessible !== true) {
241
+ throw phaseError("preflight", "kvm-unavailable", "the target does not expose an accessible /dev/kvm; hardware virtualization is required");
242
+ }
243
+ if (!facts.libvirt.startsWith("qemu:///system")) {
244
+ throw phaseError("preflight", "libvirt-user-level",
245
+ `the target libvirt connection is '${facts.libvirt}', not the system driver (qemu:///system); the microVM proof requires the system-level libvirt driver`);
246
+ }
247
+ // The decisive evidence is WHICH binary resolved, not an arch token inside
248
+ // the version string (Debian builds omit it there).
249
+ if (typeof facts.qemuBinaryPath !== "string" || !facts.qemuBinaryPath.endsWith(`qemu-system-${facts.arch}`)
250
+ || !/^QEMU/.test(facts.qemu)) {
251
+ throw phaseError("preflight", "qemu-binary-missing",
252
+ `the target did not prove a working qemu-system-${facts.arch}: resolved binary '${facts.qemuBinaryPath || "(none)"}', version output '${facts.qemu.slice(0, 80)}'`);
87
253
  }
88
254
  return facts;
89
255
  }
90
- function sshProbe(execute = run, fixtureId) {
256
+ function sshProbe(execute = run, fixtureId, target) {
91
257
  const domain = fixtureDomainForId(fixtureId);
92
258
  const quotedDomain = shellQuote(domain);
259
+ // All technical expectations are auto-discovered from the target itself;
260
+ // validation checks honest safety properties (KVM, system libvirt driver,
261
+ // qemu binary for the discovered arch) with no user-predicted values.
93
262
  const command = [
94
- "set -eu", "test \"$(uname -m)\" = x86_64", "test -r /dev/kvm -a -w /dev/kvm",
95
- "test -x /usr/bin/qemu-system-x86_64", "test -x /usr/bin/busybox",
96
- "test -x /usr/bin/cpio", "test -x /usr/bin/gzip", "test -x /usr/bin/setfacl",
97
- "test -x /usr/bin/getfacl", "test -n \"$(virsh uri)\"",
263
+ "set -eu",
264
+ "test -r /dev/kvm -a -w /dev/kvm",
265
+ "test -x /usr/bin/qemu-system-$(uname -m)",
266
+ "test -x /usr/bin/busybox", "test -x /usr/bin/cpio", "test -x /usr/bin/gzip",
267
+ "test -x /usr/bin/setfacl", "test -x /usr/bin/getfacl", "test -n \"$(virsh uri)\"",
98
268
  "printf 'host=%s\\n' \"$(hostname)\"", "printf 'arch=%s\\n' \"$(uname -m)\"",
99
269
  "printf 'kernel=%s\\n' \"$(uname -r)\"", "printf 'libvirt=%s\\n' \"$(virsh uri)\"",
100
- "printf 'qemu=%s\\n' \"$(qemu-system-x86_64 --version | head -1)\"",
270
+ `printf 'qemu_binary_path=%s\\n' "$(command -v qemu-system-$(uname -m))"`,
271
+ `printf 'qemu=%s\\n' "$(qemu-system-$(uname -m) --version | head -1)"`,
272
+ "printf 'kvm_accessible=%s\\n' \"$( test -r /dev/kvm -a -w /dev/kvm && echo yes || echo no )\"",
101
273
  `printf 'fixture_domain_name=%s\\n' ${quotedDomain}`,
102
274
  `if virsh dominfo ${quotedDomain} >/dev/null 2>&1; then printf 'fixture_domain_state=present\\n'; else names=$(virsh list --all --name); if printf '%s\\n' \"$names\" | grep -F -x -- ${quotedDomain} >/dev/null; then printf 'fixture_domain_state=present\\n'; else match_status=$?; if [ \"$match_status\" -eq 1 ]; then printf 'fixture_domain_state=absent\\n'; else exit 1; fi; fi; fi`,
103
275
  ].join("; ");
104
- const result = execute("ssh", ["linux-backend", command], { timeout: 30000 });
276
+ const result = target.mode === "local"
277
+ ? execute("bash", ["-c", command], { timeout: 30000 })
278
+ : execute("ssh", [target.sshTarget, command], { timeout: 30000 });
105
279
  if (result.code !== 0) {
106
- throw phaseError("preflight", "probe-failed", result.stderr || result.error || "fixed linux-backend capability probe failed");
280
+ const text = result.stderr || result.error || "";
281
+ if (/\/dev\/kvm/i.test(text)) {
282
+ throw phaseError("preflight", "kvm-unavailable", "the target does not expose an accessible /dev/kvm; hardware virtualization is required");
283
+ }
284
+ throw phaseError("preflight", "probe-failed", text || "configured microVM target capability probe failed");
107
285
  }
108
- return parseFacts(result.stdout, fixtureId);
286
+ return parseFacts(result.stdout, fixtureId, target);
109
287
  }
110
288
  function exactKeys(value, keys, label) {
111
289
  if (!value || typeof value !== "object" || Array.isArray(value)
@@ -121,19 +299,7 @@ function requireHash(value, label) {
121
299
  function requireBoolean(value, label) {
122
300
  if (typeof value !== "boolean") throw phaseError("evidence", "receipt-invalid", `${label} is not boolean evidence`);
123
301
  }
124
- function validateFacts(facts, fixtureId) {
125
- const domain = fixtureDomainForId(fixtureId);
126
- if (!facts || typeof facts !== "object" || Array.isArray(facts)) {
127
- throw phaseError("preflight", "facts-invalid", "trusted host facts are not an object");
128
- }
129
- if (facts.host !== "ubuntu-backend" || facts.arch !== "x86_64" || facts.libvirt !== "qemu:///system"
130
- || facts.fixtureDomain !== domain || facts.fixtureDomainState !== "absent"
131
- || typeof facts.kernel !== "string" || !facts.kernel || typeof facts.qemu !== "string" || !facts.qemu) {
132
- throw phaseError("preflight", "facts-unexpected", "fixed host facts or the exact fixture-domain absence check failed");
133
- }
134
- return facts;
135
- }
136
- function parseReceipt(stdout) {
302
+ export function parseReceipt(stdout) {
137
303
  const candidates = [];
138
304
  const unexpected = [];
139
305
  for (const line of String(stdout || "").split(/\r?\n/)) {
@@ -147,7 +313,7 @@ function parseReceipt(stdout) {
147
313
  }
148
314
  try {
149
315
  const value = JSON.parse(payload);
150
- if (value?.schema === LINUX_MICROVM_CUTOVER_SCHEMA) candidates.push(value);
316
+ if (value?.schema && RECEIPT_SCHEMAS.has(value.schema)) candidates.push(value);
151
317
  else unexpected.push(trimmed);
152
318
  } catch {
153
319
  unexpected.push(trimmed);
@@ -159,16 +325,84 @@ function parseReceipt(stdout) {
159
325
  throw phaseError("evidence", "receipt-missing", detail);
160
326
  }
161
327
  if (candidates.length !== 1) throw phaseError("evidence", "receipt-ambiguous", "multiple structured microVM receipts were returned");
162
- 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
+ }
163
338
  return candidates[0];
164
339
  }
165
- export function validateLinuxMicroVMReceipt(receipt, facts, fixtureId, scriptHash) {
166
- exactKeys(receipt, ["schema", "ok", "status", "authorityCreated", "runtimeActivated", "persisted",
167
- "identity", "marker", "scriptHash", "initramfsSha256", "teardown", "context"], "receipt");
168
- 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"
169
402
  || receipt.authorityCreated !== false || receipt.runtimeActivated !== false || receipt.persisted !== false) {
170
403
  throw phaseError("evidence", "receipt-invalid", "receipt status or non-authorizing flags are unexpected");
171
404
  }
405
+ if (receipt.schema === LINUX_MICROVM_CUTOVER_SCHEMA_V2) validateContainmentBlock(receipt);
172
406
  const domain = fixtureDomainForId(fixtureId);
173
407
  exactKeys(receipt.identity, ["remoteHost", "fixtureId", "domain"], "receipt identity");
174
408
  if (receipt.identity.remoteHost !== facts.host || receipt.identity.fixtureId !== fixtureId || receipt.identity.domain !== domain) {
@@ -220,6 +454,16 @@ export function validateLinuxMicroVMReceipt(receipt, facts, fixtureId, scriptHas
220
454
  }
221
455
  return receipt;
222
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
+ }
223
467
  function normalizedForwardedStderr(result, fallbackPhase, fallbackCode, fallbackDetail) {
224
468
  const text = String(result?.stderr || "");
225
469
  const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
@@ -235,17 +479,93 @@ function normalizedForwardedStderr(result, fallbackPhase, fallbackCode, fallback
235
479
  primary?.[2] || (cleanup.length ? "cleanup-failed" : fallbackCode), details.join("; "));
236
480
  }
237
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
+ }
238
491
  export async function runLinuxMicroVMCutover(context, options = {}) {
239
- if (!PLANNED_ISOLATION_MODES.has(options.workMode)) {
240
- return denied("blocked", reason("policy", "planned-isolation-required", "Linux microVM execution is reserved for planned or automated work."));
492
+ // Session-scoped user switch: only the explicit enable command can set this
493
+ // flag in memory; it never persists to settings and the model cannot set it.
494
+ if (options.isolationSwitch?.get() !== true) {
495
+ return denied("blocked", reason("policy", "isolation-not-enabled",
496
+ "Isolation activation is not enabled in this session. Run the agentic-isolation-enable command in the Pi TUI."));
241
497
  }
242
- const isolationEnabled = options.isolationEnabled ?? options.runtime?.isolationEnabled;
243
- if (isolationEnabled !== true) {
244
- return denied("blocked", reason("policy", "planned-isolation-not-enabled", "The planned isolation execution path is not enabled yet."));
498
+ // Target selection: the only way user intent reaches setup is the optional
499
+ // `target` parameter, relayed by the agent from the user's words. It is
500
+ // untrusted: nothing is written or run without the user's native
501
+ // confirmation dialog naming the target explicitly.
502
+ const targetParam = typeof options.target === "string" ? options.target.trim() : "";
503
+ if (targetParam) {
504
+ const shapeError = targetArgumentError(targetParam);
505
+ if (shapeError) return denied("denied", reason("input", shapeError.code, shapeError.detail));
506
+ // Headless setups are refused: only pre-configured targets run.
507
+ if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
508
+ return denied("blocked", reason("policy", "native-tui-required",
509
+ "Configuring a new microVM target requires the interactive Pi TUI; headless sessions may only use a pre-configured target."));
510
+ }
511
+ const writePath = options.targetUserConfigPath?.trim() || TARGET_USER_CONFIG;
512
+ let confirmed;
513
+ try {
514
+ confirmed = await context.ui.confirm("Use this microVM host?", [
515
+ `Use ${targetParam === "local" ? "THIS machine (local)" : targetParam} as the microVM host? This saves it to your Pi config.`,
516
+ `Config file: ${writePath}`,
517
+ "The model relayed your words; this confirmation is what authorizes the choice.",
518
+ ].join("\n"));
519
+ } catch (error) {
520
+ return denied("blocked", reason("confirmation", "confirmation-failed", `Native confirmation failed: ${error.message}`));
521
+ }
522
+ if (confirmed !== true) {
523
+ return denied("stopped", reason("confirmation", "not-granted", "No target was saved; native confirmation was not granted."));
524
+ }
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
+ }
547
+ try {
548
+ mkdirSync(dirname(writePath), { recursive: true });
549
+ writeFileSync(writePath, `${JSON.stringify(saved, null, 2)}\n`);
550
+ } catch (error) {
551
+ return denied("blocked", reason("policy", "target-write-failed", `Could not write ${writePath}: ${error.message}`));
552
+ }
245
553
  }
246
- if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
247
- return denied("blocked", reason("policy", "native-tui-required", "Open the Linux microVM cutover in the interactive Pi TUI."));
554
+ // Saved config (possibly just written above) drives the run; deny-by-default
555
+ // when neither a target param nor saved config exists.
556
+ const target = loadMicroVMTarget({
557
+ ...options,
558
+ ...(options.userConfigPath ? {} : { userConfigPath: options.userConfigPath }),
559
+ });
560
+ if (!target) {
561
+ return denied("blocked", reason("policy", "target-not-configured",
562
+ "No microVM target is configured. Ask the user which machine the microVM should run on and pass it as the `target` parameter (user@host, ip, or local)."));
248
563
  }
564
+ if (!isNativeTuiContext(context)) {
565
+ return denied("blocked", reason("policy", "native-tui-required",
566
+ "Open the Linux microVM cutover in the interactive Pi TUI; headless runs are denied."));
567
+ }
568
+ const containmentRun = typeof target.jobPayload === "string";
249
569
  if (inFlight) return denied("denied", reason("execution", "already-active", "another Linux microVM cutover is active in this host session"));
250
570
 
251
571
  const fixtureId = `microvm-${randomBytes(12).toString("hex")}`;
@@ -259,18 +579,24 @@ export async function runLinuxMicroVMCutover(context, options = {}) {
259
579
  return denied("denied", reasonFromError(error, "payload", "payload-read-failed"));
260
580
  }
261
581
  const execute = options.execute || run;
262
- const observe = options.observeFacts || ((executeArg, id) => sshProbe(executeArg, id));
582
+ const observe = options.observeFacts || ((executeArg, id) => sshProbe(executeArg, id, target));
263
583
  let facts;
264
584
  try { facts = validateFacts(observe(execute, fixtureId), fixtureId); }
265
585
  catch (error) { return denied("denied", reasonFromError(error, "preflight", "facts-observation-failed")); }
266
586
  const body = [
267
- "Run one live transient QEMU/KVM microVM proof on linux-backend?",
268
- `Remote host: ${facts.host} (${facts.arch}, kernel ${facts.kernel})`,
587
+ "Run one live transient QEMU/KVM microVM proof on the configured trusted target?",
588
+ `Target: ${target.mode === "local" ? "this machine (local)" : target.sshTarget} — discovered host: ${facts.host} (${facts.arch}, kernel ${facts.kernel})`,
269
589
  `Backend: ${facts.libvirt}; ${facts.qemu}`,
270
590
  `Fixture: ${fixtureId}; domain: ${fixtureDomain} (preflight absent)`,
271
591
  `Versioned fixture SHA-256: ${scriptHash}`,
272
592
  "Writes: one generated fixture below ~/agentic-driver-state/cutover-fixtures/microvm/.",
273
- "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)}`,
274
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.",
275
601
  "The transient domain prints one marker, powers off, and must disappear from libvirt.",
276
602
  "No install, download, repository mutation, runtime authority, staging, commit, or push.",
@@ -288,12 +614,23 @@ export async function runLinuxMicroVMCutover(context, options = {}) {
288
614
  }
289
615
  inFlight = true;
290
616
  try {
291
- const result = execute("ssh", ["linux-backend", "bash", "-s", "--", fixtureId, scriptHash], { input: script, timeout: 180000 });
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"));
626
+ const result = target.mode === "local"
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 });
292
629
  if (!result || result.code !== 0) {
293
630
  return denied("blocked", normalizedForwardedStderr(result, "fixture", "execution-failed", "fixed microVM fixture failed"));
294
631
  }
295
632
  const receipt = parseReceipt(result.stdout);
296
- return validateLinuxMicroVMReceipt(receipt, facts, fixtureId, scriptHash);
633
+ return validateLinuxMicroVMReceipt(receipt, facts, fixtureId, scriptHash, { containment: containmentRun });
297
634
  } catch (error) {
298
635
  return denied("blocked", reasonFromError(error, "execution", "fixture-failed"));
299
636
  } finally { inFlight = false; }
@@ -307,22 +644,141 @@ function commandArgumentsPresent(args) {
307
644
  export function registerLinuxMicroVMCutoverInterface(pi, options = {}) {
308
645
  if (typeof pi?.registerTool !== "function" || REGISTRATIONS.has(pi)) return;
309
646
  REGISTRATIONS.add(pi);
647
+ // Prominent outcome presentation. The first line of the tool output is a
648
+ // concise final status (VERIFIED / DENIED / STOPPED / BLOCKED) with fixture
649
+ // id and one-line evidence or reason summary; the full JSON receipt follows
650
+ // unchanged. When the interactive TUI exposes the notify surface, the same
651
+ // status is raised as a fire-and-forget notification.
652
+ const outcomeLine = (value) => {
653
+ if (value?.ok === true && value?.status === "VERIFIED") {
654
+ return `MICROVM CUTOVER: VERIFIED — fixture ${value.identity?.fixtureId ?? "unknown"} on ${value.identity?.remoteHost ?? "unknown host"}; domain ${value.identity?.domain ?? "?"} transient+gone, teardown proofed, isolation context closed.`;
655
+ }
656
+ const status = String(value?.status ?? "DENIED").toUpperCase();
657
+ const code = value?.reason?.code ?? value?.code ?? "unknown";
658
+ const detail = value?.reason?.detail ?? value?.error ?? "";
659
+ return `MICROVM CUTOVER: ${status} — reason ${code}${detail ? `: ${detail}` : ""}`;
660
+ };
661
+ const notifyOutcome = (context, value) => {
662
+ const notify = context?.ui?.notify;
663
+ if (typeof notify !== "function") return;
664
+ const line = outcomeLine(value);
665
+ notify(line, value?.ok === true ? "info" : value?.status === "stopped" ? "warning" : "error");
666
+ };
310
667
  const execute = async (_id, params, _signal, _update, context) => {
311
- const value = params && Object.keys(params).length
312
- ? denied("denied", reason("input", "model-parameters-not-allowed", "Linux microVM cutover accepts no model parameters"))
668
+ const closedParams = params && typeof params === "object" && !Array.isArray(params)
669
+ ? Object.keys(params).filter((key) => key !== "target")
670
+ : [];
671
+ const value = closedParams.length
672
+ ? denied("denied", reason("input", "model-parameters-not-allowed", "Linux microVM cutover accepts only the optional target parameter"))
313
673
  : await runLinuxMicroVMCutover(context, {
314
674
  ...options,
315
- workMode: options.runtime?.workMode,
316
- isolationEnabled: options.runtime?.isolationEnabled,
675
+ ...(typeof params?.target === "string" ? { target: params.target } : {}),
317
676
  });
318
- return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
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
+ }
686
+ notifyOutcome(context, value);
687
+ return { content: [{ type: "text", text: `${outcomeLine(value)}\n${JSON.stringify(value, null, 2)}` }], details: value };
319
688
  };
320
689
  pi.registerTool({ name: LINUX_MICROVM_CUTOVER_TOOL, label: "Verify Linux microVM",
321
- description: "Run one native-confirmed transient QEMU/KVM microVM proof on linux-backend.",
322
- parameters: { type: "object", additionalProperties: false, properties: {} }, execute });
690
+ description: "Run one native-confirmed transient QEMU/KVM microVM proof on the trusted target. The optional target parameter (user@host, ip, or local) relays the user's machine choice; saving a new target requires the user's native confirmation. Requires the session isolation switch.",
691
+ parameters: {
692
+ type: "object",
693
+ additionalProperties: false,
694
+ properties: {
695
+ target: { type: "string", maxLength: 255, description: "Where the microVM runs: user@host, an ip, or the literal local. Relays the user's explicit choice; a new target is saved only after the user's native confirmation." },
696
+ },
697
+ }, execute });
323
698
  pi.registerCommand?.("agentic-linux-microvm-cutover", { description: "Run the native-confirmed Linux microVM proof",
324
699
  handler: async (args, context) => commandArgumentsPresent(args)
325
700
  ? denied("denied", reason("input", "command-arguments-not-allowed", "Linux microVM cutover accepts no command arguments"))
326
701
  : (await execute("command", {}, undefined, undefined, context)).details });
327
702
  }
703
+
704
+ // Session-scoped isolation switch commands. The flag lives only in this
705
+ // module's memory: the model has no tool to set or read it, it is never
706
+ // written to settings, and each command itself requires a native TUI
707
+ // confirmation. Disabling always succeeds once confirmed; enabling requires
708
+ // the interactive Pi TUI.
709
+ const ISOLATION_ENABLE_COMMAND = "agentic-isolation-enable";
710
+ const ISOLATION_DISABLE_COMMAND = "agentic-isolation-disable";
711
+ export const ISOLATION_COMMANDS = { enable: ISOLATION_ENABLE_COMMAND, disable: ISOLATION_DISABLE_COMMAND };
712
+
713
+ export function registerIsolationSwitchCommands(pi, options = {}) {
714
+ if (typeof pi?.registerCommand !== "function" || SWITCH_REGISTRATIONS.has(pi)) return;
715
+ SWITCH_REGISTRATIONS.add(pi);
716
+ // Fresh, registration-scoped switch state: a new registration starts disabled.
717
+ const isolationSwitch = options.isolationSwitch ?? createIsolationSwitch();
718
+ const switchNotice = () => isolationSwitch.get()
719
+ ? "Isolation activation is ENABLED for this session only. It does not persist to settings and resets when the session ends."
720
+ : "Isolation activation is DISABLED. No microVM proof can run in this session until it is enabled.";
721
+ const switchResult = (ok, status, code, detail) => ({
722
+ schema: LINUX_MICROVM_CUTOVER_SCHEMA, ok, status,
723
+ reason: ok ? undefined : reason("policy", code, detail),
724
+ isolationEnabled: isolationSwitch.get(),
725
+ persisted: false,
726
+ });
727
+ const rejectArguments = () => switchResult(false, "denied", "command-arguments-not-allowed",
728
+ "The isolation switch commands accept no command arguments.");
729
+ pi.registerCommand(ISOLATION_ENABLE_COMMAND, {
730
+ description: "Enable the Linux microVM isolation switch for this session (native confirmation required)",
731
+ handler: async (args, context) => {
732
+ if (commandArgumentsPresent(args)) return rejectArguments();
733
+ if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
734
+ return switchResult(false, "blocked", "native-tui-required",
735
+ "Open the interactive Pi TUI to enable isolation; headless sessions cannot enable it.");
736
+ }
737
+ if (isolationSwitch.get()) {
738
+ return switchResult(true, "ALREADY_ENABLED", "", "");
739
+ }
740
+ let confirmed;
741
+ try {
742
+ confirmed = await context.ui.confirm("Enable Linux microVM isolation", [
743
+ "Enable the isolation-activation switch for this session?",
744
+ switchNotice(),
745
+ "Effect: the agentic_linux_microvm_cutover tool may run one native-confirmed transient QEMU/KVM microVM proof on the configured trusted target per invocation.",
746
+ "The switch is session-scoped: it never persists to settings and the model cannot change it.",
747
+ ].join("\n"));
748
+ } catch (error) {
749
+ return switchResult(false, "blocked", "confirmation-failed", `Native confirmation failed: ${error.message}`);
750
+ }
751
+ if (confirmed !== true) {
752
+ return switchResult(false, "stopped", "not-granted", "Isolation activation was not enabled; native confirmation was not granted.");
753
+ }
754
+ isolationSwitch.set(true);
755
+ return switchResult(true, "ENABLED", "", "");
756
+ },
757
+ });
758
+ pi.registerCommand(ISOLATION_DISABLE_COMMAND, {
759
+ description: "Disable the Linux microVM isolation switch for this session (native confirmation required)",
760
+ handler: async (args, context) => {
761
+ if (commandArgumentsPresent(args)) return rejectArguments();
762
+ if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
763
+ return switchResult(false, "blocked", "native-tui-required",
764
+ "Open the interactive Pi TUI to disable isolation; headless sessions cannot change the switch.");
765
+ }
766
+ let confirmed;
767
+ try {
768
+ confirmed = await context.ui.confirm("Disable Linux microVM isolation", [
769
+ "Disable the isolation-activation switch for this session?",
770
+ switchNotice(),
771
+ ].join("\n"));
772
+ } catch (error) {
773
+ return switchResult(false, "blocked", "confirmation-failed", `Native confirmation failed: ${error.message}`);
774
+ }
775
+ if (confirmed !== true) {
776
+ return switchResult(false, "stopped", "not-granted", "Isolation activation remains enabled; native confirmation was not granted.");
777
+ }
778
+ isolationSwitch.set(false);
779
+ return switchResult(true, "DISABLED", "", "");
780
+ },
781
+ });
782
+ return isolationSwitch;
783
+ }
328
784
  export default registerLinuxMicroVMCutoverInterface;