/** * 按**档位**决定 worker 怎么起:直接 node,还是先套一层 `am-sandbox`(Landlock)。 * * # 这一步补的是「工作区档」缺的那一维 * * 档位表写的是「本目录内可动、越界要问人」,而桥原先只能按**工具名**判 * (bash/write/edit 一律问人)—— 因为命令的影响范围无法从文本静态判定。沙箱把 * 「界内/界外」交给内核去判,于是这条档位第一次是真的: * * plan / workspace 档 → worker 跑在 `am-sandbox --rw <会话工作区> …` 里面, * 界外写是 EACCES(不依赖模型配合、也不依赖人点得准) * full 档 → **不套**(发件人已声明全权,与档位表一致) * * # 为什么 rw 里既有会话工作区、又有几个固定目录 * * 沙箱只放行「写」,而且必须把**跑起来真正需要的写点**列全,否则 worker 自己会崩: * * - 会话工作区:agent 该动手的地方 * - 临时目录(`os.tmpdir()`):构建/工具链的日常写点(dsh 也是这么放的) * - `/dev/null`:**任何 `cmd 2>/dev/null` 都是一次界外写** —— 实测漏了它会让 * 一整代命令集体 Permission denied。用 `--rw-file`(只放行写这个设备, * 不放行在 /dev 里建/删任何东西) * - pi 自己的会话登记(`/sessions`):不写它,回合结束保存会话就失败 * - 桥自己的配置目录(`AGENTMAIL_CONFIG_DIR`,默认 `~/.agentmail`): * worker 会往里面写 `explicit-sends.jsonl` * * 这几条是**实测得出**的写点清单,不是想当然:少一条的症状是 worker 回合中途报 * EACCES/EROFS,而不是"沙箱没生效"。 * * # 拿不到工作区就不套 * * `--rw` 必须给真实目录,`am-sandbox` 对不存在的路径直接 fail closed(退出码 126) * —— 那会让这条会话连 worker 都起不来。所以这里取不到 cwd 时**不猜**:退回不套沙箱, * 由调用方把原因打进日志(不许静默)。 */ import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; /** 边界工具的默认位置(由 redeploy-gateway.sh / install.sh 安装)。 */ export const DEFAULT_SANDBOX_BIN = '/opt/agentmail/bin/am-sandbox'; /** * 档位 × 有没有沙箱 → 被守卫的工具(bash/write/edit)该怎么办。 * * | 档位 | 沙箱 | 决定 | * |---|---|---| * | full | 任意 | `allow`(发件人已声明全权) | * | plan | 任意 | `block`(本档只许看;沙箱是第二层,但先把话说清楚更快) | * | workspace | **在** | `allow` —— **这是这一步改的东西** | * | workspace | 不在 | `ask`(回退到原来的逐条问人) | * * 为什么 workspace 档在有沙箱时可以不问:档位的语义是「本目录内可动,越界要问人」, * 而**内核已经把"越界"变成了 EACCES** —— 再问一遍只是让人点一次"同意",点完 * 该失败的还是失败(人点了同意也挡不住内核)。真正的语义由 am-sandbox 承担: * 界内:不问(内核保证出不去) * 界外:不用问,直接失败,模型从错误里知道"要越界得走 full 档或请人执行" * 而**没有沙箱时必须继续问** —— 那才是原来唯一的一层。 */ export function guardDecision({ mode, sandboxed, toolName, guarded }) { if (String(mode) === 'full') return 'allow'; if (!guarded) return 'pass'; if (String(mode) === 'plan') return 'block'; return sandboxed ? 'allow' : 'ask'; } /** * 自证:标记说"套了沙箱",但真的套上了吗? * * 为什么不只信 `AGENTMAIL_PI_SANDBOXED`:那只是父进程设的一个环境变量。父进程 * 判断错(或有人手改 env)会让闸门以为"内核兜住了"而放行 —— 那是最坏的一类错 * (既不问、也不拦)。所以现场真写一个**一定在界外**的金丝雀文件: * 写得进去 ⇒ 没有沙箱(退回逐条问人,方向取严) * 被拒 ⇒ 有沙箱(EACCES/EROFS/EPERM 都算) * * 金丝雀路径取 `/`:会话工作区与 `os.tmpdir()` 都在 rw 里,只有根目录永远不在。 */ export function verifySandboxActive({ canaryPath = `/.agentmail-sandbox-canary-${process.pid}`, writeFile, unlink, } = {}) { try { writeFile(canaryPath, 'x'); } catch (e) { const code = e?.code || ''; return { active: true, reason: `界外写入被拒(${code || e?.message || 'error'})` }; } try { unlink(canaryPath); } catch { /* 尽力清理,失败不影响结论 */ } return { active: false, reason: '**能写界外** —— 沙箱没生效(退回逐条问人)' }; } /** * 取沙箱二进制路径;返回空串表示"不套"。 * * `AGENTMAIL_PI_SANDBOX` 显式给出时以它为准(`""` / `0` / `off` = 明确关掉); * 没给时看默认位置在不在。**显式给了却不存在的路径**也算关掉,但要由调用方出声 * —— 静默降级成"裸跑"正是这套东西最不该有的行为。 */ export function sandboxBin(env = process.env, exists = existsSync) { const raw = env.AGENTMAIL_PI_SANDBOX; if (raw !== undefined) { const v = String(raw).trim(); if (v === '' || v === '0' || v.toLowerCase() === 'off') return ''; return exists(v) ? v : ''; } return exists(DEFAULT_SANDBOX_BIN) ? DEFAULT_SANDBOX_BIN : ''; } /** * 沙箱要放行的写点。返回 `{ dirs, files }`。 * * 只保留**存在**的目录/文件:`am-sandbox` 对不存在的 rw 路径 fail closed, * 而这里宁可少放行也不要让 worker 起不来(少放行的症状是可诊断的 EACCES)。 */ export function sandboxWritePaths({ cwd, env = process.env, exists = existsSync, tmp = undefined, home = undefined, }) { const dirs = []; const files = []; const pushDir = (d) => { if (d && exists(d) && !dirs.includes(d)) dirs.push(d); }; pushDir(cwd); pushDir(tmp ?? (env.TMPDIR || '/tmp')); const agentDir = env.PI_CODING_AGENT_DIR || join(home ?? homedir(), '.pi', 'agent'); pushDir(join(agentDir, 'sessions')); pushDir(env.AGENTMAIL_CONFIG_DIR || join(home ?? homedir(), '.agentmail')); const devNull = '/dev/null'; if (exists(devNull)) files.push(devNull); return { dirs, files }; } /** * 决定这次的 worker 启动方式。 * * @param {object} o * @param {number|string} [o.mode] 会话档位(plan / workspace / full) * @param {string} [o.cwd] 会话工作区(由调用方用与 worker 同一个函数解析) * @param {string} o.nodePath node 可执行文件 * @param {string} o.workerPath worker 脚本 * @returns {{cmd: string, argv: string[], sandboxed: boolean, reason: string}} */ export function workerLaunch({ mode = 'workspace', cwd = '', nodePath, workerPath, env = process.env, exists = existsSync, }) { const direct = (reason) => ({ cmd: nodePath, argv: [workerPath], sandboxed: false, reason }); if (String(mode) === 'full') return direct('full 档:发件人已声明全权'); const bin = sandboxBin(env, exists); if (!bin) return direct('没有可用的 am-sandbox(未安装 / 被显式关掉 / 路径不存在)'); if (!cwd || !exists(cwd)) return direct(`拿不到会话工作区(cwd=${cwd || '空'})—— 不猜`); // plan 档 = "一个字都不许写":连会话工作区都不给写权限(沙箱是第二层, // 第一层是 worker 里那道 block)。workspace 档才把工作区放进 rw。 const { dirs, files } = sandboxWritePaths({ cwd: String(mode) === 'plan' ? '' : cwd, env, exists, }); const argv = []; for (const d of dirs) argv.push('--rw', d); for (const f of files) argv.push('--rw-file', f); argv.push('--', nodePath, workerPath); return { cmd: bin, argv, sandboxed: true, rw: dirs, reason: `rw=${dirs.join(',')}` }; }