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>
This commit is contained in:
wangjia
2026-06-13 15:08:18 +08:00
parent f020e15137
commit d0b807460e
4 changed files with 546 additions and 2 deletions
+242
View File
@@ -0,0 +1,242 @@
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/);
});