# Agent Note: 子进程沙箱——约束 seam、原生 runner、升级机制与按会话模式

Status: implemented

[English](2026-07-06-sandbox.md) | 中文

## 问题

一个编码 agent（智能体）需要如下产品路径：bash 子进程（以及依附其上的钩子命令）默认在受限的文件沙箱下执行；当且仅当沙箱实际拒绝了某个操作时，模型可以为同一操作请求一次用户批准，获批后以更宽的权限重试一次。本设计刻意不声称覆盖所有工具：fs/web/todo 在进程内执行，`execve` 包装层对它们毫无意义（§ 进程内工具）；跨工具族的统一边界属于分阶段后续工作（§ 延迟阶段）。如果没有共享词汇，每个工具都会各自重新发明批准字段、拒绝解析、重试匹配和权限状态提示。

harness 是一个 SDK，因此约束必须是开发者可组合的能力：是否启用沙箱、每个平台使用哪个后端，都应作为一等条目写在叶子 `cordis.yml` 中，而非藏在某个执行器的私有机制里。而首选 runner `bwrap` 恰恰在沙箱最重要的主机上不可用（精简容器、禁用了非特权 userns、LSM 拒绝 `mount`），因此备选 runner 必须随 SDK 一起交付，而不能假设主机已有。

仅有约束还留下两个缺口。拒绝后没有升级路径就是死路：模型只能放弃，这会迫使运维人员全局配置 `workspace-write` 或 `danger-full-access`，从而使沙箱形同虚设。而沙箱模式和批准策略也会通过部署配置或可选的 UI 策略控件在 agent 生命周期内变化；执行与模型可见的策略必须派生自同一份已记录的状态。

## 决策

一个 seam、一条按平台的本地后端链、一个消费方，加上两个上层控制项：按调用的升级路径与按会话的运行时模式。以下所有内容均从叶子 `cordis.yml` 组合而来；不触及 `agent-loop`。跨工具族 fs 强制、按会话工作区根目录与 Windows 链已经作为后续设计落到同一策略载体上；`subagent-acp` 消费方与更多环境仍列在 § 延迟阶段。

### 部署方式

四条 `cordis.yml` 条目即可将一个无约束的编码 agent 转变为沙箱产品路径；[基础 profile 组合包](../../../../packages/bundle/base/cordis.patch.yml)默认使用此组合：

```yaml
- id: sandbox
  name: '@deepseek-ai/dsh-sandbox-local'   # the per-platform runner provider (ctx.sandbox)
- id: bash
  name: '@deepseek-ai/dsh-bash-sandbox'    # the confined executor, replacing dsh-bash-local behind ctx.shell
  config:
    mode: workspace-write                  # the deployment default every session starts from
    workspaceRoot: !!js process.cwd()      # the boundary workspace-write may write under
- id: approval
  name: '@deepseek-ai/dsh-user-approval'        # the escalation gate's channel (the approval Agent Note)
  config:
    policy: ask
- id: permission
  name: '@deepseek-ai/dsh-permission-presets'      # one product-facing select over both mechanism knobs
```

这一替换对 `ctx.shell` 的所有消费方透明：bash 工具、钩子命令和后台任务照常运行，直接使用提供方返回的已包装 argv 启动。删除 `sandbox` 和 `permission` 条目、将 `bash` 替换为 `@deepseek-ai/dsh-bash-local` 即为退出——执行恢复为无约束，升级字段从工具 schema 中消失，因为它们是基于已挂载执行器的能力门控，而非基于配置。仅省略 `approval` 则保留约束但以自身错误文本关闭每次升级；`permission` 还要求 approval seam 和约束执行器同时存在，因此组合不完整的 preset 层会在加载时明确报错。

配置错误会显式导致失败：`mode` 不在封闭词汇中时在插件加载时被拒绝；主机上没有可用后端时在 `confine()` 阶段抛出结构化的 `SANDBOX_UNAVAILABLE`，而非降级为无约束执行。如果所选 runner 以可归因的 `ENOENT` 或 `EACCES` 拒绝，消费方会在任何命令开始前通过 spawn 通道报告同一基础设施错误；其他 spawn 错误仍保留本地命令启动语义，同时也不会运行任何内容。`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言（跳过链和探测）；它同时充当 keyless 测试的确定性 fake-runner 钩子。

被拒绝的文件操作返回 `[sandbox: file access denied under <mode> mode]` 标记，并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions` 和 `justification` 字段，用于一次经批准的重试，该重试必须严格宽于会话的有效模式。授权仅放宽该次重试；拒绝则不执行任何内容，返回 `the user rejected escalating this command to "<mode>"`，且不允许再次请求。由归属方派生的待处理策略上下文会说明当前文件策略，但不会取代这些强制执行边界。当 `dsh-permission-presets` 与某个 UI 适配器一起组合时，一个 preset 同时选定两个旋钮值；不匹配的组合折叠为 `custom`。[ACP（Agent Client Protocol）应用组合包](../../../../packages/bundle/acp-app/README.zh.md)不挂载该 UI 服务，而是显式选定其部署模式。

