Files
maestro/test/sandbox.test.ts
T
wangjia d0b807460e feat(executor): worker OS 级沙箱 + 资源限额(sandbox-exec / ulimit)[tsk_oTFzOMeAU20D]
进程隔离之上再加一层 OS 级硬隔离,默认关闭,便于先在一个项目灰度:

- 新增 src/executor/sandbox.ts:
  - macOS sandbox-exec 写围栏 profile(allow default 基线 + deny file-write* 收口
    + 逐条放行 worktree/主仓.git/node_modules/runs/transcripts/tmp/工具缓存)。
    不 deny default 全围栏——合法 git/npm/go/node 需读海量系统路径,写才是破坏向量。
  - ulimit 资源上限(CPU 时间/虚拟内存/打开文件数/单文件大小),经 sh -c 下发,
    exec 链保证 pid 不变(daemon 记录的 workerPid 仍是真 worker)。
  - 全部由 env 解析(MAESTRO_SANDBOX*),网络默认放行(SDK 连 Anthropic)。
- orchestrator.defaultSpawnWorker 接入:开启时把命令包成
  sh -c 'ulimit…; exec sandbox-exec -f profile <原命令>';关闭时行为零变化。
  startOrchestrator 启动时打印生效策略,每个 run 的 profile 落 runs/<runId>/sandbox.sb。
- README 增补环境变量与「worker 沙箱」说明。
- 新增 test/sandbox.test.ts(18 例:config 解析 / profile 生成 / ulimit / 包装 / 预建目录)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:08:18 +08:00

