diff --git a/README.md b/README.md index f573b69..82d7a8c 100644 --- a/README.md +++ b/README.md @@ -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/`、`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//sandbox.sb`(清晰记录)。 + +**先在一个项目灰度**:开 `MAESTRO_SANDBOX=on` 后跑一轮含 `npm/go/git` 的真实任务,确认能跑通、越界写被拒、超限被杀且日志清晰,再逐步铺开。个别项目若有特殊写路径,用 `MAESTRO_SANDBOX_WRITE_EXTRA` 放行。 diff --git a/src/daemon/orchestrator.ts b/src/daemon/orchestrator.ts index 7f36c33..866b8b6 100644 --- a/src/daemon/orchestrator.ts +++ b/src/daemon/orchestrator.ts @@ -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 keychain;detached 后新会话读不到 → Not logged in)。 * 存活性不依赖 detached:unref 后 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; } diff --git a/src/executor/sandbox.ts b/src/executor/sandbox.ts new file mode 100644 index 0000000..ace5644 --- /dev/null +++ b/src/executor/sandbox.ts @@ -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/ + 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 同约定):。 */ +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 父目录 /:放行整目录,便于 worker 自建/清理本任务 worktree(含 worktreeDir)。 */ + worktreeParent: string; + /** 主仓 .git:git worktree 共享对象库 / 注册项 / info/exclude / index.lock 等均写这里。 */ + repoGitDir: string; + /** 主仓 node_modules:worktree 经软链复用它;放行使任务里的 npm 安装/缓存写不被拒。 */ + repoNodeModules: string; + /** runs/:outbox/heartbeat/job/sandbox.sb。 */ + runDir: string; + /** transcripts 目录:.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