### 设计细节

#### 范围界定

OS 子进程约束适用于 bash 执行器（包括钩子命令），后续还将适用于 ACP subagent 子进程。文件系统、web 和其他工具在进程内执行，需要在各自的 seam 层面实施策略；argv 包装层无法约束一个闭包了 `ctx` 的函数。既有的 bash request/spec 拆分承载按调用的覆盖，而 `tools/pre-execute` 和 approval seam 负责一次性策略决策。

#### seam：`ctx.sandbox`

`dsh-sandbox` 负责定义词汇和 `SandboxProvider` 约定：`confine(argv, policy)` 返回调用方应当 spawn 的替代 argv（经过包装，使进程及其所有子进程在约束下运行），加上所选后端达到的 `enforcement` 完整度、其拒绝方言（`denialSignatures`，该后端内核在拒绝文件操作时打印到 stderr 的子串），以及其结构化 runner 失败证据（`runnerFailureRules`，可选的允许退出码加上排除整行精确信息性行后按行匹配的致命签名）；没有可用后端时抛出失败关闭的 `SANDBOX_UNAVAILABLE` 错误，绝不静默放行。词汇：`SandboxMode`（`read-only` / `workspace-write` / `danger-full-access`，仅限文件操作——不声称覆盖网络和进程可见性）、`SandboxEnforcement`（`full` / `partial`）、`SandboxExecutionPolicy`（每次能力调用的完整 mode + 工作区根目录）以及 `SandboxPolicy`（提供给约束后端的子集）。

策略随每次调用而非提供方携带：两个消费方可以在同一时刻以不同策略约束（bash 在 `read-only` 下运行，而一个受约束的子 agent 保持其状态目录可写），且经批准的升级重试是一次带有更宽策略的新调用——在配置固定的提供方模式下无法表达。

该 seam 仅约束与宿主共享文件系统和内核的子进程。容器、microVM 和远程执行器不是此 seam 的后端——它们以环境一致的组替换整个能力 seam（`ctx.shell`、`ctx.fs`）的 Service Provider，因为一个 bash 在容器中运行而 fs 工具写主机的 agent 生活在两个割裂的世界中。

留待需要时再决定：网络限制是作为独立的 `network_mode` 到来，还是在某个 runner 同时强制两者后合并进 `sandbox_mode`；以及 `SandboxPolicy` 是现在就增加额外的可写根授权（launcher 已支持 `--rw <path>`），还是等到升级机制需要时再加。

#### 本地后端与随附 launcher

`dsh-sandbox-local` 在提供方生命周期内选择一个平台 runner 并缓存结论。Linux 功能性探测 `bwrap` 然后 Landlock；macOS 使用 Seatbelt。不支持的平台和不可用的 runner 失败关闭。每次包装携带后端特定的拒绝签名和 runner 失败规则，以便 `dsh-bash-sandbox` 区分被拒绝的文件操作与损坏的沙箱。Landlock runner 失败需要退出码 125，加上一行不是通知的 `landlock-run:` 诊断；即使子进程以 1、2 或 125 退出，精确匹配的部分强制执行通知仍只是信息。Bubblewrap 和 Seatbelt 仍仅依据签名，因为两者的公开约定均未保留 launcher 失败状态。`runnerCommand` 保留面向运维人员的 `runnerFailureSignatures` 配置，要求其中条目均为非空单行，并将它们映射为一条内部致命规则。消费方会直接 spawn 每个返回的 argv，因此缺失的 runner、不可执行的 runner，或 shebang 解释器不可用的可执行脚本会通过可归因的 `ENOENT`／`EACCES` spawn 通道失败，而成功启动的子进程以 126 或 127 退出时仍按普通结果处理。运维人员配置的脚本必然要先启动解释器，再应用自身 profile。

launcher 是一个约 300 行的 C 程序（纯 C11，直接使用 Landlock UAPI——除静态链接的 musl 外无其他库，因此审计面仅为该文件加内核的稳定 syscall 约定）：`--ro <path>` / `--rw <path>` 授权，`--`，被包装的 argv；它为自身安装规则集并执行 `exec`（规则集跨 `execve` 继承，且它在限制前设置 `no_new_privs`）；`--probe` 在一个短生命周期子进程中强制最大规则集，仅当内核确实强制时才以 0 退出；所有 launcher 失败都会以 125 退出且不运行子进程，并打印一行致命的 `landlock-run:` 诊断。成功完成 exec 的子进程也可能返回 125，因此仅凭退出状态不能作为 launcher 失败的证据。较旧的 ABI 会在执行子进程之前打印精确的 `landlock-run: partial enforcement (older Landlock ABI)` 通知，因此该行不是致命证据。

