@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.
- package/PROVENANCE.md +5 -0
- package/README.md +336 -190
- package/config/microvm-target.v1.example.json +5 -0
- package/config/microvm-target.v1.json +5 -0
- package/extensions/herdr-dispatch.ts +27 -0
- package/extensions/linux-microvm.ts +11 -7
- package/package.json +31 -2
- package/scripts/enforcement/guest_containment_taxonomy.v1.json +26 -0
- package/scripts/enforcement/herdr_async_dispatch_pi.js +402 -0
- package/scripts/enforcement/linux_microvm_cutover_pi.js +512 -56
- package/scripts/enforcement/linux_microvm_remote_fixture.sh +2418 -19
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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",
|
|
95
|
-
"test -
|
|
96
|
-
"test -x /usr/bin/
|
|
97
|
-
"test -x /usr/bin/
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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
|
|
268
|
-
`
|
|
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
|
-
|
|
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
|
|
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
|
|
312
|
-
?
|
|
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
|
-
|
|
316
|
-
isolationEnabled: options.runtime?.isolationEnabled,
|
|
675
|
+
...(typeof params?.target === "string" ? { target: params.target } : {}),
|
|
317
676
|
});
|
|
318
|
-
|
|
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
|
|
322
|
-
parameters: {
|
|
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;
|