KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

06 · 沙箱三后端、模型 provider 与 subagent 托管 — keel 龙骨

前五章讲的是「dsh 自己怎么跑」。这一章讲边界:它把什么交给操作系统、把什么交给别的 CLI、把什么交给上游 SDK。三件事各有各的形状——沙箱是一个必须 fail-closed 的能力接缝(capability seam),模型 provider 是一张只做「拒绝或翻译」的路由表,subagent 则是一套把 Claude Code、Codex 这种外部 agent 当作可寻址 provider 来托管(hosting)的注册表。

前五章讲的是「dsh 自己怎么跑」。这一章讲边界:它把什么交给操作系统、把什么交给别的 CLI、把什么交给上游 SDK。三件事各有各的形状——沙箱是一个必须 fail-closed 的能力接缝(capability seam),模型 provider 是一张只做「拒绝或翻译」的路由表,subagent 则是一套把 Claude Code、Codex 这种外部 agent 当作可寻址 provider 来托管(hosting)的注册表。

共同点只有一个:它们都在系统边缘,所以都不允许「静默降级」。没有沙箱就拒绝执行,没有协议就报错,没有 provider 就注册失败。

一、沙箱是一个 Service,不是一堆 if (process.platform)

packages/sandbox/sandbox/src/index.ts 是纯契约包(无实现):抽象类 SandboxProvider extends Service,构造时注册为 ctx.sandbox,只有一个抽象方法:

abstract confine(
  argv: readonly string[],
  policy: SandboxPolicy,
  signal?: AbortSignal,
): Promise<ConfinedArgv>

三个设计决定写在类型里:

模式词汇只有三档:SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access';ConfinedSandboxMode 去掉最后一个,表示「真正要去找后端的那些」。网络与进程可见性不在这个词汇表里(注释明说),这是文件效果(file-effect)沙箱,不是容器。

