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
+18
View File
@@ -102,3 +102,21 @@ exec_review accept 时自动 `--no-ff` 合并:若 defaultBranch 在主检出
| `MAESTRO_MODEL_REVIEW_EASY` | reviewer Easy 模型覆盖 | `claude-sonnet-4-6` |
| `MAESTRO_MODEL_REVIEW_MEDIUM` | reviewer Medium 模型覆盖 | `claude-opus-4-8` |
| `MAESTRO_MODEL_REVIEW_HARD` | reviewer Hard 模型覆盖 | `claude-opus-4-8` |
| `MAESTRO_SANDBOX` | worker OS 级沙箱总开关(`on`/`off` | 关闭 |
| `MAESTRO_SANDBOX_PROFILE` | 是否套 `sandbox-exec`(仅 macOS`off` 则仅留 ulimit | 开启 |
| `MAESTRO_SANDBOX_NET` | 是否放行网络(SDK 需连 Anthropic`off` 隔离) | 放行 |
| `MAESTRO_SANDBOX_CPU` | CPU 时间上限(秒),`0`=不限 | `0` |
| `MAESTRO_SANDBOX_MEM` | 虚拟内存上限(MB),`0`=不限(macOS 对 `-v` 支持不稳定) | `0` |
| `MAESTRO_SANDBOX_NOFILE` | 打开文件描述符上限 | `4096` |
| `MAESTRO_SANDBOX_FSIZE` | 单文件大小上限(MB),`0`=不限 | `0` |
| `MAESTRO_SANDBOX_WRITE_EXTRA` | 额外放行写的绝对路径(冒号分隔) | 空 |
## worker 沙箱(OS 级硬隔离,可选)
进程隔离(独立 worker 进程 + env 收敛,密钥/DB/API 与 daemon 隔开)之上,再加一层 OS 级硬隔离,**默认关闭**,开启后对每个 worker 生效:
- **文件写围栏**macOS `sandbox-exec`):基线 `allow default`(放行读 / exec / 网络 / mach——keychain 鉴权所需),仅对**写**收口,逐条放行 worker 必需的写目标:任务 worktree、主仓 `.git`/`node_modules``runs/<runId>``transcripts``$TMPDIR` 与常用工具缓存(`~/.npm``~/Library/Caches``GOCACHE` 等)。越界写(改系统、污染其它仓库、动 maestro 自身 DB)被内核拒绝。为什么不 `deny default` 全围栏:合法的 git/npm/go/node 需读海量系统/缓存路径,全围栏极易误伤——真正的破坏向量是「写」。
- **资源上限**`ulimit`):CPU 时间 / 虚拟内存 / 打开文件数 / 单文件大小,防失控 agent 拖垮机器;超限由内核杀进程,daemon 的 reaper 据此回收并重试/转 `needs_attention`
- 生效的策略会在 daemon 启动日志打一行;每个 run 的实际 profile 落在 `runs/<runId>/sandbox.sb`(清晰记录)。
**先在一个项目灰度**:开 `MAESTRO_SANDBOX=on` 后跑一轮含 `npm/go/git` 的真实任务,确认能跑通、越界写被拒、超限被杀且日志清晰,再逐步铺开。个别项目若有特殊写路径,用 `MAESTRO_SANDBOX_WRITE_EXTRA` 放行。
+10 -2
View File
@@ -7,6 +7,7 @@ import { rankByScore } from '../model/scoring.js';
import { branchFor, worktreeDirFor } from '../executor/worktree.js';
import { writeJobSpec, isWorkerAlive, heartbeatAgeMs, type JobSpec } from '../executor/protocol.js';
import { pickModel } from '../executor/models.js';
import { prepareSandboxedSpawn, describeSandbox } from '../executor/sandbox.js';
import { ingestAll, type IngestLogger } from './ingest.js';
/** 失败后默认最多自动重试次数(项目级 maxRetries 未设置时回退此值)。失败/退避策略本体在 store.failTaskAttempt。 */
@@ -56,10 +57,16 @@ export function workerEnv(src: NodeJS.ProcessEnv = process.env): NodeJS.ProcessE
* daemon 的鉴权上下文(本机无 API_KEY/凭证文件,鉴权走 macOS keychaindetached 后新会话读不到 → Not logged in)。
* 存活性不依赖 detachedunref 后 daemon 退出,worker 作为孤儿被 init/launchd 收养、继续运行(daemon 无控制终端,
* 不会有进程组 SIGHUP;重启用单 pid kill 不波及 worker)。
*
* OS 级沙箱(可选,默认关):MAESTRO_SANDBOX 开启时,prepareSandboxedSpawn 把命令包成
* `sh -c 'ulimit…; exec sandbox-exec -f profile <原命令>'`——文件写围栏 + 资源上限。
* 末尾 exec 链保证 pid 不变(仍是真 worker 的 pid),unref/同会话/keychain 等约束不受影响。
*/
function defaultSpawnWorker(runId: string): number {
const [cmd, ...args] = workerEntry();
const child = spawn(cmd, [...args, runId], {
const base = [...workerEntry(), runId];
const wrapped = prepareSandboxedSpawn(base, runId); // 沙箱关闭 → null,按原命令直接 spawn
const [cmd, ...args] = wrapped ? [wrapped.cmd, ...wrapped.args] : base;
const child = spawn(cmd, args, {
detached: false,
stdio: 'ignore',
env: workerEnv(),
@@ -242,5 +249,6 @@ export function startOrchestrator(
const timer = setInterval(() => orch.tick(), intervalSec * 1000);
timer.unref();
app.log.info(`编排器已启用:每 ${intervalSec}s 一轮(ingest→回收→领取,autonomy≠manual 的 active 项目)`);
app.log.info(describeSandbox()); // 记录当前生效的沙箱/资源上限策略
return timer;
}
+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(' ') || '无'}`;
}
+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/);
});