Landlock launcher 源码和包家族位于 `native/landlock-run`，与 harness 消费方同仓，并属于根 pnpm workspace。[仓库内 Landlock 发布决策](../process/2026-08-06-in-repository-landlock-release.zh.md)负责共享锁文件、原生构建、打包演练和 npm 发布边界。平台二进制由 npm 选择，入口包拥有路径解析、探测、CLI（命令行界面）参数、致命前缀和部分强制执行通知，而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化，使探测解析和启动语法保持对齐。

后端 profile 共享模式约定但在必要的主机授权上有所不同。Landlock 和 Seatbelt 在 read-only 模式下仅允许 `/dev/null`；workspace-write 还允许各自所需的主机临时目录根。每次包装携带后端特定的拒绝签名。Landlock 在较旧的 ABI 无法管控所有操作时报告 partial enforcement，而成功的 bwrap 和 Seatbelt profile 报告 full enforcement。

#### bash 消费方

`dsh-bash-sandbox` 扩展 `LocalBashExecutor`，把精确的 `['bash', '-c', command]` argv 交给 `ctx.sandbox`，并直接 spawn 提供方返回的 argv。这样，随附的原生 runner 建立约束后，shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播。进程启动前，只有当调用方拥有的 workdir 经独立验证可用，Node 报告 `ENOENT` 或 `EACCES`，并且错误符合以下一种形态时，才判定为 runner 失败：`error.path` 等于提供方返回的 `argv[0]`，同时 `syscall` 为 `'spawn'` 或精确的 `'spawn <runner>'`；或者 `error.path` 不存在，同时 `syscall` 为精确的 `'spawn <runner>'`。其他错误码、无效 workdir、资源失败、无关 syscall 与无结构拒绝保留本地命令启动语义。前台执行会将 runner 失败转为 `SANDBOX_UNAVAILABLE` 并附上原始详细信息；异步后台拒绝则盖章 `runnerFailed: true`、`denied: false`。如果 `SubprocessRuntime` 同步抛出同样能指明 runner 的形态，后台启动会抛出 `SANDBOX_UNAVAILABLE`；其他同步错误原样传播。进程启动后，前台与后台共用一个 runner 失败分类器：先排除信息性行，再要求规则的退出码检查与余下的一行致命诊断同时匹配。匹配结果优先于拒绝：前台执行抛出 `SANDBOX_UNAVAILABLE`，并以该致命行作为详细信息；结算后的 `ShellProcess` 会盖章 `sandbox.runnerFailed`，bash 生产者再通过通用 `job_output` 渲染它。

模型会在归属方派生的 `sandbox:policy` 上下文中看到当前有效的文件策略；静态工具描述则解释拒绝标记（`[sandbox: file access denied under <mode> mode]`），鼓励尝试可能被拒绝的命令，并禁止绕过拒绝重试。当升级字段被公布时，被拒绝的结果还会携带升级提示本身，使被认可的同轮次重试在决策点获得提示，而非依赖模型回忆描述（§ 升级机制）。[当前策略决策](2026-07-30-current-sandbox-policy-context.zh.md)负责该上下文的理由与边界。

#### 升级机制：拒绝后一次经批准的更宽重试

`ShellExecRequest.sandboxPolicy` 是可选的完整按调用输入；解析后的 spec 使该字段显式。`ShellExecutor.sandboxMode` 仍是公布已挂载执行器能否兑现该策略的能力事实，因此只有约束组合才暴露升级。seam 接受任何显式策略；工具拥有会话解析和「仅更宽」的升级规则。非沙箱执行器诚实地保持无约束。

`ctx.sandboxPolicy.resolve()` 在执行器运行前盖章完整执行策略——显式升级模式 > 会话覆盖 > 配置默认值，且 `SessionHeader.cwd` > 配置的后备根目录。`SandboxBashExecutor.resolve()` 在 spec 上保留该策略，或为直接的无 agent 调用方提供部署后备值，使 `run()`/`start()` 永不读取可变会话状态。每进程包装事实以返回的 `ShellProcess` 为键；`onProcessDone()` 会通过 stderr 分类之外的通道接收 spawn 失败，并在 `done` 结算前给该句柄盖章，因此重叠进程各自保留自己的模式和 runner 方言。