243 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
readSandboxConfig,
buildSandboxProfile,
ulimitLines,
wrapCommand,
prepareSandboxedSpawn,
describeSandbox,
DEFAULT_NOFILE,
type SandboxProfileInput,
} from '../src/executor/sandbox.js';
import { writeJobSpec, runDir } from '../src/executor/protocol.js';
import type { JobSpec } from '../src/executor/protocol.js';
import type { Project, Task } from '../src/model/types.js';
// ───────────────────────── readSandboxConfig ─────────────────────────
test('readSandboxConfig:默认关闭,spawn 行为零变化', () => {
const c = readSandboxConfig({}, 'darwin');
assert.equal(c.enabled, false);
assert.equal(c.profile, false);
});
test('readSandboxConfigMAESTRO_SANDBOX 开启 → enabledmacOS 默认套 profile', () => {
const c = readSandboxConfig({ MAESTRO_SANDBOX: 'on' }, 'darwin');
assert.equal(c.enabled, true);
assert.equal(c.profile, true);
assert.equal(c.allowNetwork, true, '默认放行网络(SDK 连 Anthropic');
assert.equal(c.limits.nofile, DEFAULT_NOFILE);
assert.equal(c.limits.cpuSec, 0, '默认不限 CPU');
});
test('readSandboxConfig:非 macOS 不套 profile(仅 ulimit', () => {
const c = readSandboxConfig({ MAESTRO_SANDBOX: '1' }, 'linux');
assert.equal(c.enabled, true);
assert.equal(c.profile, false);
});
test('readSandboxConfigMAESTRO_SANDBOX_PROFILE=off 单独关 profile,保留 ulimit', () => {
const c = readSandboxConfig({ MAESTRO_SANDBOX: '1', MAESTRO_SANDBOX_PROFILE: 'off' }, 'darwin');
assert.equal(c.enabled, true);
assert.equal(c.profile, false);
});
test('readSandboxConfig:资源上限从 env 解析(MB→KB),网络可关', () => {
const c = readSandboxConfig({
MAESTRO_SANDBOX: 'true',
MAESTRO_SANDBOX_CPU: '1800',
MAESTRO_SANDBOX_MEM: '2048',
MAESTRO_SANDBOX_NOFILE: '1024',
MAESTRO_SANDBOX_FSIZE: '512',
MAESTRO_SANDBOX_NET: 'off',
}, 'darwin');
assert.equal(c.limits.cpuSec, 1800);
assert.equal(c.limits.asKB, 2048 * 1024);
assert.equal(c.limits.nofile, 1024);
assert.equal(c.limits.fsizeKB, 512 * 1024);
assert.equal(c.allowNetwork, false);
});
test('readSandboxConfig:非法/负数限额回退默认', () => {
const c = readSandboxConfig({
MAESTRO_SANDBOX: 'on', MAESTRO_SANDBOX_CPU: 'abc', MAESTRO_SANDBOX_NOFILE: '-5',
}, 'darwin');
assert.equal(c.limits.cpuSec, 0);
assert.equal(c.limits.nofile, DEFAULT_NOFILE);
});
test('readSandboxConfig:额外写路径按冒号分隔', () => {
const c = readSandboxConfig({ MAESTRO_SANDBOX: 'on', MAESTRO_SANDBOX_WRITE_EXTRA: '/a/b: /c/d :' }, 'darwin');
assert.deepEqual(c.extraWritePaths, ['/a/b', '/c/d']);
});
// ───────────────────────── buildSandboxProfile ─────────────────────────
function profileInput(over: Partial<SandboxProfileInput> = {}): SandboxProfileInput {
return {
worktreeParent: '/data/worktrees/repo',
repoGitDir: '/proj/repo/.git',
repoNodeModules: '/proj/repo/node_modules',
runDir: '/data/runs/run1',
transcriptsDir: '/data/transcripts',
tmpDir: '/tmp/x',
homeDir: '/Users/u',
allowNetwork: true,
extraWritePaths: [],
...over,
};
}
test('buildSandboxProfileallow default + 写收口 + 逐条放行', () => {
const p = buildSandboxProfile(profileInput());
assert.match(p, /^\(version 1\)/m);
assert.match(p, /\(allow default\)/);
assert.match(p, /\(deny file-write\*\)/);
// 关键写目标都在
assert.match(p, /\(subpath "\/data\/worktrees\/repo"\)/);
assert.match(p, /\(subpath "\/proj\/repo\/\.git"\)/);
assert.match(p, /\(subpath "\/proj\/repo\/node_modules"\)/);
assert.match(p, /\(subpath "\/data\/runs\/run1"\)/);
assert.match(p, /\(subpath "\/data\/transcripts"\)/);
assert.match(p, /\(subpath "\/tmp\/x"\)/);
// home 缓存
assert.match(p, /\(subpath "\/Users\/u\/\.npm"\)/);
assert.match(p, /\(subpath "\/Users\/u\/Library\/Caches"\)/);
});
test('buildSandboxProfiledeny file-write* 出现在第一条 allow file-write* 之前(后匹配优先才正确)', () => {
const p = buildSandboxProfile(profileInput());
assert.ok(p.indexOf('(deny file-write*)') < p.indexOf('(allow file-write*'), 'deny 须在放行之前');
});
test('buildSandboxProfile:默认放行网络(无 deny network*);allowNetwork=false 时隔离', () => {
assert.doesNotMatch(buildSandboxProfile(profileInput({ allowNetwork: true })), /deny network/);
assert.match(buildSandboxProfile(profileInput({ allowNetwork: false })), /\(deny network\*\)/);
});
test('buildSandboxProfile:额外写路径被纳入;含特殊字符的路径被转义', () => {
const p = buildSandboxProfile(profileInput({ extraWritePaths: ['/extra/one'], homeDir: '/U/a"b\\c' }));
assert.match(p, /\(subpath "\/extra\/one"\)/);
assert.match(p, /\\"b\\\\c/); // " → \" 和 \ → \\
});
// ───────────────────────── ulimitLines ─────────────────────────
test('ulimitLines0 项跳过,非 0 项下发且容错(|| true', () => {
const lines = ulimitLines({ cpuSec: 1800, asKB: 0, nofile: 4096, fsizeKB: 0 });
assert.equal(lines.length, 2);
assert.match(lines[0], /^ulimit -t 1800 2>\/dev\/null \|\| true$/);
assert.match(lines[1], /^ulimit -n 4096 2>\/dev\/null \|\| true$/);
});
test('ulimitLines:全 0 → 空', () => {
assert.deepEqual(ulimitLines({ cpuSec: 0, asKB: 0, nofile: 0, fsizeKB: 0 }), []);
});
// ───────────────────────── wrapCommand ─────────────────────────
test('wrapCommand:套 profile → sh -c,含 sandbox-exec 与 ulimit 与 exec "$@"', () => {
const { cmd, args } = wrapCommand(['node', '/w/worker.js', 'run1'], {
profilePath: '/data/runs/run1/sandbox.sb',
limits: { cpuSec: 600, asKB: 0, nofile: 4096, fsizeKB: 0 },
});
assert.equal(cmd, '/bin/sh');
assert.equal(args[0], '-c');
const script = args[1];
assert.match(script, /ulimit -t 600/);
assert.match(script, /ulimit -n 4096/);
assert.match(script, /exec "\$@"\n$/);
// 位置参数:$0 标签 + 目标命令
assert.deepEqual(args.slice(2), [
'maestro-worker', 'sandbox-exec', '-f', '/data/runs/run1/sandbox.sb', 'node', '/w/worker.js', 'run1',
]);
});
test('wrapCommand:无 profile(非 macOS)→ 仅 ulimit + 原命令', () => {
const { cmd, args } = wrapCommand(['node', '/w/worker.js', 'run1'], {
profilePath: undefined,
limits: { cpuSec: 0, asKB: 0, nofile: 4096, fsizeKB: 0 },
});
assert.equal(cmd, '/bin/sh');
assert.deepEqual(args.slice(2), ['maestro-worker', 'node', '/w/worker.js', 'run1']);
assert.doesNotMatch(args[1], /sandbox-exec/);
});
// ───────────────────────── prepareSandboxedSpawn ─────────────────────────
function fakeJob(runId: string, dataDir: string, repoPath: string): JobSpec {
const task = { id: 'tsk_x', title: 't', status: 'executing' } as unknown as Task;
const project = { id: 'prj_x', repoPath, defaultBranch: 'main' } as unknown as Project;
return {
runId,
task,
project,
worktreeDir: join(dataDir, 'worktrees', 'repo', 'tsk_x'),
branch: 'maestro/tsk_x',
};
}
test('prepareSandboxedSpawn:关闭 → null(原命令直接 spawn', () => {
const out = prepareSandboxedSpawn(['node', 'w.js', 'r'], 'r', {});
assert.equal(out, null);
});
test('prepareSandboxedSpawn:开启 → 读 job、预建父目录、(macOS)写 profile 并包命令', () => {
const dataDir = mkdtempSync(join(tmpdir(), 'maestro-sbx-'));
try {
const env: NodeJS.ProcessEnv = {
MAESTRO_SANDBOX: 'on',
MAESTRO_DATA_DIR: dataDir,
HOME: dataDir,
TMPDIR: join(dataDir, 'tmp'),
};
const runId = 'run_sbx';
const job = fakeJob(runId, dataDir, join(dataDir, 'repo'));
// writeJobSpec / runDir 走 MAESTRO_DATA_DIR
const prev = process.env.MAESTRO_DATA_DIR;
process.env.MAESTRO_DATA_DIR = dataDir;
try {
writeJobSpec(job);
const out = prepareSandboxedSpawn(['node', 'w.js', runId], runId, env);
assert.ok(out);
assert.equal(out!.cmd, '/bin/sh');
// 预建的父目录
assert.ok(existsSync(join(dataDir, 'worktrees', 'repo')), '预建 worktree 父目录');
assert.ok(existsSync(join(dataDir, 'transcripts')), '预建 transcripts 目录');
const onDarwin = process.platform === 'darwin';
const profilePath = join(runDir(runId), 'sandbox.sb');
if (onDarwin) {
assert.ok(existsSync(profilePath), 'macOS 应落 sandbox.sb');
const prof = readFileSync(profilePath, 'utf8');
assert.match(prof, /\(deny file-write\*\)/);
// 放行的是 worktree 父目录(含本任务 worktree),以及 runs/<runId>
assert.ok(prof.includes(`(subpath "${join(dataDir, 'worktrees', 'repo')}")`), 'profile 含 worktree 父目录');
assert.ok(prof.includes(`(subpath "${runDir(runId)}")`), 'profile 含 runs/<runId>');
assert.ok(out!.args.includes('sandbox-exec'));
} else {
assert.ok(!out!.args.includes('sandbox-exec'), '非 macOS 不套 profile');
}
} finally {
if (prev === undefined) delete process.env.MAESTRO_DATA_DIR; else process.env.MAESTRO_DATA_DIR = prev;
}
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
});
// ───────────────────────── describeSandbox ─────────────────────────
test('describeSandbox:关闭/开启的人读摘要', () => {
assert.match(describeSandbox({}, 'darwin'), /关闭/);
const s = describeSandbox({ MAESTRO_SANDBOX: 'on', MAESTRO_SANDBOX_CPU: '1800' }, 'darwin');
assert.match(s, /启用/);
assert.match(s, /sandbox-exec/);
assert.match(s, /cpu=1800s/);
assert.match(s, /nofile=4096/);
});