dsh能接生产吗?fail-closed 沙箱到底保不保底
你让 dsh 跑一句 rm -rf,它真敢删吗?你接了个能 kubectl apply 的插件,它会不会半夜把你的集群干没?这是每个想把 agent 接进生产的人,睡前最后一道心理关。
dsh 能不能接生产,不看它能做什么,看它做砸了由谁兜底。这篇文章就把这道关拆开:当模型自己写出一句危险命令,系统拿它怎么办。
0 地图:四道信任边界
dsh 在「AI 碰生产」这条路上,前后拦了四道闸。每一道的设计语言都一样:失败了默认拒绝,而不是默认放行(fail-open)。四道闸的失败默认态全部落在「拒绝」这一侧:
四道闸不是重复设卡,而是分别守住四段职责:sandbox 决定进程能碰哪些文件,approval 决定这次执行要不要人授权,checkpoint 决定副作用落盘发生在哪一步,escalation 决定临时放宽权限能不能成。它们各自有盲区,但失败默认态完全相同,于是单层失误也不会让整体从「拒绝」滑向「放行」。
- sandbox :进程级文件访问控制。
confine()失败则不 spawn(packages/sandbox/sandbox/src/index.ts:152-176)。 - approval :一次性人工或程序授权。
unavailable则 deny(docs/subsystems/approval.md:21)。 - checkpoint :持久化之前的落盘栅栏,拒绝后则 tool body 不执行(
packages/session/session-checkpoint-policy/README.md:23)。 - escalation :更宽权限的有序升级。没有 approval channel 则直接抛错(
packages/sandbox/sandbox/src/escalation.ts:186)。

下面逐道拆解说明。
1 sandbox 抽象层:confine 失败即拒绝
sandbox 的核心是一道抽象契约。SandboxProvider 要求 confine() 必须返回 enforcing argv,否则在 wrap 或 runner 执行时 fail-closed,而静默的 unconfined passthrough 被明确禁止通过。

后端怎么兜底?sandbox-local 对不支持的平台和不可用的 runner 一律返回 SANDBOX_UNAVAILABLE,执行永不静默落到 unconfined。Landlock 原生启动器最狠:内核不能执行就 exit 125,绝不运行命令。这比「尝试降级到非沙箱」安全得多,也是为什么 dsh 敢说「沙箱不可用等于不跑」,而不是「沙箱不可用等于裸跑」。
模式本身只有三档,而且 read-only 是 fail-safe 默认,任何部署想放宽都要显式 opt-in(这一档的设计在第四节的代码块里看得到)。会话级切换走 sandbox/mode 事件:effective = fold(events) ?? deployment default,一次切换只是往 session 日志追加一条事件,重启靠重放恢复,两个会话永远看不到彼此的状态。
ts
/**
* Abstract process-sandbox service. {@link confine} must return enforcing argv
* or fail closed at wrap or runner-execution time; silent unconfined passthrough
* is forbidden. Functional probes arbitrate multi-runner chains and may be
* skipped for a sole candidate, whose own refusal remains the fail-closed end.
*/
export abstract class SandboxProvider extends Service {
abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv
}
2 approval 接缝:闭合且 fail-closed
ApprovalOutcome 是一个闭合枚举:allowed-once | rejected | cancelled | unavailable。callers 在 unavailable 上 fail-closed。ctx.approval.request(req) 返回的也是这四态,缺失或失败的 answerer 一律 fail closed。
关键反直觉点在这里:没有人工、非自身会话、answerer 抛错、或 answerer 不合规,统统变成 unavailable,绝不「那就默认放行」。answerer 是 approval/request 的瀑布式监听器,按 agent 作用域分发,缺失或失败都 fail closed。headless 部署或者拼装不全的部署直接 unavailable,也就是 fail-closed;服务本身永不主动弹窗问人。这决定了「自动化部署里忘了配 approval」不会悄悄变成开门。
还有一层 ask / never 策略。ApprovalPolicy 是 'ask' 或 'never',生效值是最后一条 approval/policy 事件,回退到配置;'never' 在交互分发之前就拒绝。审计记录是 log-only 的,模型只看到最终的 allowed / rejected / cancelled / unavailable 结果,看不到人工授权界面本身。换句话说,approval 这道闸即便在「没人能答」的部署里,也是关着的。
3 checkpoint 边界:model / tool 副作用前 fail-closed
checkpoint 在三个边界生效:model adapter 收到请求之前、top-level tool body 可能产生外部副作用之前、以及每个 agent/pre-step 之前。它的作用是把执行意图先落盘,再让副作用发生。
实现上,策略懒包装 llm/stream,下游 stream 在实时会话的缓冲请求事件落盘之前不会被构造;它在 pre-execute 策略与守卫之后包装 tools/execute,top-level tool body 只在属于自己的那条 recorded call 落盘之后才执行。如果 flush 还没完成就收到取消,包装层直接返回规范的 ABORTED_BEFORE_DISPATCH,不进入 tool body。
拒绝了就是 fail-closed:model 与 tool 边界都拦,adapter 和顶层 tool body 都不执行。意思是:即便 AI 已经「想」执行一个副作用,只要 checkpoint 没过,副作用就不会发生。这里有个诚实的局限要说清:checkpoint 落盘的是「执行意图」,不是通用的 exactly-once 保证;副作用工具应当在提供商支持时把 exec.callId 当作幂等键转发,便于崩溃恢复后判断外部副作用是否真的完成。
4 权限升级也 fail-closed
shell 后端本身 deny-only,不自己谈权限。升级提问在 tool 层驱动,由 approveEscalation 这个有序 fail-closed 序列裁决:先查严格加宽,再解 approval channel,再把每个 outcome 映射到模式或抛错。
ts
export async function approveEscalation<A, C>(
request: EscalationRequest, approval: EscalationApproval<A, C>,
): Promise<SandboxMode> {
const { requestedMode: mode, effectiveMode, justification, subject } = request
// 严格加宽校验:目标模式必须严格宽于当前 effective mode
if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) {
throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`)
}
if (approval.approver === undefined) {
throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval service is composed`)
}
const outcome = await approval.approver.request({ /* ... */ })
switch (outcome) {
case 'allowed-once': return mode as SandboxMode
case 'rejected': throw new Error(`the user rejected escalating this ${subject} to "${mode}"`)
case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`)
case 'unavailable': throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval channel is available`)
}
}
rejected 抛「用户拒绝」,cancelled 抛「已取消」,unavailable 抛「需要 approval 但没有 approval channel 可用」。也就是「想升级权限但没人批」等于拒绝,不是「先给着」。
模式本身的阶梯是写死的。沙箱只有三档,read-only 是 fail-safe 默认,任何部署想放宽都要显式 opt-in:
ts
export const SANDBOX_MODES: readonly SandboxMode[] =
['read-only', 'workspace-write', 'danger-full-access']
// packages/sandbox/sandbox/src/escalation.ts:28-31,41
export const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
'read-only': ['workspace-write', 'danger-full-access'],
'workspace-write': ['danger-full-access'],
}
export const ESCALATION_TARGETS: readonly SandboxMode[] =
['workspace-write', 'danger-full-access']
// packages/sandbox/sandbox-policy/src/index.ts:94(fail-safe 默认)
mode: z.union(['read-only', 'workspace-write', 'danger-full-access'] as const).default('read-only')
5 决策表:4 类场景该不该上