当约束执行器被挂载时，`bash` 公布配对的 `sandbox_permissions` 和 `justification` 字段。schema 暴露完整的封闭升级词汇，因为有效模式是按会话的；执行拒绝任何不严格宽于该调用有效模式的目标。批准在执行之前解析。`allowed-once` 仅将授权模式盖章到该请求上，而 `rejected`、`cancelled`、`unavailable`、缺失的 approval 服务或缺失的 agent 都以各自不同的结果文本失败关闭。授权不持久化。

升级是对被拒绝命令的同轮次重试，使用最窄的足够 `sandbox_permissions` 和一个 `justification`；批准提示词是同意步骤。它必须基于实际的拒绝，除非会话已观察到相同的被拒绝访问；禁用或被拒绝的批准终结该命令。重试、批准决策和结果使用既有的工具和批准事件。`dsh-tool-bash` 拥有请求动作，因为执行器 Service Definition 既没有 agent 也没有用户交互所需的 call id。

仍未决定：持久授权超出沙箱模式之外的作用域标识是什么——确切调用、路径、命令前缀、会话或时间窗口——这是公布 `allow_always` 选项之前必须回答的问题。

#### 按会话模式：会话日志即存储

```
effective(session) = sessionProjections.stateOf(session, '<knob>') ?? the composition-config default
```

默认值是组合配置（`cordis.yml`）——由运维人员拥有、作用于整个进程。运行时切换是会话范围的覆盖，以一条仅日志事件记录在该会话的日志中；该旋钮的投影单元折叠这条事件，且所有单元的状态都会被统一检查点，因此重启后仍然有效与多会话隔离来自持久化投影缓存加日志回放，且不存在任何外部配置存储。进程内 subagent 驱动器在委派时对父级的显式覆盖项获取快照，并在子 agent 可选的 fork 前缀之后预置一条带来源标记的事件，因此委派无法回退到更宽的默认值（[决策](2026-07-25-subagent-policy-inheritance.zh.md)）。

**每个旋钮一种事件，由其领域拥有**——这是每个既有事件族已遵循的可合并扩展 `SessionEventMap` 惯用法（`dsh-user-approval` 中的 `approval/*`、hooks 包中的 `hook/*`）：

```ts
interface SessionEventMap {
  'sandbox/mode': { mode: 'read-only' | 'workspace-write' | 'danger-full-access' }
  'approval/policy': { policy: 'ask' | 'never' }
}
```

每个拥有者贡献同一套构成：事件声明，一个注册在必需注入的 `ctx.sessionProjections` 注册表上的投影单元（`key` 为 `sandboxMode` / `approvalPolicy`，`stateVersion` 1，一个校验持久化状态的 zod `stateSchema`，`init` 为 `null`，以及 `apply`——即 fold，一个对其旋钮事件的 find-last 形态转移，类型化到领域的封闭联合），以及唯一的写入路径（`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)`——切换即其事件；没有任何东西在带外修改状态）。注册表在每条已提交的会话事件上急切驱动所有单元，并统一检查点所有状态——host-only 单元也不例外，无 `persist` 开关——因此重启后仍然有效与多会话隔离依托持久化缓存加日志回放，而非每个包自带的读取辅助函数。它是共享的框架基础设施，而非带归属服务的 facts map：每个领域保留自己的事件与单元，第三个配置项只需在自己的包中写好事件、单元与 setter 并注册该单元——驱动、检查点与读取面都来自注册表，无需复制任何 fold 导出。host 读取方经 `stateOf(session, key)` 读取折叠后的覆盖（`SandboxPolicyService.overrideOf` 与 `ApprovalService.overrideOf` 就是这些读取），且贡献方与读取方都必需注入 `sessionProjections`（[必需投影 seam](../architecture/2026-08-19-session-projection-mandatory-seam.zh.md)）。执行在两侧都遵循投影状态——bash 工具的按调用盖章将其作为 § 升级机制优先级链的中间层读取，approval seam 的 `'never'` 门控是[批准 Agent Note](2026-07-06-approval-seam.zh.md) 同一模式的另一侧。

每次拟议步骤之前，沙箱策略与批准策略都会渲染为一条目标策略上下文消息中的有序贡献。监听器将该消息与会话历史、已领取批次及其待处理的 `next-step` inbox 条目协调。消息一旦被领取并进入步骤，循环就会记录完整且带来源的 `user/message`；`'ask'` 与 `'never'` 都会明确写入，因此两个归属方都无需切换叙述或「上次告知」状态。

**可选的 UI 界面**是 `PermissionPresetService`：一张部署定义的 preset 表，每个条目捆绑一个沙箱模式与一个批准策略。随附的 `workspace-write` 和 `danger-full-access` preset 写入两个领域 setter；preset 表之外的旋钮组合报告为 `custom`。UI 适配器可以把该表暴露为选择器。仅面向自动化的 ACP 传输层不公布任何配置选择器，也不挂载权限 preset 服务。

