ai-chatbot/scripts/zcode-agent/sandbox-fixture.mjs

55 lines
4.4 KiB
JavaScript

import assert from 'node:assert/strict';
import { mkdtemp, mkdir, writeFile, readFile, chmod, chown } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { sandboxCommand } from '../../services/zcode-agent/sandbox.mjs';
import { createRunBroker } from '../../services/zcode-agent/broker.mjs';
const linux = process.platform === 'linux';
const base = await mkdtemp('/var/tmp/yedir-isolation-'); await chmod(base, 0o711);
const root = join(base, 'task'); await mkdir(root, { mode: 0o700 });
const uid = 900000 + Math.floor(Math.random() * 90000);
for (const name of ['home', 'workspace', 'tmp']) await mkdir(join(root, name), { mode: 0o700 });
const canary = join(base, 'host-secret'); await writeFile(canary, 'HOST_SECRET_MUST_NOT_BE_READ', { mode: 0o600 });
const sibling = join(base, 'another-task'); await mkdir(sibling, { mode: 0o700 });
await writeFile(join(sibling, 'secret'), 'OTHER_TASK_SECRET', { mode: 0o600 });
await writeFile(join(root, 'passwd'), `agent:x:${uid}:${uid}:Agent:/home/agent:/bin/bash\n`);
await writeFile(join(root, 'group'), `agent:x:${uid}:\n`);
const token = 'fixture-token-private-to-this-task'; let modelCalls = 0;
const broker = await createRunBroker({ token, ...(linux ? { socketPath: join(root, 'broker.sock') } : {}),
model: async () => { modelCalls++; return Response.json({ ok: true }); }, mcp: async () => ({}) });
const forbidden = createServer((_req, res) => { res.end('HOST_NETWORK'); });
await new Promise(resolve => forbidden.listen(0, '127.0.0.1', resolve));
if (linux) { for (const path of [root, ...['home', 'workspace', 'tmp', 'broker.sock'].map(n => join(root, n))]) await chown(path, uid, uid); }
const nodePath = process.env.ZCODE_FIXTURE_NODE || join(process.env.ZCODE_FIXTURE_RUNTIME || process.env.CANVAS_ZCODE_RUNTIME_DIR || resolve('.local/zcode-runtime'), 'bin/node');
const runtimeDir = process.env.ZCODE_FIXTURE_RUNTIME || process.env.CANVAS_ZCODE_RUNTIME_DIR || resolve('.local/zcode-runtime');
const script = `
const fs = require('node:fs'); const http = require('node:http');
const deniedRead = path => { try { fs.readFileSync(path); return false; } catch { return true; } };
const own = ${JSON.stringify(linux ? '/workspace' : join(root, 'workspace'))};
fs.writeFileSync(own + '/own-file', 'private');
let writeDenied = false; try { fs.writeFileSync(${JSON.stringify(join(base, 'escape'))}, 'escape'); } catch { writeDenied = true; }
const req = http.get('http://127.0.0.1:${forbidden.address().port}', response => { response.resume(); done(false); });
req.on('error', () => done(true)); req.setTimeout(2500, () => { req.destroy(); done(true); });
let finished = false; function done(networkDenied) { if (finished) return; finished = true;
console.log(JSON.stringify({hostReadDenied: deniedRead(${JSON.stringify(canary)}), otherTaskDenied: deniedRead(${JSON.stringify(join(sibling, 'secret'))}), writeDenied, networkDenied, ownRead: fs.readFileSync(own + '/own-file', 'utf8'), uid: process.getuid()})); }
`;
try {
const command = await sandboxCommand({ root, runtimeDir, token, brokerUrl: broker.url, uid, runId: 'isolation-proof-' + Date.now(), nodePath, cliArgs: [] });
let args = command.args;
if (linux) { const i = args.indexOf('/runtime/bridge.mjs'); args = [...args.slice(0, i), '-e', script]; }
else args = [...args.slice(0, args.indexOf(nodePath) + 1), '-e', script];
const child = spawn(command.command, args, { cwd: command.cwd, env: command.env, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '', stderr = ''; child.stdout.on('data', d => stdout += d); child.stderr.on('data', d => stderr += d);
const exit = await new Promise((resolve, reject) => { child.on('error', reject); child.on('close', code => resolve(code)); });
assert.equal(exit, 0, stderr); const proof = JSON.parse(stdout.trim());
for (const key of ['hostReadDenied','otherTaskDenied','writeDenied','networkDenied']) assert.equal(proof[key], true, key);
assert.equal(proof.ownRead, 'private'); if (linux) assert.equal(proof.uid, uid);
if (broker.url) {
const unauthorized = await fetch(broker.url + '/v1/chat/completions', { method: 'POST', headers: { Authorization: 'Bearer other-task-token' }, body: '{}' });
assert.equal(unauthorized.status, 401); assert.equal(modelCalls, 0);
}
console.log('SANDBOX_ISOLATION_PASSED', JSON.stringify({ ...proof, sandbox: command.kind }));
} finally { await broker.close(); await new Promise(resolve => forbidden.close(resolve)); }