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:
@@ -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) ulimit:CPU 时间 / 虚拟内存 / 打开文件数 / 单文件大小上限,防失控 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 返回 null,spawn 行为零变化),
|
||||
// 便于「先在一个项目灰度」。开启后 macOS 套 profile + ulimit;非 macOS 仅套 ulimit(profile 跳过)。
|
||||
|
||||
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 -t:CPU 时间秒数(累计 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;
|
||||
/** 是否放行网络(默认 true;SDK 需连 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_CPU:CPU 秒(默认 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;
|
||||
/** 主仓 .git:git worktree 共享对象库 / 注册项 / info/exclude / index.lock 等均写这里。 */
|
||||
repoGitDir: string;
|
||||
/** 主仓 node_modules:worktree 经软链复用它;放行使任务里的 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/网络/mach(keychain 鉴权所需),仅对【写】收口后逐条放行。');
|
||||
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 是为了下发 ulimit(spawn 本身设不了),末尾 `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=标签,$@=target,exec "$@" 执行目标
|
||||
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(' ') || '无'})`;
|
||||
}
|
||||
Reference in New Issue
Block a user