**已提交的事件是提交边界。** 运行时切换在目标会话上记录其 preset 和发生变化的旋钮事件，之后每次能力解析都折叠最后的值。选择有效的会话追加边界由适配器负责；ACP 传输层没有运行时切换路径。（原先 ACP 的空闲切换锚定——将待定的空闲选择保留到下一次提示词提交——已随该桥一并移除。）

#### 进程内工具

fs/web/todo 在进程内执行，因此它们的沙箱语义是各自能力边界层面的策略。fs seam 现在通过沙箱提供方强制共享模式词汇（`dsh-fs-sandbox` 按模式限制 write/edit；见[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.zh.md)），因此 `read-only`/`workspace-write` 对文件系统工具也是真实边界，而非仅限 bash 的近似。web/todo 仍不受限制（web 的唯一效果是网络，不在文件效果模式词汇内）。没有通用的按工具沙箱运行时：主机中介的工具仅通过返回主机验证的声明式效果来离开进程，那是一次重写而非包装层——后续设计选择了一个共享策略归属 `ctx.sandboxPolicy`，由各能力强制，而不是统一包装层。

### 测试

- **单元测试：** 固定平台选择和 profile、直接交接提供方返回的 argv、带有无效 workdir 对照的 spawn 层失败、runner 缺失／不可执行／解释器缺失证据、格式错误 runner 阴性对照、受约束的 `BASH_ENV` 求值顺序、结构化 runner 分类（包括只有部分强制执行通知的子进程结果、带门控的致命证据、子进程退出码 126／127，以及前台／后台一致性）、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传，以及运行时上下文排序与具体化。
- **Keyless 真实 runner：** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试；一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话，证明在自身根目录写入成功、在兄弟根目录写入被拒绝。打包安装测试会安装当前 checkout 的原生 tarball，并证明 launcher 保持可执行且字节完全一致。CI 拒绝静默全跳过。
- **With-key：** 以只读模式启动真实 ACP 组合，让模型驱动的 bash 写入命中 runner 的拒绝标记，再通过已授权与被拒绝的 workspace-write 重试驱动 bridge 应答器和磁盘效果；不可用的凭证或 runner 自动跳过。
- **快照：** 固定原子化的当前策略上下文和两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下，同时让部署后备根目录指向 `/tmp`，然后固定 workspace-write 运行时上下文消息与一次成功的、由部署选定的变更；这能区分会话根目录解析与进程后备值，而不依赖 runner 特定的拒绝文本。一个模拟 Landlock 部分强制执行行为的 POSIX 提供方会在组装后的应用中固定直接执行 bash `false` 时仍得到普通子进程结果，并固定提供方可执行文件缺失时在前台／后台均为基础设施失败。其他快照以无约束启动，使无关 fixture（测试前置数据）保持平台无关。

## 延迟阶段

每个阶段在开始实施时都会完成完整设计，并对照当时的代码验证，同时在其涉及的层级配套单元测试、真实 API e2e 和快照覆盖。

- **第二个消费方**——`subagent-acp` 可选地约束子 agent（按调用策略；默认无约束——子 agent 必须写入自己的持久化）。
- **更多环境**——环境一致的能力组示例（如 bash+fs 对一个容器）。

## 曾考虑的替代方案

