aweft

recipes/sandbox/main.ts

One file of recipes/sandbox, at commit 9a5bb24.

/* eslint-disable no-console */
// The proof for @aweftjs/sandbox: a hostile module stays contained while a benign one does
// real work through granted names, and the same room shape runs behind three different walls.
//
// The job: an application lets people drop in report modules an agent wrote. A report may
// read the day's rows through a granted `data/Rows` and may compose another report module,
// but it may not touch the filesystem, the network, or the application's own state. One
// report is benign and is summarized; one is hostile and is contained; a plan change revokes
// a name mid-run; an edit to a report reloads it in place.
//
// Run: node recipes/sandbox/main.ts

import { createArray, createObject } from '@aweftjs/core';
import { createSandbox, inProcess } from '@aweftjs/sandbox';
import { child } from '@aweftjs/sandbox/node';
import type { Runner, Sandbox } from '@aweftjs/sandbox';

let checks = 0;
let failed = 0;
const check = (ok: boolean, what: string): void => {
	checks += 1;
	if (!ok) failed += 1;
	console.log(`  ${ok ? 'ok  ' : 'FAIL'} ${what}`);
};
const rejects = async (reason: string, what: string, run: () => Promise<unknown>): Promise<void> => {
	try {
		await run();
		check(false, `${what} (it answered instead of ${reason})`);
	} catch (error) {
		check((error as { reason?: string }).reason === reason, `${what} (${(error as { reason?: string }).reason})`);
	}
};
const until = async (what: string, ok: () => boolean | Promise<boolean>): Promise<void> => {
	for (let i = 0; i < 500; i++) {
		if (await ok()) return;
		await new Promise((done) => setTimeout(done, 5));
	}
	check(false, `timed out waiting for ${what}`);
};

// The reports an agent wrote, as source in a document. `daily/Summary` composes `lib/Format`,
// a report module of its own; `evil/Exfiltrate` tries everything a room must not be able to do.
const reports = (): Record<string, unknown> => createObject<Record<string, unknown>>({
	'lib/Format': createObject({ source: `export default () => ({ bullet: (line) => '- ' + line });` }),
	'daily/Summary': createObject({ source: `
		export const deps = ['data/Rows', 'lib/Format'];
		export default ({ imports }) => ({
			run: async (day) => {
				const rows = await imports.Rows.forDay(day);
				return rows.map((r) => imports.Format.bullet(r.label + ': ' + r.value)).join('\\n');
			},
		});` }),
	'evil/Exfiltrate': createObject({ source: `
		export default () => ({
			ransack: async () => {
				const out = {};
				const attempt = async (name, fn) => { try { out[name] = await fn(); } catch (e) { out[name] = (e.code ?? e.name); } };
				await attempt('read /etc/passwd', async () => (await import('node:fs')).readFileSync('/etc/passwd', 'utf8').length);
				await attempt('spawn', async () => (await import('node:child_process')).execSync('id').toString());
				await attempt('eval', () => new Function('return process.env')());
				return out;
			},
			smuggle: () => ({ looksFine: true, but: () => 'a function' }),
		});` }),
});

// What the application grants: a reader over the day's rows, and nothing else.
const rowsForDay = { forDay: (day: string) => [{ label: 'signups', value: day === 'mon' ? 12 : 3 }, { label: 'churn', value: 1 }] };

const scenario = async (label: string, runner: Runner, contained: boolean): Promise<void> => {
	console.log(`\n${label}`);
	const modules = reports();
	const grants = createArray<string>(['data/Rows']);
	const applied: string[] = [];
	const sandbox: Sandbox = await createSandbox({
		runner, modules, grants, props: { site: 'reports' }, follow: true, limits: { callMs: 10000 },
		handlers: { applied: (name, action) => applied.push(`${name} ${action}`) },
	});
	try {
		sandbox.expose('data/Rows', rowsForDay);

		const { 'daily/Summary': summary } = await sandbox.load(['daily/Summary']);
		check(await summary!.run!('mon') === '- signups: 12\n- churn: 1',
			'a report read the granted rows and composed another report module to format them');

		const { 'evil/Exfiltrate': evil } = await sandbox.load(['evil/Exfiltrate']);
		if (contained) {
			// Behind a wall, every reach out of the room is denied, so the result is all error
			// codes and crosses as data. The point is that not one of them says a number or a
			// captured value.
			const ransacked = await evil!.ransack!() as Record<string, string>;
			const reached = Object.entries(ransacked).filter(([, v]) => v === undefined || /^\d+$/.test(String(v)) || String(v).includes('uid='));
			check(reached.length === 0, `the hostile report reached nothing behind the wall: ${JSON.stringify(ransacked)}`);
		} else {
			// In process there is no wall: the module runs with the process's own privileges, and
			// its reaches SUCCEED. That is what the runner claims, and why it is the trusted case.
			console.log('  --   in process there is no isolation, so this report is NOT contained (by design)');
		}
		await rejects('not-data', 'a function it tried to smuggle out in a result was refused by name, in every runner', () => evil!.smuggle!());

		// The plan changes: this tenant loses row access. The next call is refused.
		grants.splice(grants.indexOf('data/Rows'), 1);
		await rejects('refused', 'a name revoked mid-run refuses from the next call', () => summary!.run!('tue'));
		grants.push('data/Rows');
		check(await summary!.run!('tue') === '- signups: 3\n- churn: 1', 'and granting it again lets the report run again');

		// An agent revises the summary. It reloads in place, and the new source is what runs.
		(modules['daily/Summary'] as { source: string }).source = `
			export const deps = ['data/Rows'];
			export default ({ imports }) => ({ run: async (day) => 'total ' + (await imports.Rows.forDay(day)).reduce((n, r) => n + r.value, 0) });`;
		await until('the reload', () => applied.includes('daily/Summary reloaded'));
		check(await (await sandbox.load(['daily/Summary']))['daily/Summary']!.run!('mon') === 'total 13',
			'an edited report reloaded in place and the new source is what ran');
	} finally {
		await sandbox.stop();
	}
	await rejects('closed', 'and after the room stopped, it answers nothing', () => sandbox.loaded());
};

console.log('@aweftjs/sandbox: a room for modules the host does not trust');
await scenario('in process (no isolation: the trusted case)', inProcess(), false);
await scenario('a child process under the permission model', child({ limits: { memoryMB: 128 } }), true);

console.log(`\n${checks - failed}/${checks} checks passed`);
if (failed > 0) process.exitCode = 1;