mode 的真相存在会话日志里:packages/sandbox/sandbox-policy/src/session-mode.ts: setSandboxMode 只做一件事——session.append('sandbox/mode', { mode })。事件是 log-only 的(与 approval/* 同例),不携带 surfaceOp,因此可重放、进不进模型转录互不干扰。生效规则是 effective = 投影状态 ?? 部署默认,部署默认是 read-only(fail-safe)。

二、平台链与 fail-closed

packages/sandbox/sandbox-local/src/index.ts 是唯一实现。选择逻辑只有一张表:

const PLATFORM_CHAINS = {
  linux: ['bwrap', 'landlock'],
  darwin: ['seatbelt'],
  win32: ['windows-acl'],
}

chainVerdict() 的规则是先按平台选、再探测:

STATIC_ENFORCEMENT 里唯一一个 partial 是 windows-acl,理由写在注释里且很硬:NTFS 硬链接会把一个已授权文件别名到工作区外的路径、读操作不受限、被别的 AppContainer 工具 ACL 过的目录对低完整性子进程不可读。于是后端只声明它真正能管的表面,绝不宣传绝对承诺。

探测函数各自映射到一个真实可执行体:bwrap 跑一次只读 profile 的 true;Seatbelt 用 sandbox-exec -p 应用真实只读 profile(Apple 已标 deprecated,注释直言「这个探测就是它消失时 fail-closed 的那道闸」);Landlock 走启动器自带的 --probe。probeTimeoutMs 默认 5000,且被 assertPositiveFinite 校验——Node 把 spawnSync({ timeout: 0 }) 当无超时,未校验的 0 会静默变成「无界」,正好是字段承诺的反面。

三个 profile 生成器在 profiles.ts,各自说自己的方言:

// bwrap
['--ro-bind', '/', '/', '--dev', '/dev', '--unshare-pid', '--proc', '/proc', '--die-with-parent']
// workspace-write 追加
['--tmpfs', '/tmp', '--bind', ws, ws]

// landlock:readOnly ['/'],readWrite 基线 ['/dev/null']
// seatbelt:'(version 1) (allow default) (deny file-write*)'
//           + (literal "/dev/null") + 每个 writableRoots 的 (subpath ...)

Seatbelt 的可写根来自共享助手 packages/sandbox/sandbox/src/roots.ts: writableRoots(workspaceRoot + /tmp + tmpdir(),规范化去重)。注释点明这样做的原因:让 Seatbelt 授权与进程内 fs 围栏(@deepseek-ai/dsh-fs-sandbox)不可能漂移。

Linux 的 Landlock 后端不是一个 JS 包装,而是一个 C 启动器 native/system/packages/entry/src/main.c(self-restrict-then-exec)。它直接 syscall 444/445/446,--ro 只授予读侧权限、--rw 授予全部文件访问;LAUNCHER_BIN = 'landlock-run'、LAUNCHER_FAILURE_EXIT = 125。probe() 靠 stderr 里的 /partially enforced/ 区分 full 与 partial——旧 ABI 内核对某些访问类型无能为力,这正是「完整性」这个维度存在的意义。

三、拒绝方言归一与 runner 失败识别

一个被沙箱拒绝的命令和一个根本没跑起来的命令,在调用者眼里是两种完全不同的结果:前者说明约束生效了,后者说明约束失效了。区分二者靠的是 ConfinedArgv 里的两个数组。

拒绝方言(denial dialect):DENIAL_SIGNATURES 给每个后端列它自己内核会说的话——bwrap 是 read-only file system(EROFS)、Landlock 是 permission denied、Seatbelt 是 operation not permitted、windows-acl 是 access is denied / access to the path / permission denied / operation not permitted。字段注释说得很直白:消费者必须匹配本后端的方言,而不是跨后端的并集——并集会宣称某些后端根本不会产生的拒绝。

runner 失败规则:RUNNER_FAILURE_RULES 是结构化的 RunnerFailureRule[],判定顺序被类型注释钉死——先按 allowedExitCodes 门控,再按 informationalLines 整行精确剔除,最后才在剩余行里做大小写不敏感的子串匹配。关键在退出码门:Landlock 只在 exit 125 匹配、windows-acl 只在 exit 127 匹配(WINDOWS_ACL_RUNNER_FAILURE_EXIT = 127)。理由写得很细:如果只按签名匹配,一个恰好打印了这行文字的受限命令、或一次发生在非零子退出上的 runner 清理失败,都会被误判成「命令没跑」。bwrap 与 seatbelt 因没有保留退出码契约,只做签名匹配(bwrap: 、sandbox-exec: )。

Windows 的写权限走另一套:packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts 从规范化工作区路径派生确定性 SID。

// sha256(workspaceRoot) 取前两个 uint32,各缩到 30 位后 +1
return `S-1-4-${first}-${second}`
// temp 版本:哈希输入前置 'temp\0',并追加一个固定域分隔段
return `S-1-4-${first}-${second}-1`

于是工作区级 ACE 每个工作区只物化一次并且长存(它就是跨会话复用缓存——精确 ACE 跳过让后续 provision 是 O(1),而不是重新传播整棵树);而每个存活会话拿到一个随机私有 temp 目录和它自己的 tempWriteSid,会话的 temp ACE 在 provider dispose 时撤销。materializeAclGrant 是 fail-closed 的:半物化的 temp 授权会被撤销、目录会被删除,再抛错。

四、升级阶梯:模型只能「申请」,不能自己放宽

packages/sandbox/sandbox/src/escalation.ts 是两个 enforcing 家族(bash 与 fs)共用的升级(escalation)词汇与编排。核心是一张单向阶梯:

export const WIDER_MODES = {
  'read-only': ['workspace-write', 'danger-full-access'],
  'workspace-write': ['danger-full-access'],
}
export const ESCALATION_TARGETS = ['workspace-write', 'danger-full-access']

ESCALATION_TARGETS 是schema 里的枚举(registry-global),而 WIDER_MODES 是执行期检查(per-call truth)。注释解释了为什么不能只留一个:schema 是全局的,而「当前这次调用的有效模式」是逐调用的事实。如果按组件的默认模式裁剪枚举,一个有效模式低于默认的会话就会「被约束着、却没有可用的杠杆」。

approveEscalation 是一条有序的 fail-closed 序列:请求的目标模式等于当前有效模式 → 直接返回(无需审批);不是严格更宽 → 抛错;没有审批服务、或调用没有 agent 可路由 → 抛错;走审批后只有 allowed-once 才返回,rejected / cancelled / unavailable 各自抛不同文本。它依赖的是结构化的审批函数形态(EscalationApprover),而非审批服务类型,因此这个包不必依赖 approval 或 agent 包。

模型侧看到的两种标记也在这里统一:[sandbox: file access denied under <mode> mode] 与一行升级提示(retry this exact <subject> once with sandbox_permissions … + justification)。提示直接挂在拒绝点上,注释说明理由:不让受认可的这次重试依赖于「模型是否还记得工具描述」。

五、模型 provider:一张只负责「拒绝或翻译」的表

packages/llm/llm-pi-ai 把配置里的路由翻译成 pi-ai 的 provider。关键判断只有一句:catalog 里有、且 profile 没覆盖协议的路由,直接复用 catalog provider(把 models 换掉);其余路由才用 createProvider 从协议表构建。

复用的理由不是省事,而是正确性:catalog provider 持有本包无法重建的 API 实现(注释点名 Bedrock 通过独立入口加载它的 Smithy 模块),从部件重建会静默收窄「哪些 provider 能用」。

协议表 PROTOCOLS 本身就是刻意的窄,只有三个条目:openai-completions、openai-responses、anthropic-messages。只有「一个 key、一个 endpoint、加几个 header 就能完整描述」的协议才在里面。Bedrock(SigV4 + 区域)、Vertex(项目 + 位置 + ADC)、Azure(provider 环境 + api-version)、Codex(OAuth)都不在——不是因为阻塞,而是因为这种配置形态表达不了它们的认证,放进来只会给回一个无法认证的 provider。catalog 路由仍可通过它自己的 provider 到达每一种协议,只有显式覆盖才被拒绝(PiAiCatalogError)。

认证有一条常被忽略的细节:routeAuth 只在 catalog provider 没有 apiKey 方法、且 profile 确实命名了凭证时,才把 harness 自己的 api-key 方法并上去。因为 pi-ai 只在 provider 声明了 auth.apiKey 时才认请求里的 apiKey 覆盖——一个纯 OAuth 的 provider(catalog 里就有 openai-codex)会在此之前就以 Provider is not configured 拒绝。凭证永远不会到这个模块的存储里:harness 先经 ctx.credentials 解析,再作为 stream option 交出去。

llm-deepseek 是另一条路:固定 provider 名 deepseek-official,inject = ['llm'],ctx.llm.registerAdapter([PROVIDER], adapter)。重试不在 provider 内,而在独立插件 llm-retry(inject = ['agents','sessionProjections']),它的退避是 exponential × jitter 再夹到 maxDelayMs。

六、Drift Gate:让上游漂移变成编译错误

packages/llm/llm-pi-ai/src/catalog.ts 里有一组很特别的常量,形如:

const MODALITY_GATE: Record<PiAiModality, true> = { text: true, image: true }
const THINKING_LEVEL_GATE: Record<ModelThinkingLevel, true> = { off: true, /* … */ max: true }