- **命令字符串启发式预检**：否决。无法理解展开/子进程/符号链接；严格尝试（运行它，让内核决定）是唯一可信的拒绝信号。
- **即使平台仅有一个后端也功能性探测**：否决。探测用于在候选者之间仲裁；只有一个时无需决策，且探测开销对每个会话的首次约束命令征税（对未来重量级后端而言代价过高）。runner 自身执行时的失败关闭拒绝加结构化 `runnerFailureRules` 分类承载了安全属性。
- **提交构建好的 launcher 二进制**：否决。diff 中的二进制不可审查且膨胀历史；经审查的源码 + 原生 CI 构建 + 主仓库的字节固定发布演练使二进制远离所有代码树。
- **安装时编译 launcher**：否决。将 C 工具链强加给每个消费方；仅在碰巧有编译器时才存在的备选不是备选。
- **从一个构建器交叉编译两种架构**：否决。仅为重建两个约 70 KB 的二进制就需要携带一个固定的交叉工具链（rustup targets、zig 或容器镜像）；每架构的原生 runner 已存在，各自构建自己的平台包（`node-addon-require-builtin` 模式，由主仓库的原生流水线保留）。
- **无备选（bwrap 或失败关闭）**：否决。将失败集中在沙箱最重要的主机上，最终因放弃而降级到 `danger-full-access`。
- **将机制保留在 `dsh-bash-sandbox` 内部**：否决。阻塞既有的第二个消费方，使未来阶段从一个 bash 插件的配置中读取模式，且无法表达升级。
- **提供方上的配置固定模式**：否决。每进程一个模式；无法服务具有不同策略的并发消费方，也无法表达一次性放宽重试。
- **一个接口同时覆盖容器/VM**：否决。`confine(argv)` 预设共享文件系统；环境隔离是作为一致组部署的能力兄弟后端。
- **通用 ToolRuntime 包装任何工具**：否决。对进程内工具（闭包了 `ctx`）机械上不成立；声明式效果重写对 fs/web/todo 而言不合理。
- **在执行器内部（`dsh-bash-sandbox`）请求批准**：否决。没有可路由的 `agent`，没有可附加提示词的 `callId`；添加它们会让传输边界了解会话和 UI——工具层持有两者并拥有面向模型的词汇。
- **同一工具调用内自动重试**：否决。日志无法重建的隐藏重入：一个 `tool/call` 会产生两次具有不同策略的执行——重试是一次新的带有自身参数和结果事实的已记录调用。
- **无条件公布升级字段**：否决。在 `dsh-bash-local` 下它们是死杠杆——公布 harness 无法兑现的选项会制造注定失败的授权；能力门控仅需注册时一次读取。
- **默认值相对的升级阶梯（仅公布比执行器注册时默认值更宽的模式）**：否决。按会话覆盖使默认值成为错误的基线——切换到比默认值更窄的会话恰恰失去它需要的杠杆，而在 `danger-full-access` 默认值下字段完全消失，同时一个被覆盖为 `read-only` 的会话仍处于约束中却没有升级路径。枚举固定封闭的目标词汇；严格放宽是针对会话有效模式的按调用执行检查。
- **按会话动态工具 schema**：否决。schema 设计上是注册表全局的（一套 assembly 词汇、固定 header 快照约定），按会话重新注册只能买到执行时严格放宽检查已保证的东西，代价是按会话的 schema 和每次切换的 header 变动。
- **将重试硬匹配到先前的拒绝**：否决。命令字符串同一性脆弱（引号、`workdir`、env 前缀、作为失败阶段重试的管道）——要么误拒诚实的重试，要么被轻易满足；真正的边界是人看到命令 + 理由。仅在 `allow_always` 授权存储需要机器可检查的范围时才重新考虑。
- **通用 `env/state` facts map 加拥有者服务**：否决。approval 和沙箱独立组合，因此任何一方的状态都不应拖入第三个包；单键 fold 各自是一个 `findLast`，拥有者服务自然消解；没有跨旋钮的不变式，因此原子多键补丁无收益。
- **通过 `agent.inject()` 加总线事件逐次叙述切换**：否决。独立通知会暴露归属方顺序和中间组合，而一次 pre-step 组合可以原子排队完整的当前状态。
- **在稳定系统提示词中声明沙箱模式**：先行交付，随后根据线上证据移除：每次请求都带有 `Bash commands run under the "read-only" file sandbox.` 时，模型会拒绝尝试本可在被拒后升级的工作（首次人工会话的十二个轮次中有五个以零工具调用结束），使沙箱变成软锁死。拒绝标记会在相关时刻指出模式，升级字段则承载恢复路径。[当前策略决策](2026-07-30-current-sandbox-policy-context.zh.md)取代了省略策略的决策；这项测量和因果观察仍是任何替代方案必须进行反证测试的依据。
- **用专门的簿记事件追踪「上次告知」**：否决。会话历史记录模型看到的确切策略上下文，已领取批次与待处理 inbox 条目则表明正在进入或已经排队的内容。重新计算目标消息取代了第二条簿记流——事件仅在它们本身即为存储时才需要。
- **相互独立的沙箱与批准选择器**：否决。一个部署定义的权限 preset 让两个策略旋钮对暴露运行时切换的 UI 客户端保持一致。

## 后果

已交付并固定的内容——测试中的各层级分别保障：

