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
+276
View File
@@ -0,0 +1,276 @@
// worker 的 OS 级硬隔离:macOS `sandbox-exec` profile(文件写围栏 + 可选网络隔离)+ `ulimit` 资源上限。
//
// 进程隔离(独立 worker 进程 + env 收敛)已在 orchestrator/worker 落地,本模块再加一层 OS 级硬隔离:
// 1) sandbox-exec:限制 worker 只能【写】其 worktree + 主仓 .git/node_modules + runs/<runId> + transcripts
// + 必要的临时/缓存目录,越界写被内核拒绝(读/exec/网络默认放行,避免误伤 git/npm/go/node)。
// 2) ulimitCPU 时间 / 虚拟内存 / 打开文件数 / 单文件大小上限,防失控 agent 拖垮机器。
//
// 设计取舍(为什么是「写围栏」而非「deny default 全围栏」):
// - 合法的 git/npm/go/node 需要【读】海量系统/缓存/依赖路径(node 二进制、系统库、~/.npm、GOCACHE…),
// deny-default 极易误伤且回归成本高(即任务里写的「难点」)。真正的破坏向量是【写】——改坏系统、
// 污染其它仓库、动 maestro 自己的 DB。故基线 `allow default`,仅对 file-write* 收口后逐条放行。
// - 网络默认放行:Agent SDK 需连 Anthropic;可经 MAESTRO_SANDBOX_NET=off 关闭(离线/本地任务)。
//
// 全部【可选】:默认关闭(MAESTRO_SANDBOX 未开启时 prepareSandboxedSpawn 返回 nullspawn 行为零变化),
// 便于「先在一个项目灰度」。开启后 macOS 套 profile + ulimit;非 macOS 仅套 ulimitprofile 跳过)。
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { mkdirSync, writeFileSync } from 'node:fs';
import { readJobSpec, runDir } from './protocol.js';
/** ulimit 资源上限。每项 0 表示不设(不下发对应 ulimit 行)。 */
export interface ResourceLimits {
/** ulimit -tCPU 时间秒数(累计 CPU,非墙钟)。超限内核发 SIGXCPU 杀进程。 */
cpuSec: number;
/** ulimit -v:虚拟内存地址空间 KB。注意 macOS 对 -v 支持不稳定(常被忽略),Linux 生效。 */
asKB: number;
/** ulimit -n:可打开文件描述符数。 */
nofile: number;
/** ulimit -f:单个文件最大大小 KB(防失控写爆盘)。 */
fsizeKB: number;
}
/** 沙箱总配置(由 env 解析)。 */
export interface SandboxConfig {
/** 总开关(MAESTRO_SANDBOX)。关闭时 prepareSandboxedSpawn 直接返回 null。 */
enabled: boolean;
/** 是否套 sandbox-exec profile(仅 macOS;可经 MAESTRO_SANDBOX_PROFILE=off 单独关,仅留 ulimit)。 */
profile: boolean;
/** 是否放行网络(默认 trueSDK 需连 Anthropic)。 */
allowNetwork: boolean;
limits: ResourceLimits;
/** 额外放行写的绝对路径(MAESTRO_SANDBOX_WRITE_EXTRA,冒号分隔),供个别项目按需放宽。 */
extraWritePaths: string[];
}
/** ulimit -n 默认值(macOS 默认仅 256,agent 起一堆子进程易顶满,给个宽裕但有界的值)。 */
export const DEFAULT_NOFILE = 4096;
const TRUTHY = new Set(['1', 'on', 'true', 'yes', 'enable', 'enabled']);
const FALSY = new Set(['0', 'off', 'false', 'no', 'disable', 'disabled']);
/** env 布尔解析:认得 on/off/true/false/1/0…,无法识别时回退 def。 */
function isOn(v: string | undefined, def: boolean): boolean {
if (v === undefined) return def;
const s = v.trim().toLowerCase();
if (TRUTHY.has(s)) return true;
if (FALSY.has(s)) return false;
return def;
}
/** env 非负整数解析;空/非法/负数 → def。 */
function intEnv(v: string | undefined, def: number): number {
if (v === undefined || v.trim() === '') return def;
const n = Number(v);
return Number.isFinite(n) && Number.isInteger(n) && n >= 0 ? n : def;
}
/** 数据根目录(与 protocol/worktree 同约定):<MAESTRO_DATA_DIR 或 ~/.maestro>。 */
function dataDir(env: NodeJS.ProcessEnv): string {
return env.MAESTRO_DATA_DIR ?? join(homedir(), '.maestro');
}
/** transcripts 目录(与 cc.ts transcriptDir 同约定)。 */
function transcriptsDir(env: NodeJS.ProcessEnv): string {
return join(dataDir(env), 'transcripts');
}
/**
* 从 env 解析沙箱配置。
* - MAESTRO_SANDBOX:总开关(默认关)。
* - MAESTRO_SANDBOX_PROFILE:是否套 sandbox-exec(默认开,仅 macOS 有效)。
* - MAESTRO_SANDBOX_NET:是否放行网络(默认放行)。
* - MAESTRO_SANDBOX_CPUCPU 秒(默认 0=不限;建议灰度时设 1800~3600 拦截死循环)。
* - MAESTRO_SANDBOX_MEM:虚拟内存 MB(默认 0=不限;macOS 可能无效)。
* - MAESTRO_SANDBOX_NOFILE:打开文件数(默认 4096)。
* - MAESTRO_SANDBOX_FSIZE:单文件 MB(默认 0=不限)。
* - MAESTRO_SANDBOX_WRITE_EXTRA:额外放行写的绝对路径(冒号分隔)。
*/
export function readSandboxConfig(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): SandboxConfig {
const enabled = isOn(env.MAESTRO_SANDBOX, false);
const profile = enabled && platform === 'darwin' && isOn(env.MAESTRO_SANDBOX_PROFILE, true);
const allowNetwork = isOn(env.MAESTRO_SANDBOX_NET, true);
const memMB = intEnv(env.MAESTRO_SANDBOX_MEM, 0);
const fsizeMB = intEnv(env.MAESTRO_SANDBOX_FSIZE, 0);
const limits: ResourceLimits = {
cpuSec: intEnv(env.MAESTRO_SANDBOX_CPU, 0),
asKB: memMB * 1024,
nofile: intEnv(env.MAESTRO_SANDBOX_NOFILE, DEFAULT_NOFILE),
fsizeKB: fsizeMB * 1024,
};
const extraWritePaths = (env.MAESTRO_SANDBOX_WRITE_EXTRA ?? '')
.split(':').map((s) => s.trim()).filter(Boolean);
return { enabled, profile, allowNetwork, limits, extraWritePaths };
}
/** 把绝对路径转义进 sandbox profile 的字符串字面量(仅需转义 \ 与 ")。 */
function esc(p: string): string {
return p.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
/** buildSandboxProfile 的输入:worker 必需的写目标(绝对路径)。 */
export interface SandboxProfileInput {
/** worktree 父目录 <base>/<repo>:放行整目录,便于 worker 自建/清理本任务 worktree(含 worktreeDir)。 */
worktreeParent: string;
/** 主仓 .gitgit worktree 共享对象库 / 注册项 / info/exclude / index.lock 等均写这里。 */
repoGitDir: string;
/** 主仓 node_modulesworktree 经软链复用它;放行使任务里的 npm 安装/缓存写不被拒。 */
repoNodeModules: string;
/** runs/<runId>outbox/heartbeat/job/sandbox.sb。 */
runDir: string;
/** transcripts 目录:<runId>.jsonl 转录。 */
transcriptsDir: string;
/** 进程临时目录($TMPDIR)。 */
tmpDir: string;
/** 用户 HOME(推导 ~/.npm、~/Library/Caches、GOCACHE 等缓存)。 */
homeDir: string;
/** 是否放行网络。 */
allowNetwork: boolean;
/** 额外放行写的绝对路径。 */
extraWritePaths: string[];
}
/**
* 生成 macOS sandbox-exec profile 文本(SBPL)。
* 策略:`(allow default)` 基线 → `(deny file-write*)` 收口 → 逐条 `(allow file-write* …)` 放行。
* Apple sandbox 后匹配优先(last-match-wins),故越界写命中 deny 即被拒,放行目标被最后的 allow 覆盖。
*/
export function buildSandboxProfile(i: SandboxProfileInput): string {
const writeSubpaths = [
i.worktreeParent,
i.repoGitDir,
i.repoNodeModules,
i.runDir,
i.transcriptsDir,
i.tmpDir,
'/private/tmp', '/tmp',
'/private/var/folders', '/var/folders',
// 常用工具缓存(npm/go/git/claude)——这些通常已存在于开发机,放行其整目录的写
join(i.homeDir, '.npm'),
join(i.homeDir, '.cache'),
join(i.homeDir, '.config'),
join(i.homeDir, '.cargo'),
join(i.homeDir, 'go'),
join(i.homeDir, 'Library', 'Caches'),
join(i.homeDir, '.claude'),
...i.extraWritePaths,
];
const writeLiterals = [
join(i.homeDir, '.gitconfig'),
join(i.homeDir, '.npmrc'),
'/dev/null', '/dev/zero', '/dev/stdout', '/dev/stderr',
];
const lines: string[] = [];
lines.push('(version 1)');
lines.push(';; maestro worker OS 级沙箱(macOS sandbox-exec)。');
lines.push(';; 基线放行读/exec/网络/machkeychain 鉴权所需),仅对【写】收口后逐条放行。');
lines.push('(allow default)');
lines.push('(deny file-write*)');
lines.push('(allow file-write*');
for (const p of writeSubpaths) lines.push(` (subpath "${esc(p)}")`);
for (const p of writeLiterals) lines.push(` (literal "${esc(p)}")`);
lines.push(' (subpath "/dev/fd")');
lines.push(' (regex #"^/dev/tty"))');
if (!i.allowNetwork) {
lines.push(';; 网络隔离:SDK 将无法连 Anthropic,仅适用于离线/本地任务');
lines.push('(deny network*)');
}
return lines.join('\n') + '\n';
}
/** 由 limits 生成 ulimit 行(0 项跳过)。每行 `|| true` + 吞 stderr:某项不被平台支持(如 macOS 的 -v)不应中断启动。 */
export function ulimitLines(l: ResourceLimits): string[] {
const out: string[] = [];
const add = (flag: string, val: number): void => {
if (val > 0) out.push(`ulimit ${flag} ${val} 2>/dev/null || true`);
};
add('-t', l.cpuSec); // CPU 秒
add('-v', l.asKB); // 虚拟内存 KB
add('-n', l.nofile); // 打开文件数
add('-f', l.fsizeKB); // 单文件 KB
return out;
}
/**
* 把基础命令包成「ulimit + 可选 sandbox-exec」的 `/bin/sh -c` 调用。
* 经 sh 是为了下发 ulimitspawn 本身设不了),末尾 `exec "$@"` 让 sh 替换为目标进程(pid 不变,
* 保证 daemon 记录的 workerPid 即真 worker——sandbox-exec 同样 exec 替换,pid 链一路保留)。
* 目标命令经 `"$@"` 位置参数传入,天然免转义(路径含空格也安全)。
*/
export function wrapCommand(
baseCmd: string[],
opts: { profilePath?: string; limits: ResourceLimits },
): { cmd: string; args: string[] } {
const target = opts.profilePath
? ['sandbox-exec', '-f', opts.profilePath, ...baseCmd]
: [...baseCmd];
const script = [...ulimitLines(opts.limits), 'exec "$@"'].join('\n') + '\n';
// sh -c <script> <$0> <$1...>$0=标签,$@=targetexec "$@" 执行目标
return { cmd: '/bin/sh', args: ['-c', script, 'maestro-worker', ...target] };
}
/**
* 为某个 run 准备沙箱化的 spawn 命令。沙箱关闭 → 返回 null(调用方按原始命令直接 spawn)。
* 开启 → 读 job.json 得 worktree/repo,预建必要父目录(避免沙箱内因父目录不可写而建子目录失败),
* macOS 写 runs/<runId>/sandbox.sb 并套 sandbox-exec,最后叠加 ulimit,返回包装后的 {cmd,args}。
*/
export function prepareSandboxedSpawn(
baseCmd: string[],
runId: string,
env: NodeJS.ProcessEnv = process.env,
): { cmd: string; args: string[] } | null {
const cfg = readSandboxConfig(env);
if (!cfg.enabled) return null;
const job = readJobSpec(runId);
const worktreeDir = job.worktreeDir;
const worktreeParent = dirname(worktreeDir);
const repoGitDir = join(job.project.repoPath, '.git');
const repoNodeModules = join(job.project.repoPath, 'node_modules');
const rd = runDir(runId);
const tDir = transcriptsDir(env);
const home = env.HOME ?? homedir();
const tmp = env.TMPDIR ?? '/tmp';
// 预建父目录:沙箱只放行这些目录【内部】的写,不放行其父级;提前建好,worker 内的
// mkdirSync(recursive) / git worktree add / 写转录就不会因创建中间目录而被拒。
mkdirSync(worktreeParent, { recursive: true });
mkdirSync(tDir, { recursive: true });
mkdirSync(rd, { recursive: true });
let profilePath: string | undefined;
if (cfg.profile) {
const profile = buildSandboxProfile({
worktreeParent,
repoGitDir,
repoNodeModules,
runDir: rd,
transcriptsDir: tDir,
tmpDir: tmp,
homeDir: home,
allowNetwork: cfg.allowNetwork,
extraWritePaths: cfg.extraWritePaths,
});
profilePath = join(rd, 'sandbox.sb');
writeFileSync(profilePath, profile);
}
return wrapCommand(baseCmd, { profilePath, limits: cfg.limits });
}
/** 人读的沙箱状态摘要(daemon 启动时打一行,作为「当前生效策略」的清晰记录)。 */
export function describeSandbox(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): string {
const c = readSandboxConfig(env, platform);
if (!c.enabled) return '沙箱:关闭(MAESTRO_SANDBOX 未开启)';
const lims: string[] = [];
if (c.limits.cpuSec) lims.push(`cpu=${c.limits.cpuSec}s`);
if (c.limits.asKB) lims.push(`mem=${Math.round(c.limits.asKB / 1024)}MB`);
if (c.limits.nofile) lims.push(`nofile=${c.limits.nofile}`);
if (c.limits.fsizeKB) lims.push(`fsize=${Math.round(c.limits.fsizeKB / 1024)}MB`);
const profile = c.profile
? 'sandbox-exec'
: (platform === 'darwin' ? 'profile=off' : `无profile(${platform})`);
const net = c.allowNetwork ? '网络放行' : '网络隔离';
return `沙箱:启用(${profile}${net}ulimit: ${lims.join(' ') || '无'}`;
}