Record<上游联合类型, true> 的键类型就是门(gate):pi-ai 升级后如果新增或删除了某个成员,这里的 Record 就不完整,在本文件编译失败并点名漂移的键,而不是静默收窄「profile 能声明什么」。这类值门有 7 个:MODALITY、THINKING_LEVEL、THINKING_FORMAT、MAX_TOKENS_FIELD、THINKING_TOKEN_BUDGET_FIELD、CACHE_CONTROL_FORMAT、CHAT_TEMPLATE_VAR。另有 4 个 *_COMPAT_GATE(completions / responses / anthropic / bedrock),值不是 true 而是 CompatDisposition,并在 COMPAT_GATES 里按 API 归拢——注释说明:上游新增一个「带 compat 的协议」会让这个条目列表也编译失败。

每个门旁边都紧跟一个 Object.keys(...) 导出的公开列表(MODALITIES、THINKING_LEVELS…),顺序即声明顺序,配置界面用它做选项。于是「上游加了什么」和「我们能声明什么」之间的差集只能靠改代码来关闭,不能靠运气。

七、subagent 托管:把外部 CLI 当可寻址 provider

packages/subagent/subagent 是契约包。SubagentRuntime extends TypertRemoteService,注册为 ctx.subagents,核心是注册表:

registerProvider(provider: SubagentProvider): () => void {
  return this.ctx.effect(function* () { /* … */ })   // effect 作用域,HMR 安全
  // 重名 → SubagentError('DUPLICATE_PROVIDER')
  // 注册成功的副作用是 ctx.emit('subagent/provider-added', provider)
}