把上面的机制归纳成一张表。
| 场景 | sandbox 模式 | approval | 结论 | 依据 |
|---|---|---|---|---|
| 本地开发、跑只读分析 | read-only(默认) |
不需要 | 可上 | 默认即最严,失败也只是拒绝 |
| 需要写工作区产物(构建缓存等) | workspace-write |
看部署 | 评估后上 | 仍需显式 opt-in,且 confine 失败 fail-closed |
自动改生产库 / 直接 kubectl apply |
danger-full-access |
必须有 | 暂不上,先评估 | 强副作用,缺 approval 即 unavailable 拒绝 |
| 多 agent 编排互相触发 | 继承父会话边界 | 各自 fail-closed | 评估后上 | 子会话边界由 delegation 注入,互不可见 |
注意:这些都是机制推论,不是一刀切的判决,需要你按自家部署代入。第三、四行的要点是「沙箱管不住读 secrets」,别误以为 read-only 能防数据泄露。
把四道闸叠在一起看,fail-closed 保命的含义就清楚了:它不保证 AI 永远聪明,它保证 AI 犯傻时系统站在你这边。任何一道闸单独看都有盲区,但四道闸的设计语言高度一致,全是「失败时拒绝」。
sandbox 兜不住读,但写被卡死;approval 管不住「没人能答」时的放行,但缺失 answerer 就变成 unavailable;checkpoint 不保证 exactly-once,但副作用发生前先把意图落盘;escalation 不自动许可,但没人批就抛错。当 AI 去碰生产,即便每一层都出错,失控域仍然被框在「拒绝」这一侧,而不是滑向「放行」。
这正是 dsh 敢谈「接生产」的底气:不是因为它让 AI 更聪明,而是因为它把「AI 失控时谁兜底」这件事,用四道 fail-closed 闸从机制上钉死了。
6 把你的工具塞进边界
这也是本专栏的拐点:从「工具怎么接」切到「接进来之后谁来兜底」。照着四道闸走一遍自检框架,你自己的工具也能照抄。
给你一个可照抄的自检四问,拿去套你自己的工具:
- 它的 argv 最终能写哪些路径?(对应 sandbox 模式)
- 这次执行需不需要人来手动确认?(对应 approval,缺了是不是
unavailable) - 它的副作用是不是强副作用,checkpoint 有没有先落盘?(对应 checkpoint)
- 它会不会临时要更宽权限,没人批时是不是拒绝?(对应 escalation)
四个回答都是「框得住」才上,有一处是「默认放行」就先别接。把你的工具想清楚它的 argv,用上面四道闸自检一遍。你最想接哪个工具进 dsh?评论区说。