Enhance Beryl Agent detail and report views
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
const require = createRequire(new URL("../packages/harness/package.json", import.meta.url));
|
||||
const { parse } = require("yaml");
|
||||
const c = parse(readFileSync(new URL("../compose.sandbox.yaml", import.meta.url), "utf8"));
|
||||
test("agent sandbox is non-root, read-only, limited and internal-network-only", () => {
|
||||
const a = c.services.agent;
|
||||
assert.equal(a.user,"1000:1000"); assert.equal(a.read_only,true);
|
||||
assert.deepEqual(a.cap_drop,["ALL"]); assert.ok(a.security_opt.includes("no-new-privileges:true"));
|
||||
assert.equal(a.pids_limit,128); assert.ok(a.cpus>0); assert.ok(a.mem_limit);
|
||||
assert.deepEqual(a.networks,["isolated"]); assert.equal(c.networks.isolated.internal,true);
|
||||
assert.ok(a.ports.every(p=>p.startsWith("127.0.0.1:")));
|
||||
assert.deepEqual(a.volumes,["sandbox-data:/app/workspace"]);
|
||||
assert.equal(a.privileged,undefined);
|
||||
});
|
||||
test("credentials are not copied or loaded wholesale into the image", () => {
|
||||
const dockerfile = readFileSync(new URL("../Dockerfile",import.meta.url),"utf8");
|
||||
assert.ok(!/COPY\s+\.\s|COPY.*\.env|docker.sock/.test(dockerfile));
|
||||
assert.equal(c.services.agent.env_file,undefined);
|
||||
assert.equal(c.services.egress.environment.ANTHROPIC_API_KEY,undefined);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
export function publicIPv4(ip) {
|
||||
if (net.isIP(ip) !== 4) return false;
|
||||
const [a,b,c] = ip.split(".").map(Number);
|
||||
return !(a === 0 || a === 10 || a === 127 || a >= 224 ||
|
||||
(a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) || (a === 192 && (b === 168 || b === 0 || (b === 88 && c === 99))) ||
|
||||
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) || (a === 203 && b === 0 && c === 113));
|
||||
}
|
||||
export async function targetFor(authority, hosts, resolve = lookup) {
|
||||
const match = /^([a-z0-9.-]+):443$/i.exec(authority ?? "");
|
||||
if (!match || !hosts.has(match[1].toLowerCase()) || net.isIP(match[1])) throw new Error("HOST_DENIED");
|
||||
const addresses = await resolve(match[1], { family: 4, all: true });
|
||||
if (!addresses.length || addresses.some(a => !publicIPv4(a.address))) throw new Error("ADDRESS_DENIED");
|
||||
return addresses[0].address; // Connect to the checked address, not a second DNS lookup.
|
||||
}
|
||||
export function startProxy() {
|
||||
const hosts = new Set((process.env.EGRESS_HOSTS ?? "").split(",").map(s => s.trim().toLowerCase()).filter(Boolean));
|
||||
const server = http.createServer((_req,res) => res.writeHead(403).end());
|
||||
server.maxConnections = 64;
|
||||
server.headersTimeout = 10000;
|
||||
server.on("connect", async (req, client, head) => {
|
||||
client.on("error", () => client.destroy());
|
||||
client.setTimeout(120000, () => client.destroy());
|
||||
let upstream;
|
||||
try {
|
||||
const ip = await targetFor(req.url, hosts);
|
||||
if (client.destroyed) return;
|
||||
upstream = net.connect({ host: ip, port: 443 });
|
||||
upstream.setTimeout(120000, () => upstream.destroy());
|
||||
upstream.on("error", () => client.destroy());
|
||||
client.on("close", () => upstream.destroy());
|
||||
upstream.on("close", () => client.destroy());
|
||||
upstream.once("connect", () => {
|
||||
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
if (head.length) upstream.write(head);
|
||||
client.pipe(upstream); upstream.pipe(client);
|
||||
});
|
||||
} catch { upstream?.destroy(); client.end("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n"); }
|
||||
});
|
||||
server.listen(3128, "0.0.0.0");
|
||||
return server;
|
||||
}
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) startProxy();
|
||||
@@ -0,0 +1,17 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { publicIPv4, targetFor } from "./egress.mjs";
|
||||
test("blocks private, metadata, reserved and IPv6 addresses", () => {
|
||||
for (const ip of ["127.0.0.1","10.1.2.3","172.16.1.1","192.168.1.1","169.254.169.254","100.64.1.1","0.0.0.0","224.0.0.1","::1","::ffff:127.0.0.1"]) assert.equal(publicIPv4(ip),false,ip);
|
||||
assert.equal(publicIPv4("1.1.1.1"),true);
|
||||
});
|
||||
test("requires exact hostname and HTTPS port", async () => {
|
||||
const hosts = new Set(["example.com"]);
|
||||
for (const target of ["evil.example.com:443","example.com:80","example.com:443/","127.0.0.1:443","example.com@evil.com:443"])
|
||||
await assert.rejects(targetFor(target,hosts));
|
||||
});
|
||||
test("rejects mixed public/private DNS and pins a validated address", async () => {
|
||||
const hosts = new Set(["example.com"]);
|
||||
await assert.rejects(targetFor("example.com:443",hosts,async()=>[{address:"1.1.1.1"},{address:"127.0.0.1"}]));
|
||||
assert.equal(await targetFor("example.com:443",hosts,async()=>[{address:"1.1.1.1"}]),"1.1.1.1");
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { spawn } from "node:child_process";
|
||||
const children = [
|
||||
spawn(process.execPath, ["--import", "./apps/server/node_modules/tsx/dist/loader.mjs", "apps/server/src/index.ts"], { stdio: "inherit" }),
|
||||
spawn(process.execPath, ["sandbox/web.mjs"], { stdio: "inherit" }),
|
||||
];
|
||||
let stopping = false;
|
||||
function stop(code) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
children.forEach(child => child.kill("SIGTERM"));
|
||||
setTimeout(() => { children.forEach(child => child.kill("SIGKILL")); process.exit(code); }, 5000);
|
||||
}
|
||||
children.forEach(child => { child.on("error", () => stop(1)); child.on("exit", () => stop(stopping ? 0 : 1)); });
|
||||
process.on("SIGTERM", () => stop(0));
|
||||
process.on("SIGINT", () => stop(0));
|
||||
const ttl = Number(process.env.SANDBOX_TTL_SECONDS ?? 3600);
|
||||
if (!Number.isInteger(ttl) || ttl < 30 || ttl > 86400) stop(1);
|
||||
else setTimeout(() => stop(0), ttl * 1000);
|
||||
@@ -0,0 +1,49 @@
|
||||
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); }
|
||||
@@ -0,0 +1,26 @@
|
||||
import http from "node:http";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
const root = path.resolve("apps/web/dist");
|
||||
http.createServer(async (req, res) => {
|
||||
if (!/^localhost(?::\d+)?$|^127\.0\.0\.1(?::\d+)?$/.test(req.headers.host ?? "")) { res.writeHead(403).end(); return; }
|
||||
if (req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) { res.writeHead(403).end(); return; }
|
||||
if (req.url?.startsWith("/api/") || req.url === "/health") {
|
||||
const upstream = http.request({ hostname: "127.0.0.1", port: 3001, path: req.url, method: req.method, headers: { ...req.headers, host: "127.0.0.1:3001" } }, response => {
|
||||
res.writeHead(response.statusCode ?? 502, response.headers); response.pipe(res);
|
||||
});
|
||||
upstream.on("error", () => { if (!res.headersSent) res.writeHead(502); res.end(); });
|
||||
res.on("close", () => upstream.destroy());
|
||||
req.pipe(upstream); return;
|
||||
}
|
||||
try {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") { res.writeHead(405).end(); return; }
|
||||
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://localhost").pathname);
|
||||
const file = path.resolve(root, `.${pathname === "/" ? "/index.html" : pathname}`);
|
||||
if (!file.startsWith(root + path.sep)) { res.writeHead(403).end(); return; }
|
||||
const bytes = await readFile(file);
|
||||
const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml" };
|
||||
res.writeHead(200, { "content-type": types[path.extname(file)] ?? "application/octet-stream", "x-content-type-options": "nosniff" });
|
||||
res.end(req.method === "HEAD" ? undefined : bytes);
|
||||
} catch { res.writeHead(404).end(); }
|
||||
}).listen(5173, "0.0.0.0");
|
||||
Reference in New Issue
Block a user