SubagentProvider 的形状是 readonly name: string + start(request): Promise<SubagentRun> + prepareContinuable。生命周期事件是 subagent/start / subagent/end,成对携带同一个 runId。

subagent/ 目录下有 6 个真正注册 provider 的实现包(另有 subagent-in-process-driver 与两个 tool-subagent* 不属于 provider 侧),按「子 agent 跑在哪」分类:

provider 包 默认 providerName 子 agent 位置
subagent-fork-in-process fork 同进程,fork 当前上下文
subagent-spawn-in-process spawn 同进程,新起一个 agent
subagent-acp acp ACP 协议外接
subagent-dsh-sdk dsh-sdk 走 SDK 的独立进程
subagent-claude-code claude-code 外部 Claude Code
subagent-codex codex 外部 Codex app-server

模型面只看到一个工具 tool-subagent(inject = ['tools','subagents','systemPrompt','sessionProjections'],工具名默认 subagent),它的注释写明:委派(delegation)的深度由配置上限约束,subagent/src/depth.ts 把深度记在 agent header 上(顶层为 0,子为父 +1)。

两个外部 CLI 的托管都遵循**无人值守(unattended)**原则。subagent-claude-code/src/run.ts 的 claudeQueryOptions 把 persistSession 设为 false,并把所有交互回调变成确定性拒绝:canUseTool 一律 deny、onElicitation 一律 decline、onUserDialog 一律 cancelled,同时把事实交给 captureDiagnostic。默认权限模式是 DEFAULT_CLAUDE_CODE_PERMISSION_MODE = 'dontAsk';只有 bypassPermissions 才换成 allowDangerouslySkipPermissions。subagent-codex/src/run.ts 对应地从 [process.execPath, CODEX_PACKAGE_BIN, 'app-server', '--stdio'] 启动真正的 Codex,权限模式词汇是 never / approve-for-me / dangerously-bypass-approvals-and-sandbox,默认 never。两者都有 DEFAULT_DISPOSE_GRACE_MS = 3_000 的回收宽限。

八、hooks 桥与 MCP 接入

hooks 桥把两个外部 agent 的 hook 配置翻译成 harness 自己的拦截点。hooks-claude-code 只认 7 个事件(SessionStart、UserPromptSubmit、PreToolUse、PostToolUse、Stop、SubagentStart、SubagentStop),parseClaudeCodeConfig 在解析期做两件事:替换 ${CLAUDE_PLUGIN_ROOT} / ${CLAUDE_PROJECT_DIR},并把非 command 类型的 hook 收集进 skipped 交给桥去 warn(只有 command hooks 会真正执行)。两个刻意的「不采纳」写在警告文本里:updatedInput 被记录并警告但不生效,systemMessage 被忽略。hooks-codex 更窄,只认 5 个事件,且只跑同步 command hooks。

MCP 接入在 packages/mcp/mcp-client。工具命名规则是 mcp__<serverName>__<rawName>;serverName 必须匹配 SERVER_NAME_PATTERN([A-Za-z0-9_-]{1,32},注释说这个宽度是为了留在公开工具名的预算内)。serverName 是一个命名空间预订:重复注册会直接让本实例构造失败,报错文本建议去 cordis.yml 里换一个唯一名。工具调用超时默认 DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000。

