Trust / Permission / Sandbox / Network:四层防线为什么不能合成一个开关
Agent 能跑 shell、能写文件、能联网。DSH 不打算用一把总钥匙管住它——它把防线拆成四层:Trust(要不要问人)、Permission(这一轮放行什么)、Sandbox(文件系统写到哪)、Network(联网到哪)。每层单独 fail-closed,任一层抬杠,动作就停。这一章讲四层各自的边界与它们为什么互不信任。
Written in Chinese
第 0 秒,agent 要执行一条 shell:curl http://internal/secret | sh。这一个动作同时穿越了四件事:要不要先问人?这一轮它被允许做什么?文件系统让它写到哪?网络让它连到哪?
很多系统的做法是设一个大开关:「沙箱开/关」「联网开/关」。DSH 不要这种东西。因为一个总开关一旦被某个配置项翻成「全开」,下面四件事就全部裸奔——一层失守,全盘沦陷。DSH 的选择是把防线拆成四层,每层独立 fail-closed,互不信任:
| 层 | 管什么 | fail-closed 的样子 |
|---|---|---|
| Trust | 这个动作要不要先问人 | ask 没人答 → unavailable,不擅自同意 |
| Permission | 这一轮放什么、拒什么 | 未放行即拒 |
| Sandbox | 文件系统能写到哪 | 无后端能执行 → SANDBOX_UNAVAILABLE,不裸奔 |
| Network | 能联网到哪 | 非 HTTP(S)/带凭证/跨源重定向 → 直接拒 |
这一章接第 54 章:那章讲每个包自带守卫;这一章讲危险能力本身怎么被分层拦住。
Trust 层:ask 没人答,就当没问到人
packages/interaction/user-approval 是「要不要问人」这一层。它的策略被刻意压到最简——ApprovalPolicy 只有两个值:
'ask'(默认):把问题委派给组合进来的 answerers;如果一个 answerer 都没组合,链兜底到 fail-closed 的'unavailable'。'never':永不问任何人,每个 ask 直接结算为'rejected'。
ApprovalOutcome 四个:allowed-once / rejected / cancelled / unavailable。注意这个集合里没有「默认同意」——ask 的语义是「等人裁决」,不是「赌用户会同意」。没有 answerer 意味着这个环境根本没人能回答,正确做法是停下来报 unavailable,而不是替用户点头。
为什么 ask 必须落在开放 turn 内? 因为审批是挂起等待的:tool 发起 ask 后要把自己挂起来,直到人回来 decide。而挂起只能发生在「turn 还没闭合」的时候——turn 一旦结算,会话状态就定格了,你不能在一个已经结束的 turn 里再插一笔未决审批。所以 ask 发起时要先校验当前是否在开放 turn(turn/start 未闭合),越界即拒。
为什么 abort 要落成 cancelled? 用户可以在问题还挂着的时候撤销它。这时请求不该停在「pending」状态让人以为它还在等,而要干净地结算成 'cancelled'——撤销即终态,不留下半截审批污染审计。
本节源码依据(4 处)
Sandbox 层:策略按调用携带,默认只读,挡不住就不跑
文件系统这一层是 packages/sandbox。SandboxMode 三个值:
| 模式 | 文件行为 |
|---|---|
read-only | 只允许必要的 sink(如 /dev/null),不写任何业务文件 |
workspace-write | 额外允许写工作区与后端定义的临时区 |
danger-full-access | 绕过所有限制——名字里就带着警告 |
'read-only' 是部署的 fail-safe 默认。SandboxPolicyService 的 config 把 mode 默认值钉成 'read-only'——你不显式放宽,它就只读。
为什么策略是按调用携带(per-call),而不是钉在 provider 上? 注释说得直白:两个消费者可以在同一瞬间处于不同策略——bash 在 read-only 下跑,而一个被囚禁的子 agent 需要自己的状态目录可写。一个审批通过的放宽重试,本质是一次带更宽策略的新调用。provider 把策略当成「已经完全指定」的输入,不在自己内部瞎猜默认值。
resolve() 的取值有三级优先级:
mode = request.mode ?? session override ?? defaultMode
调用者显式传的最高;其次是会话日志里记录的上一次放宽(session override);最后才落到部署默认 read-only。放宽是有记忆的:你批准过一次 workspace-write,同一会话后续不再重复问。
为什么挡不住就要 fail-closed? provider.confine 在没有任何后端能执行所请求模式时,抛 SANDBOX_UNAVAILABLE——错误码经 tool/result 结构化通道带出去。绝不「实在关不上就裸跑吧」。还有一层更细:enforcement 分 full 和 partial——partial 意味着某个后端或旧内核 ABI 管不全承诺的文件效果,要绝对边界的调用不能把 partial 当 full 用。
本节源码依据(6 处)
Network 层:联网要过的几道预检
网络这一层是 packages/web/web-fetch-http。它在真正发请求之前做一串预检,任何一道不过就直接拒:
| 预检 | 规则 | 拒绝码 |
|---|---|---|
| 协议 | 只放 HTTP(S) | 非协议即拒 |
| URL 长度 | 上限 2048 字符 | WEB_INVALID_URL |
| 内嵌凭证 | URL 里不许带 user:pass@ | WEB_BLOCKED_URL |
| 重定向 | 仅同源跟随 | 跨源 WEB_REDIRECT_BLOCKED |
| 凭证 | 不带浏览器 ambient cookie | — |
为什么要管内嵌凭证? http://user:pass@host 这种 URL 会把密码藏在地址里——它会被记进日志、进 shell 历史、进错误消息。预检直接把这种 URL 拒掉(credentials in URLs are not allowed → WEB_BLOCKED_URL),从源头不让凭证沿着请求流漂移。
为什么重定向要卡同源? 你请求 A.com,服务器回一个 302 跳 B.com。如果无脑跟随,agent 就被悄悄带到了另一个站——可能是钓鱼,可能把请求体发错地方。isSameOrigin 判一下:同源才跟,跨源就停在 WEB_REDIRECT_BLOCKED。
为什么不带 ambient cookie? 因为 agent 发请求时如果自动带上浏览器那套登录态,它访问任何站都会以「已登录的你」的身份行事——这等于把你的会话权限交给了 agent。DSH 的网络层故意不沾 ambient 凭证:每次请求都是匿名、显式的。
本节源码依据(4 处)
平台链与升级:每个 OS 一个后端,拒了就升一级问人
沙箱不是一个后端打天下。sandbox-local 按平台选 runner 链:Linux 先试 bwrap 再退 Landlock,macOS 用 Seatbelt,Windows 用 windows-acl。每条 rung 的 enforcement 还不一样——windows-acl 这条 rung 自报 partial,因为它在交出写权限后就不再自己管 DACL 了。这就是上一节说的「partial 不能当 full」的具体出处:不是所有平台 rung 都给你完整边界。
被拒之后怎么办?模型不是干瞪眼。escalation.ts 给了一条严格更宽的升级阶梯:WIDER_MODES 定义当前模式能升到哪,ESCALATION_TARGETS 钉死可升目标只有 workspace-write 与 danger-full-access 两档。一次被拒后,模型可以请求「最窄的那个更宽模式」重试一次,附带 justification——而这一次重试会触发一次人工审批。
| 要素 | 值 |
|---|---|
| Linux 后端链 | bwrap → Landlock |
| macOS 后端 | Seatbelt |
| Windows 后端 | windows-acl(自报 partial) |
| 升级目标 | workspace-write / danger-full-access |
| 升级前提 | 带 justification,触发人工审批 |
为什么升级必须「最窄更宽」而不是一步全开? 因为放宽权限是有成本的,最小必要原则要求你只要能写工作区就别请求 danger-full-access。升级阶梯把这个偏好编码进了表格:从 read-only 出发,第一档就是 workspace-write,够用就别再往上爬一步。
本节源码依据(3 处)
为什么四层不能合成一个总开关
最后回到那个反问:能不能设一个「安全全开/全关」开关?
不能。原因是四层的失败模式互不一样:
| 维度 | 一个总开关 | 四层分开 |
|---|---|---|
| 一层失守 | 全部裸奔 | 只破一段,其余仍在 |
| 配置粒度 | 要么全开要么全关 | 联网可以禁但 shell 仍问人 |
| 错误定位 | 不知道哪层拦的 | 每层有自己的拒绝码 |
| 默认值 | 一个默认值管四件事 | 各层独立 fail-closed |
四层互不信任的意义在于:任何一层都不需要信任另外三层是对的。 Trust 层不需要相信沙箱一定拦得住写文件——它该问还是问。Sandbox 层不需要相信网络层一定拦得住外连——它该只读还是只读。每一层独立地把自己能看到的那一段 fail-closed 掉,系统的安全性就是这四层的最弱环,而不是被一个开关同时拉低的全局。
这也正是第 53 章快照测试与第 54 章包级守卫的延伸:守卫不是「大检查」,是分散到每个边界的小闸门——安全也一样。
本节源码依据(3 处)
小结
- 四层互不信任:Trust 问不问人、Permission 放什么、Sandbox 写到哪、Network 连到哪,各管一段。
- Trust fail-closed:ask 无 answerer 落 unavailable,不替用户点头;ask 限开放 turn,abort 落 cancelled。
- Sandbox 默认只读:三模式 read-only 是 fail-safe;策略 per-call 携带,resolve 三级优先级;挡不住即 SANDBOX_UNAVAILABLE 不裸奔;partial 不当 full。
- Network 预检:HTTP(S) only、URL 封顶 2048、禁内嵌凭证、跨源重定向阻断、不带 ambient cookie。
- 不合成开关:一层失守不再全盘沦陷,每层独立 fail-closed,最弱环即系统边界。
下一章是全书收束:怎么用一份固定快照把整本书钉在某个 commit 上——第 56 章。
只读验证
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
git -C "$repo" rev-parse HEAD # 应为 477b4f420553e8a52c2fbccc464d7561b239c443
grep -n "ApprovalPolicy = " "$repo/packages/interaction/user-approval/src/index.ts"
grep -n "SANDBOX_UNAVAILABLE" "$repo/packages/sandbox/sandbox/src/index.ts"
grep -n "WEB_BLOCKED_URL\|2048" "$repo/packages/web/web-fetch-http/src/policy.ts"