- 被拒绝的命令以 `sandbox_permissions` + `justification` 重试时，通过组合的应答器链提示用户；授权使该次调用在更宽模式下运行（结果事实如此报告），而其他所有调用保持各自的有效模式；每种非授权结果产生各自不同的错误文本且不执行任何内容。
- 升级字段恰好在已挂载的执行器约束时存在；不严格宽于调用有效模式的请求以自身文本失败关闭且不提示任何人；没有 ApprovalService 的部署对升级调用失败关闭，对普通调用不影响。
- 一条带来源的策略上下文消息会以原子方式声明完整的当前沙箱策略与批准策略；整个交互——上下文消息、header、旋钮事件、批准通知、批准与结果——仅从会话日志即可重建，除两个旋钮事件外没有策略簿记事件。
- 一次 preset 选择只记录发生变化的旋钮值，而无操作的选择不记录任何内容；下一次 pre-step 会原子 upsert 两个当前值，已提交的沙箱切换由下一次调用的盖章兑现。
- 恢复会话的覆盖项会进入其首条新策略上下文消息，无需追赶状态；进程停止期间变更的组合默认值也会出现在该消息中。
- 两个并发会话永远看不到彼此的状态或通知。
- 同一个 Cordis 上下文中的两个并发项目会话解析各自独立的工作区根目录；bash 和 fs 写入在调用方会话的 cwd 内成功，对其相邻会话的 cwd 则失败。
- 策略归属仍通过 `SessionEventMap` 合并、注册在必需注入的 `ctx.sessionProjections` 注册表上的投影单元、从 `agent/pre-step` 变更 inbox，以及由能力归属方拥有的解析留在插件中；通用循环只领取并记录最终进入步骤的批次。

代价与已接受的限制：

- **单一包装层的幻觉被有意放弃。**`tools/pre-execute` 包装层加提示词约定无法解决沙箱批准——正确的设计需要结构化拒绝、原生 runner 探测、按调用策略承载和一致的跨工具族强制，本设计为此付出了代价。
- **`read-only` 通过后续设计成为跨工具族边界。** 本 Agent Note 最初只交付 bash 强制；[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.zh.md) 通过沙箱化的 `ctx.fs` 提供方把同一模式词汇扩展到文件系统工具，并将 mode/root 配置和 `sandbox/mode` 覆盖迁移到 `ctx.sandboxPolicy`（§ 进程内工具）。
- **Windows 后端只提供部分强制执行。** 本 RFC 最初预留了一条空的、失败关闭的 win32 链；后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)以受限令牌 runner 填充了它。其 Everyone 与硬链接缺口报告为 `enforcement: 'partial'`，绝不提升为完整承诺。
- **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选，它无需探测即被选中，因此在 workdir 可用时，未来移除会表现为可归因于 runner 的 spawn 失败，可执行文件拒绝则通过其致命签名体现——两者都会变为 `SANDBOX_UNAVAILABLE`，且命令绝不会运行；失败关闭，绝不开放。
- **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡，使备选在旧内核主机上仍可用。
- **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者，因此受限子进程可以模仿 runner 的致命诊断行和状态，造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配；这不是沙箱绕过，因为子进程已经受到限制。
- **launcher 在源码中是 workspace 依赖，发布后是 NPM 依赖。** 主仓库会在发布同一个包家族之前，一起测试经审查的 C 源码、原生 CI 构建和字节固定的本地 tarball；真实内核 e2e 测试环节会验证这些安装字节的实际行为。
- **模型可能过度请求。** 在没有拒绝依据的情况下升级，或在 `workspace-write` 足够时选择 `danger-full-access`：描述引导且枚举强制阶梯，但人的提示词是实际门控；`approval/asked` 原因使过度请求可审计，且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。
- **公布的目标集是静态的，而有效模式是按会话的**（schema 是注册表全局的）——已处于最宽模式的会话仍被提供这些字段。构造上无害：执行时的严格放宽检查（而非枚举）是安全边界——非放宽请求以自身文本失败且不提示任何人。
- **授权的升级不等于可工作的沙箱。** 不可用的后端即使对授权升级到约束模式也仍然失败关闭——平台没有链或所有探测失败时在 `confine()` 阶段失败，所选可执行文件无法启动时通过 spawn 通道失败，已启动的 runner 拒绝时则通过结构化规则失败——而授权的 `danger-full-access` 运行根本不触及提供方：此时授权（而非探测）是权威。
- **运行时上下文历史仅追加。** 策略切换会在保留的历史之后追加一份用于取代先前快照的完整快照，从而保留稳定的系统与对话前缀；状态不变时不添加消息。
- **旧策略快照仍保留在历史中。** 每份完整快照都会明确取代更早的运行时上下文快照，因此回放与压缩（compaction）只需保留最新具体化的消息。

## FAQ