代码地图

机制 位置 要点
沙箱契约 packages/sandbox/sandbox/src/index.ts: SandboxProvider Service 子类注册为 ctx.sandbox,抽象 confine(argv, policy, signal) 返回 ConfinedArgv
三档模式 packages/sandbox/sandbox/src/index.ts: SandboxMode read-only/workspace-write/danger-full-access;ConfinedSandboxMode 去掉最后一档
逐调用策略 packages/sandbox/sandbox/src/index.ts: SandboxPolicy 携带 mode、workspaceRoot、可选 sessionId;provider 视为已完全解析
强制完整性 packages/sandbox/sandbox/src/index.ts: SandboxEnforcement full/partial;partial 表示后端或旧 ABI 无法治理全部承诺的文件效果
fail-closed 出口 packages/sandbox/sandbox/src/index.ts: SandboxUnavailableError 携带 SANDBOX_UNAVAILABLE,经 tool/result 结构化通道传出
可写根 packages/sandbox/sandbox/src/roots.ts: writableRoots workspaceRoot + /tmp + tmpdir() 规范化去重,Seatbelt 与 fs-sandbox 共用
平台链 packages/sandbox/sandbox-local/src/index.ts: PLATFORM_CHAINS linux ['bwrap','landlock']、darwin ['seatbelt']、win32 ['windows-acl']
链裁决 packages/sandbox/sandbox-local/src/index.ts: chainVerdict 单候选不探测直接选;多候选按序功能探测;全失败返回 unavailable
静态完整性 packages/sandbox/sandbox-local/src/index.ts: STATIC_ENFORCEMENT bwrap/landlock/seatbelt = full,windows-acl = partial
拒绝方言 packages/sandbox/sandbox-local/src/index.ts: DENIAL_SIGNATURES 每后端自己的 stderr 子串,禁用跨后端并集匹配
runner 失败规则 packages/sandbox/sandbox-local/src/index.ts: RUNNER_FAILURE_RULES landlock 只在 exit 125、windows-acl 只在 exit 127 匹配
bwrap profile packages/sandbox/sandbox-local/src/profiles.ts: bwrapProfileArgs --ro-bind / /、--unshare-pid、--die-with-parent;可写模式加 --tmpfs /tmp 与 --bind ws ws
Seatbelt profile packages/sandbox/sandbox-local/src/profiles.ts: seatbeltProfileArgs SBPL (deny file-write*) + /dev/null 与 writableRoots 白名单
Landlock 启动器 native/system/packages/entry/src/index.ts: LAUNCHER_BIN landlock-run;LAUNCHER_FAILURE_EXIT = 125;probe() 按 /partially enforced/ 分 full/partial
Landlock 原生入口 native/system/packages/entry/src/main.c: restrict_self 直接 syscall 444/445/446;--ro 只给读侧、--rw 给全部文件访问
Windows SID packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts: workspaceWriteSid sha256(workspaceRoot) 取两段 30 位 → S-1-4-x-y;temp 版追加 -1 域段
升级阶梯 packages/sandbox/sandbox/src/escalation.ts: WIDER_MODES read-only → [workspace-write, danger-full-access];执行期检查而非 schema
升级审批 packages/sandbox/sandbox/src/escalation.ts: approveEscalation 严格更宽才走审批;拒绝/取消/无通道各自抛不同文本
会话模式折叠 packages/sandbox/sandbox-policy/src/session-mode.ts: setSandboxMode 追加一条 sandbox/mode 事件(log-only);effective = 投影 ?? 部署默认
协议表 packages/llm/llm-pi-ai/src/provider.ts: PROTOCOLS 只放 openai-completions / openai-responses / anthropic-messages 三种可手写路由的协议
Drift Gate packages/llm/llm-pi-ai/src/catalog.ts: MODALITY_GATE 7 个 Record<上游联合, true> 值门 + 4 个 *_COMPAT_GATE;上游加成员即编译失败
DeepSeek provider packages/llm/llm-deepseek/src/index.ts: PROVIDER 固定 deepseek-official,ctx.llm.registerAdapter([PROVIDER], adapter)
重试插件 packages/llm/llm-retry/src/index.ts: backoff 指数退避 × jitter 夹到 maxDelayMs;Config 为空对象
subagent 注册表 packages/subagent/subagent/src/index.ts: registerProvider effect-scoped、HMR 安全;重名抛 DUPLICATE_PROVIDER
provider 契约 packages/subagent/subagent/src/types.ts: SubagentProvider readonly name + start(request): Promise<SubagentRun> + prepareContinuable
claude-code 托管 packages/subagent/subagent-claude-code/src/run.ts: claudeQueryOptions persistSession:false;无人值守回调一律 deny/decline/cancel
codex 托管 packages/subagent/subagent-codex/src/run.ts: CODEX_PERMISSION_MODES never/approve-for-me/dangerously-bypass-approvals-and-sandbox,默认 never
hooks 桥 packages/hooks/hooks-claude-code/src/config.ts: CLAUDE_EVENTS 7 个事件;只跑 command hooks;updatedInput 只告警不采纳
MCP 命名 packages/mcp/mcp-client/src/index.ts: SERVER_NAME_PATTERN mcp__<serverName>__<rawName>;serverName 须匹配 [A-Za-z0-9_-]{1,32}
MCP 超时 packages/mcp/mcp-client/src/index.ts: DEFAULT_TOOL_CALL_TIMEOUT_MS 60_000 ms

