import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const [action, name = "local"] = process.argv.slice(2); if (!["start", "stop", "verify"].includes(action) || !/^[a-z][a-z0-9-]{0,30}$/.test(name)) { console.error("Usage: node sandbox/manage.mjs start|stop|verify instance-name"); process.exit(2); } function docker(args, capture = false) { const r = spawnSync("docker", args, { cwd: root, encoding: "utf8", stdio: capture ? "pipe" : "inherit" }); if (r.error || r.status !== 0) throw new Error(r.error?.code === "ENOENT" ? "Docker is not installed. Install Docker Engine and Compose first; host execution is NOT a fallback." : "Docker operation failed; check Docker daemon/Compose access."); return r.stdout?.trim(); } try { docker(["info", "--format", "{{.ServerVersion}}"], true); docker(["compose", "version"], true); const base = ["compose", "-p", `beryl-sb-${name}`]; if (existsSync(path.join(root, ".env"))) base.push("--env-file", ".env"); base.push("-f", "compose.sandbox.yaml"); if (process.env.PPT_RUNTIME_HOST_DIR || process.env.PPT_SKILL_HOST_DIR) base.push("-f", "compose.sandbox-ppt.yaml"); docker([...base, "config", "--quiet"]); if (action === "start") { docker([...base, "up", "--build", "--detach", "--wait"]); console.log(`Sandbox instance: beryl-sb-${name}. Open http://localhost:${process.env.SANDBOX_PORT ?? 15173}/. Run verify before use.`); } else if (action === "stop") { docker([...base, "down"]); // Deliberately no --volumes: retain reports and requirements. console.log("Containers stopped/removed; persistent report volume retained."); } else { const id = docker([...base, "ps", "-q", "agent"], true); if (!id) throw new Error("Sandbox is not running"); const data = JSON.parse(docker(["inspect", id], true))[0]; const h = data.HostConfig; if (data.Config.User !== "1000:1000" || !h.ReadonlyRootfs || h.Privileged || !h.CapDrop?.includes("ALL") || !h.SecurityOpt?.includes("no-new-privileges:true") || h.PidsLimit !== 128 || h.Memory <= 0 || h.NanoCpus <= 0) throw new Error("Isolation settings failed verification"); if (data.Mounts.some(m => !["/app/workspace", "/opt/ppt-runtime", "/opt/ppt-skill", "/tmp", "/home/node"].includes(m.Destination) || (m.Type === "bind" && m.RW))) throw new Error("Unexpected host mount"); const networks = Object.keys(data.NetworkSettings.Networks); if (networks.length !== 1 || !JSON.parse(docker(["network", "inspect", networks[0]], true))[0].Internal) throw new Error("Agent must use only the internal network"); docker(["exec", id, "node", "--input-type=module", "-e", ` import fs from 'node:fs/promises'; if(process.getuid()===0) throw Error('root'); try { await fs.writeFile('/app/escape-check','x'); throw Error('rootfs writable'); } catch(e) { if(!['EROFS','EACCES'].includes(e.code)) throw e; } await fs.writeFile('/app/workspace/.sandbox-check','ok'); await fs.unlink('/app/workspace/.sandbox-check'); const net=await import('node:net'); await new Promise((resolve,reject)=>{const s=net.connect({host:'1.1.1.1',port:443});s.setTimeout(2000,()=>{s.destroy();resolve();});s.on('error',()=>resolve());s.on('connect',()=>{s.destroy();reject(Error('direct egress bypass'));});}); console.log('PASS: non-root, read-only rootfs, workspace writable, direct network blocked'); `]); } } catch (e) { console.error(e.message); process.exit(1); }