@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.
|
|
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
|
|
316
|
+
if (value?.schema && RECEIPT_SCHEMAS.has(value.schema)) candidates.push(value);
|
|
281
317
|
else unexpected.push(trimmed);
|
|
282
318
|
} catch {
|
|
283
319
|
unexpected.push(trimmed);
|
|
@@ -289,16 +325,84 @@ function parseReceipt(stdout) {
|
|
|
289
325
|
throw phaseError("evidence", "receipt-missing", detail);
|
|
290
326
|
}
|
|
291
327
|
if (candidates.length !== 1) throw phaseError("evidence", "receipt-ambiguous", "multiple structured microVM receipts were returned");
|
|
292
|
-
if (unexpected.length)
|
|
328
|
+
if (unexpected.length) {
|
|
329
|
+
// Relax receipt-extra-output ONLY for the containment envelope marker
|
|
330
|
+
// lines, and only when a v2 (containment) receipt is in force. Any other
|
|
331
|
+
// unbound output still fails closed.
|
|
332
|
+
const envelopeLines = unexpected.filter((line) => CONTAINMENT_MARKER_LINE.test(line));
|
|
333
|
+
if (!(envelopeLines.length === unexpected.length
|
|
334
|
+
&& candidates[0]?.schema === LINUX_MICROVM_CUTOVER_SCHEMA_V2)) {
|
|
335
|
+
throw phaseError("evidence", "receipt-extra-output", `unbound fixture output: ${unexpected.join(" ")}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
293
338
|
return candidates[0];
|
|
294
339
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
340
|
+
function requireCount(value, label) {
|
|
341
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
342
|
+
throw phaseError("evidence", "receipt-invalid", `${label} is not a non-negative integer count`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// Closed containment sub-object (design section 5). A verified killswitch
|
|
346
|
+
// trip with a verified log digest is still the success state for the
|
|
347
|
+
// containment proof: the proof is that the killswitch worked.
|
|
348
|
+
function validateContainmentBlock(receipt) {
|
|
349
|
+
const block = receipt.containment;
|
|
350
|
+
// Durable kill report (design section 5): the report path is present only
|
|
351
|
+
// on a tripped killswitch; a clean session must not carry one.
|
|
352
|
+
const blockKeys = ["schema", "taxonomySha256", "logSha256", "events", "denials", "probes", "concealmentIndex", "histogram", "killswitch"];
|
|
353
|
+
const trippedEarly = block?.killswitch?.tripped === true;
|
|
354
|
+
if (trippedEarly) blockKeys.push("killReportPath");
|
|
355
|
+
exactKeys(block, blockKeys, "containment block");
|
|
356
|
+
if (block.schema !== GUEST_CONTAINMENT_LOG_SCHEMA) {
|
|
357
|
+
throw phaseError("evidence", "receipt-invalid", "containment log schema is unexpected");
|
|
358
|
+
}
|
|
359
|
+
requireHash(block.taxonomySha256, "containment taxonomy digest");
|
|
360
|
+
requireHash(block.logSha256, "containment log digest");
|
|
361
|
+
requireCount(block.events, "containment event count");
|
|
362
|
+
requireCount(block.denials, "containment denial count");
|
|
363
|
+
requireCount(block.probes, "containment probe count");
|
|
364
|
+
requireCount(block.concealmentIndex, "containment concealment index");
|
|
365
|
+
// Compact class histogram (design section 4): class -> non-negative count.
|
|
366
|
+
if (!block.histogram || typeof block.histogram !== "object" || Array.isArray(block.histogram)
|
|
367
|
+
|| Object.values(block.histogram).some((count) => !Number.isInteger(count) || count < 0)) {
|
|
368
|
+
throw phaseError("evidence", "receipt-invalid", "containment histogram is not a class-count map");
|
|
369
|
+
}
|
|
370
|
+
exactKeys(block.killswitch, ["tripped", "rule", "class", "tier", "guestPoweroff", "final"], "containment killswitch");
|
|
371
|
+
requireBoolean(block.killswitch.tripped, "killswitch tripped");
|
|
372
|
+
if (block.killswitch.guestPoweroff !== true || block.killswitch.final !== true) {
|
|
373
|
+
throw phaseError("evidence", "receipt-invalid", "killswitch guest poweroff or final flag is unexpected");
|
|
374
|
+
}
|
|
375
|
+
if (block.killswitch.tripped) {
|
|
376
|
+
if (typeof block.killswitch.rule !== "string" || !block.killswitch.rule
|
|
377
|
+
|| typeof block.killswitch.class !== "string" || !block.killswitch.class
|
|
378
|
+
|| typeof block.killswitch.tier !== "string" || !block.killswitch.tier) {
|
|
379
|
+
throw phaseError("evidence", "receipt-invalid", "killswitch tripped without rule, class, or tier");
|
|
380
|
+
}
|
|
381
|
+
if (typeof block.killReportPath !== "string" || !block.killReportPath.endsWith("kill-report.json")) {
|
|
382
|
+
throw phaseError("evidence", "receipt-invalid", "killswitch trip without a kill report path");
|
|
383
|
+
}
|
|
384
|
+
} else if (block.killswitch.rule !== null || block.killswitch.class !== null || block.killswitch.tier !== null) {
|
|
385
|
+
throw phaseError("evidence", "receipt-invalid", "killswitch rule, class, and tier must be null when not tripped");
|
|
386
|
+
}
|
|
387
|
+
return block;
|
|
388
|
+
}
|
|
389
|
+
export function validateLinuxMicroVMReceipt(receipt, facts, fixtureId, scriptHash, options = {}) {
|
|
390
|
+
const containmentRun = options.containment === true;
|
|
391
|
+
// Fail-closed: a containment run without the v2 receipt and its evidence is
|
|
392
|
+
// containment-evidence-missing, never silently downgraded.
|
|
393
|
+
if (containmentRun && receipt.schema !== LINUX_MICROVM_CUTOVER_SCHEMA_V2) {
|
|
394
|
+
throw phaseError("evidence", "containment-evidence-missing",
|
|
395
|
+
"containment run returned no v2 containment receipt");
|
|
396
|
+
}
|
|
397
|
+
const expectedKeys = ["schema", "ok", "status", "authorityCreated", "runtimeActivated", "persisted",
|
|
398
|
+
"identity", "marker", "scriptHash", "initramfsSha256", "teardown", "context"];
|
|
399
|
+
if (containmentRun || receipt.schema === LINUX_MICROVM_CUTOVER_SCHEMA_V2) expectedKeys.push("containment");
|
|
400
|
+
exactKeys(receipt, expectedKeys, "receipt");
|
|
401
|
+
if (!RECEIPT_SCHEMAS.has(receipt.schema) || receipt.ok !== true || receipt.status !== "VERIFIED"
|
|
299
402
|
|| receipt.authorityCreated !== false || receipt.runtimeActivated !== false || receipt.persisted !== false) {
|
|
300
403
|
throw phaseError("evidence", "receipt-invalid", "receipt status or non-authorizing flags are unexpected");
|
|
301
404
|
}
|
|
405
|
+
if (receipt.schema === LINUX_MICROVM_CUTOVER_SCHEMA_V2) validateContainmentBlock(receipt);
|
|
302
406
|
const domain = fixtureDomainForId(fixtureId);
|
|
303
407
|
exactKeys(receipt.identity, ["remoteHost", "fixtureId", "domain"], "receipt identity");
|
|
304
408
|
if (receipt.identity.remoteHost !== facts.host || receipt.identity.fixtureId !== fixtureId || receipt.identity.domain !== domain) {
|
|
@@ -350,6 +454,16 @@ export function validateLinuxMicroVMReceipt(receipt, facts, fixtureId, scriptHas
|
|
|
350
454
|
}
|
|
351
455
|
return receipt;
|
|
352
456
|
}
|
|
457
|
+
// Elastic resource allocation (design section 6.1): user-configured through
|
|
458
|
+
// the target config only (never model-set); the model-visible tool surface
|
|
459
|
+
// stays closed. Defaults are the existing proof values.
|
|
460
|
+
function resourceAllocation(target) {
|
|
461
|
+
return { vcpu: target?.vcpu ?? 1, memoryMiB: target?.memoryMiB ?? 128 };
|
|
462
|
+
}
|
|
463
|
+
function resourceLine(target) {
|
|
464
|
+
const allocation = resourceAllocation(target);
|
|
465
|
+
return `${allocation.vcpu} vCPU, ${allocation.memoryMiB} MiB, BusyBox initramfs, no disk, network, host share, credentials, GPU, or serving access.`;
|
|
466
|
+
}
|
|
353
467
|
function normalizedForwardedStderr(result, fallbackPhase, fallbackCode, fallbackDetail) {
|
|
354
468
|
const text = String(result?.stderr || "");
|
|
355
469
|
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
@@ -365,6 +479,15 @@ function normalizedForwardedStderr(result, fallbackPhase, fallbackCode, fallback
|
|
|
365
479
|
primary?.[2] || (cleanup.length ? "cleanup-failed" : fallbackCode), details.join("; "));
|
|
366
480
|
}
|
|
367
481
|
|
|
482
|
+
// Containment run mode (design sections 1.3, 5, 6; M6 wired): the
|
|
483
|
+
// user-configured `jobPayload` in the target config is the only source of
|
|
484
|
+
// containment mode — present payload = containment run (v2 receipt with the
|
|
485
|
+
// containment block), absent = plain proof mode (v1, backward compatible).
|
|
486
|
+
// The model cannot set or alter it: the tool schema stays closed (target
|
|
487
|
+
// relay only) and the payload never crosses the model-visible surface.
|
|
488
|
+
function payloadRedacted(payload) {
|
|
489
|
+
return boundedText(String(payload).replace(/\s+/g, " ").trim(), "payload").slice(0, 160);
|
|
490
|
+
}
|
|
368
491
|
export async function runLinuxMicroVMCutover(context, options = {}) {
|
|
369
492
|
// Session-scoped user switch: only the explicit enable command can set this
|
|
370
493
|
// flag in memory; it never persists to settings and the model cannot set it.
|
|
@@ -399,9 +522,28 @@ export async function runLinuxMicroVMCutover(context, options = {}) {
|
|
|
399
522
|
if (confirmed !== true) {
|
|
400
523
|
return denied("stopped", reason("confirmation", "not-granted", "No target was saved; native confirmation was not granted."));
|
|
401
524
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
525
|
+
// Read-modify-write: the confirmation authorizes changing WHERE the
|
|
526
|
+
// microVM runs — nothing else. A fresh narrow object here would silently
|
|
527
|
+
// drop the user's other configured fields (jobPayload above all: a
|
|
528
|
+
// re-save stripped it and flipped the next run to plain proof mode).
|
|
529
|
+
// Preserve every schema field the existing config carries; the decision
|
|
530
|
+
// under confirmation replaces exactly one of sshTarget/local. Preserved
|
|
531
|
+
// fields are still fully validated fail-closed by loadMicroVMTarget.
|
|
532
|
+
let existing = {};
|
|
533
|
+
try {
|
|
534
|
+
const parsed = JSON.parse(readFileSync(writePath, "utf8"));
|
|
535
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) existing = parsed;
|
|
536
|
+
} catch {
|
|
537
|
+
// Absent or unreadable config: fresh write below.
|
|
538
|
+
}
|
|
539
|
+
const saved = { ...existing, schema: TARGET_SCHEMA };
|
|
540
|
+
if (targetParam === "local") {
|
|
541
|
+
delete saved.sshTarget;
|
|
542
|
+
saved.local = true;
|
|
543
|
+
} else {
|
|
544
|
+
delete saved.local;
|
|
545
|
+
saved.sshTarget = targetParam;
|
|
546
|
+
}
|
|
405
547
|
try {
|
|
406
548
|
mkdirSync(dirname(writePath), { recursive: true });
|
|
407
549
|
writeFileSync(writePath, `${JSON.stringify(saved, null, 2)}\n`);
|
|
@@ -423,6 +565,7 @@ export async function runLinuxMicroVMCutover(context, options = {}) {
|
|
|
423
565
|
return denied("blocked", reason("policy", "native-tui-required",
|
|
424
566
|
"Open the Linux microVM cutover in the interactive Pi TUI; headless runs are denied."));
|
|
425
567
|
}
|
|
568
|
+
const containmentRun = typeof target.jobPayload === "string";
|
|
426
569
|
if (inFlight) return denied("denied", reason("execution", "already-active", "another Linux microVM cutover is active in this host session"));
|
|
427
570
|
|
|
428
571
|
const fixtureId = `microvm-${randomBytes(12).toString("hex")}`;
|
|
@@ -447,7 +590,13 @@ export async function runLinuxMicroVMCutover(context, options = {}) {
|
|
|
447
590
|
`Fixture: ${fixtureId}; domain: ${fixtureDomain} (preflight absent)`,
|
|
448
591
|
`Versioned fixture SHA-256: ${scriptHash}`,
|
|
449
592
|
"Writes: one generated fixture below ~/agentic-driver-state/cutover-fixtures/microvm/.",
|
|
450
|
-
|
|
593
|
+
...(containmentRun
|
|
594
|
+
? [
|
|
595
|
+
"Guest runs a deny-by-default containment monitor; any kill decision kills the guest session and tears down the VM.",
|
|
596
|
+
`Guest job payload (user-configured): ${payloadRedacted(target.jobPayload)}`,
|
|
597
|
+
]
|
|
598
|
+
: []),
|
|
599
|
+
`Guest: ${resourceLine(target)}`,
|
|
451
600
|
"A temporary traverse-only ACL for libvirt-qemu is added to the remote home directory and the exact prior ACL is restored after exit or failure.",
|
|
452
601
|
"The transient domain prints one marker, powers off, and must disappear from libvirt.",
|
|
453
602
|
"No install, download, repository mutation, runtime authority, staging, commit, or push.",
|
|
@@ -465,14 +614,23 @@ export async function runLinuxMicroVMCutover(context, options = {}) {
|
|
|
465
614
|
}
|
|
466
615
|
inFlight = true;
|
|
467
616
|
try {
|
|
617
|
+
const allocation = resourceAllocation(target);
|
|
618
|
+
const fixtureArgs = [fixtureId, scriptHash, String(allocation.vcpu), String(allocation.memoryMiB)];
|
|
619
|
+
// The payload travels base64-encoded: ssh concatenates argv into one
|
|
620
|
+
// command string parsed by the remote login shell, so a raw payload
|
|
621
|
+
// (newlines, quotes, semicolons are allowed by design) would be word-
|
|
622
|
+
// split, reinterpreted, or injected as remote commands. Base64 is
|
|
623
|
+
// shell-safe (no whitespace/metacharacters) and round-trips exactly;
|
|
624
|
+
// the fixture decodes and validates it. Local mode benefits identically.
|
|
625
|
+
if (containmentRun) fixtureArgs.push(Buffer.from(target.jobPayload, "utf8").toString("base64"));
|
|
468
626
|
const result = target.mode === "local"
|
|
469
|
-
? execute("bash", ["-c", "bash -s -- " + shellQuote(
|
|
470
|
-
: execute("ssh", [target.sshTarget, "bash", "-s", "--",
|
|
627
|
+
? execute("bash", ["-c", "bash -s -- " + fixtureArgs.map(shellQuote).join(" ")], { input: script, timeout: 180000 })
|
|
628
|
+
: execute("ssh", [target.sshTarget, "bash", "-s", "--", ...fixtureArgs], { input: script, timeout: 180000 });
|
|
471
629
|
if (!result || result.code !== 0) {
|
|
472
630
|
return denied("blocked", normalizedForwardedStderr(result, "fixture", "execution-failed", "fixed microVM fixture failed"));
|
|
473
631
|
}
|
|
474
632
|
const receipt = parseReceipt(result.stdout);
|
|
475
|
-
return validateLinuxMicroVMReceipt(receipt, facts, fixtureId, scriptHash);
|
|
633
|
+
return validateLinuxMicroVMReceipt(receipt, facts, fixtureId, scriptHash, { containment: containmentRun });
|
|
476
634
|
} catch (error) {
|
|
477
635
|
return denied("blocked", reasonFromError(error, "execution", "fixture-failed"));
|
|
478
636
|
} finally { inFlight = false; }
|
|
@@ -516,6 +674,15 @@ export function registerLinuxMicroVMCutoverInterface(pi, options = {}) {
|
|
|
516
674
|
...options,
|
|
517
675
|
...(typeof params?.target === "string" ? { target: params.target } : {}),
|
|
518
676
|
});
|
|
677
|
+
// M2 (design section 5): a killswitch trip raises an error-severity
|
|
678
|
+
// notification naming the rule, class, and severity tier.
|
|
679
|
+
const killswitch = value?.containment?.killswitch;
|
|
680
|
+
if (value?.ok === true && killswitch?.tripped === true && typeof context?.ui?.notify === "function") {
|
|
681
|
+
context.ui.notify(
|
|
682
|
+
`MICROVM CONTAINMENT: KILLSWITCH TRIPPED — rule ${killswitch.rule}, class ${killswitch.class}, tier ${killswitch.tier}; guest session killed and VM torn down. Kill report: ${value?.containment?.killReportPath ?? "(unavailable)"}`,
|
|
683
|
+
"error",
|
|
684
|
+
);
|
|
685
|
+
}
|
|
519
686
|
notifyOutcome(context, value);
|
|
520
687
|
return { content: [{ type: "text", text: `${outcomeLine(value)}\n${JSON.stringify(value, null, 2)}` }], details: value };
|
|
521
688
|
};
|