d0b807460e
进程隔离之上再加一层 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>
243 lines
10 KiB
TypeScript
243 lines
10 KiB
TypeScript
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('readSandboxConfig:MAESTRO_SANDBOX 开启 → enabled;macOS 默认套 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('readSandboxConfig:MAESTRO_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('buildSandboxProfile:allow 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('buildSandboxProfile:deny 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('ulimitLines:0 项跳过,非 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/);
|
||
});
|