关键取舍

沙箱宁可拒绝执行,也不静默放行。 confine 的类型签名里没有任何「返回原 argv」的合法形态,平台无链、链全失败、单候选运行期拒绝,三条路都汇聚到 SandboxUnavailableError。代价是 Windows 用户可能直接跑不起来任何受限命令;收益是「沙箱开着」这个事实永远是真的。

「完整性」被显式建模成 full | partial,而不是布尔值。 引入这个维度是为了让 windows-acl 这种「管住了一部分、但硬链接与读操作仍是洞」的后端能诚实声明自己。需要绝对边界的调用者据此拒绝把 partial 当 full——这是把平台差异变成类型事实,而不是写成文档里的一句免责声明。

拒绝方言按后端拆分,而不是取并集。 并集实现更简单、匹配率更高,但会宣称某些后端根本不会产生的拒绝,进而把「runner 没跑起来」误诊成「约束生效了」——这正是 fail-open 的错误方向。同理,runner 失败规则退出码门控,是为了不让一个恰好打印了签名的受限命令被误判。

Drift Gate 把上游漂移变成编译错误,而不是运行期惊喜。 Record<上游联合, true> 的键类型是一道只增不减的清单:pi-ai 加一个 thinking format,我们必须先命名它才能编译通过。代价是每次升级上游都要动代码;收益是「我们能声明的集合」与「上游实际支持的集合」永远不可能静默错位。

外部 agent 托管一律无人值守。 claude-code 与 codex 两个 provider 都把交互回调换成确定性拒绝,并把默认权限模式压到最保守(dontAsk / never)。因为子 agent 没有可以弹出审批框的人;如果允许「等待人类」,这个 run 就会挂死在一个永不出现的对话框上。

自测题

  1. PLATFORM_CHAINS.linux 有两个候选,而 darwin 只有一个。为什么「单候选不探测」不会破坏 fail-closed?
  2. 一个受限命令的 stderr 里出现了 windows-acl-run: ,但进程退出码是 1。它应该被判定为 runner 失败吗?依据是哪条规则?
  3. ESCALATION_TARGETS 与 WIDER_MODES 表达的是同一个阶梯,为什么需要两份常量?
  4. buildProvider 为什么对「catalog 里存在、但 profile 覆盖了协议」的路由不复用 catalog provider?
  5. claude-code subagent 的 canUseTool 直接返回 deny,而不是把它转发给 ctx.approval。这个设计取舍对应哪一条更上层的约束?

进入 keel 阅读