- **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗？** 它运行了，内核拒绝了一个文件操作：拒绝是与退出码正交的结果事实。相关指令禁止通过绕过限制来重试；唯一被认可的动作是以升级请求重试同一命令一次。
- **如何区分损坏的沙箱与失败的命令？** 提供方 argv 的任何 spawn 拒绝都能证明受限启动从未开始，但只有在调用方拥有的 workdir 可用，且 Node 为该 argv[0] 报告可归因的 `ENOENT` 或 `EACCES` 时，才能据此判定 runner 损坏。没有精确错误路径的裸 `syscall: 'spawn'` 和其他所有拒绝仍是普通的命令启动错误。进程启动后，只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控，以及排除整行精确信息性行后的一行致命 stderr 诊断时，runner 失败才会优先于拒绝。前台失败会抛出结构化的 `SANDBOX_UNAVAILABLE`，并附带 spawn 错误或匹配行作为详细信息；遭异步拒绝或已结算的后台任务则盖章 `sandbox.runnerFailed` 并渲染自己的标记。如果 `SubprocessRuntime` 同步抛出同样带有 runner 路径的 `ENOENT`／`EACCES` 形态，后台启动会抛出该结构化错误；其他同步错误原样传播。Landlock 部分强制执行通知加上普通子进程失败时，仍返回命令结果。
- **在没有后端的平台上会发生什么？** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`，命令永不 spawn。
- **`bwrap` 已安装在我的主机上但不可用（禁用了非特权 userns、LSM 拒绝 `mount`）——会发生什么？** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败，选择落到已打包的 Landlock launcher，结论在提供方生命周期内缓存。
- **沙箱限制网络或进程可见性吗？** `SandboxMode` 仅声称文件操作，没有后端声称网络。进程可见性取决于后端：bwrap 会 unshare PID 并挂载匹配的 procfs，因为宿主 `/proc/<pid>` 魔法链接会绕过文件约束；Landlock 与 Seatbelt 则保持进程可见性不变（[决策](../bug-fix/2026-08-06-bwrap-private-pid-namespace.zh.md)）。网络限制是否成为自己的旋钮留在 § seam 中开放。
- **哪些工具实际在约束下运行？** 通过 `ctx.shell` 的 OS 子进程——bash 工具及传递性的钩子命令——再加上通过沙箱化 `ctx.fs` 提供方运行的文件系统工具（`read`/`write`/`edit`，见[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.zh.md)）：bash 通过 OS runner 约束，fs 通过进程内路径围栏约束，二者都以同一个 `ctx.sandboxPolicy` 模式为键。web/todo 仍在进程内且不受限制（web 的唯一效果是网络，不在文件效果模式词汇内）。
- **授权的升级会持久化吗？** 不会。授权由发起请求的确切前台或后台调用消费；每个相邻调用保留自己的有效模式。后续的后台拒绝通过 `job_output` 呈现，并且可以作为一次新的精确命令重试的依据。
- **运行时模式切换何时生效？** 一旦其会话事件提交，下一次 pre-step 策略上下文协调与下一次能力解析都会折叠新模式。带来源的上下文消息会记录模型收到的内容，之后的任何拒绝都会在使用点命名同一策略。
- **重启后什么存活——如果运维人员在进程停止期间改了配置默认值呢？** 覆盖从持久化投影缓存恢复并在会话日志上重新折叠（`effective = 投影覆盖 ?? config`），因此恢复的会话以零追赶机制保持其模式；离线漂移的默认值会进入下一条完整策略上下文消息。
- **结果上的 `enforcement: 'partial'` 是什么意思？** 所选后端强制其内核 ABI 管控的子集——例如 ABI v3 之前的 Landlock 不管控路径 truncate——并以结构化方式如此声明而非拒绝主机；探测的报告行区分各种情况。bwrap 和 Seatbelt profile 构造上管控所有承诺的文件操作，因此始终报告 `full`。

## 先例

本设计复制或对比的仓库内先例：

- [能力 seam Agent Note](../architecture/2026-06-13-capability-seams.zh.md)——Service Definition/Service Provider/Consumer 拆分与「不要过早拆分」的时机规则（第二个消费方满足了该规则）。
- `dsh-shell` 的 request/spec 拆分（[bash 词汇目录](../../../../docs/subsystems/shell.zh.md)）——完整的 `sandboxPolicy` 搭载其按调用载体，以及显式 `resolve()` 默认约定。
- [批准 seam Agent Note](2026-07-06-approval-seam.zh.md)——升级请求通过的通道；其应答器 waterfall（瀑布式事件）、审计对和单包理由记录在那里。
- [会话投影作为必需 seam](../architecture/2026-08-19-session-projection-mandatory-seam.zh.md)——旋钮单元注册所在的必需 `ctx.sessionProjections` 注册表；host 读取方经 `stateOf(session, key)` 读取折叠后的覆盖。
- [事件溯源会话](../architecture/2026-06-11-event-sourced-sessions.zh.md)与[独立纯日志事件](../simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md)——投影单元折叠所依赖的日志即存储基础，以及锚定设计遵守的显式持久性边界。
- [拦截扩展点 Agent Note](2026-06-30-interception-extension-points.zh.md)——`tools/pre-execute` 词汇，升级门控刻意不复用它（升级调用没有自己的 pre-execute 时刻）。
