DeepSeek Harness 源码解读(八):文件、命令、审批与沙箱如何协作
DeepSeek Harness 让模型读文件、改代码、运行命令时,安全不是一个开关,而是一组按能力分层的约束。本文从
ctx.fs、ctx.shell、ctx.sandbox、ctx.approval和两个 guard 插件出发,解释一次危险操作怎样被解析、审批、限制、执行并留下可追溯结果。
项目地址:https://github.com/deepseek-ai/deepseek-harness
源码基线:仓库版本 0.1.0-rc.5。重点路径是 packages/fs、packages/shell、packages/sandbox、packages/interaction/user-approval、packages/guard 和 packages/e2b。
一、本章要回答的问题
一个能修改工作区的 Agent 至少面对四类风险:路径可能越界,命令可能触碰不该触碰的文件,用户可能没有批准高风险操作,模型还可能因为超时或重复调用把同一动作无限执行。把这些风险都交给一个"安全插件"并不能解决问题,因为文件写入、子进程创建、用户决定和回合控制分别发生在不同的生命周期。
本文要回答五个问题:
read-only、workspace-write和danger-full-access是在哪里解析的?- 文件工具的路径检查和命令沙箱为什么必须分开?
- 审批没有 UI、用户取消或 answerer 抛错时,系统会怎样处理?
- E2B 远端执行世界与本地沙箱之间是什么关系?
- 超时和重复调用 guard 是安全边界,还是运行卫生机制?
二、核心结论:安全由多层约束共同完成
DeepSeek Harness 当前的安全模型可以概括成四层。
第一层是能力契约。 @deepseek-ai/dsh-fs 和 @deepseek-ai/dsh-shell 只定义"文件系统"和"命令执行"能做什么,消费者不会直接依赖某个本地实现。FsTargetKey、FsVersion、SandboxExecutionPolicy 等类型把目标身份、版本和每次调用的策略明确传递下去。
第二层是调用前的策略。 文件工具把会话工作目录和有效模式解析成一次调用的 SandboxExecutionPolicy。fs-sandbox 在真正写入前重新解析路径并做 containment 检查;bash-sandbox 则在 spawn 前把原始 argv 包在 bwrap、Landlock、Seatbelt 或 Windows ACL runner 后面。
第三层是审批与失败策略。 ctx.approval 只授予当前请求的一次性结果,并把 approval/asked 与 approval/decided 写入会话。没有 answerer、answerer 抛错、策略为 never 或信号取消,都不会默默放行。
第四层是运行卫生。 timeout-policy 用工具声明的 timeoutMs 发出协作式取消;repeat-tool-reminder 统计同一 Agent 的连续重复调用并注入提醒。它们通常不替代沙箱,但能阻止"已经失败却继续重试"的运行失控。
因此,"安全"不是某个工具内部的一段 if,而是从服务契约一直延伸到内核执行器和持久化审计的组合。
三、相关包与源码入口
| 层次 | 入口 | 负责什么 |
|---|---|---|
| 文件服务定义 | packages/fs/fs/src/index.ts |
解析目标、读写、编辑、目录和 fs/* 事件 |
| 本地文件策略 | packages/fs/fs-sandbox/src/index.ts |
对写入和编辑执行按调用 containment 检查 |
| 文件模型工具 | packages/fs/tool-fs/src |
参数、窗口、错误渲染和一次批准的更宽重试 |
| Shell 服务定义 | packages/shell/shell/src/index.ts |
resolve、前台 run、后台 start 的契约 |
| 本地命令沙箱 | packages/shell/bash-sandbox/src/index.ts |
选择 runner 并返回被包裹的 argv |
| 沙箱公共契约 | packages/sandbox/sandbox/src/index.ts |
模式、策略、执行完整性和 SANDBOX_UNAVAILABLE |
| 审批服务 | packages/interaction/user-approval/src/index.ts |
策略、answerer waterfall、审计事件 |
| 超时 guard | packages/guard/timeout-policy/src/index.ts |
为声明了预算的工具替换超时结果 |
| 重复调用 guard | packages/guard/repeat-tool-reminder/src/index.ts |
按 Agent 记录同参数连续调用并注入提醒 |
| 远端执行世界 | packages/e2b/e2b/src/index.ts、packages/e2b/subprocess-e2b/src/index.ts |
让文件和进程适配器共享一个 E2B 沙箱 |
这里有一个容易忽略的分工:dsh-fs-sandbox 不负责模型提示和审批,dsh-bash-sandbox 也不负责解释工具参数。模型面对的是 dsh-tool-fs 或 dsh-tool-bash,它们把同一个会话策略传递给底层能力。
四、一次受限执行的真实调用链
下面的调用链以"模型请求修改工作区外的文件"为例。文件工具先解析会话 cwd,再由能力提供者决定是否执行;需要人工批准时,审批事件发生在同一回合内。

图中"更宽 policy"不是永久切换。SandboxExecutionPolicy 带着本次调用的 mode、workspaceRoot 和可选 sessionId,提供者只处理已经解析好的策略;会话策略的默认值和升级规则由更上层的 policy/tool 插件负责。
五、文件系统:受信代码中的策略围栏
5.1 ctx.fs 只定义稳定的文件操作
packages/fs/fs/src/index.ts 的 FileSystem 定义了 resolve、stat、lstat、readText、streamText、readBytes、listDir、writeText 和 editText 等原语。目标由 FsTarget 表示,其中 targetKey 是品牌化的不透明身份,只有 displayPath 给模型和 UI 展示。
这个设计限制了消费者自行拼路径的冲动。消费者拿到目标后,调用 processPath() 或 fileUrl(),而不是解析 targetKey。远端 provider 可以把 target key 实现成文件 ID 或 workspace URI,上层工具不需要改写。
写入还带有显式的版本意图:
ts
export type FsWriteIntent =
| { kind: 'createIfAbsent' }
| { kind: 'replaceIfVersion'; version: FsVersion }
省略 expected 只表示不要求版本前置条件,不代表放弃原子性。editText 的版本检查、字面量匹配和重写仍在 provider 的一个临界区内完成。这使得两个并发编辑可以明确区分"赢得更新"和 FS_STALE_VERSION,而不是把一次读和一次写拼成不可追踪的竞态。
5.2 fs-sandbox 检查的是目标路径,不是内核
packages/fs/fs-sandbox/src/index.ts 的 SandboxedFileSystem 继承 LocalFileSystem,只覆盖 writeText 和 editText。read-only 直接抛出 FS_SANDBOX_DENIED;workspace-write 会重新调用 resolve(target.displayPath),再检查新目标是否位于 writableRoots(policy) 之下,最后把这个"刚检查过的目标"交给父类写入。
关键代码的意图是:
ts
const fresh = await this.resolve(target.displayPath)
let contained = false
for (const root of writableRoots(policy)) {
if (await isPathUnder(fresh.targetKey, root)) {
contained = true
break
}
}
if (!contained) {
throw new FsError('file access denied under workspace-write mode', 'FS_SANDBOX_DENIED')
}
return fresh
这是一道受信代码中的策略围栏,不是对恶意进程的内核隔离。源码 README 明确把残余的 resolve-to-syscall TOCTOU 风险限定在当前威胁模型内;需要隔离"不受信代码"时,应当使用 ctx.shell 的进程沙箱。这个区分非常重要:把路径 containment 宣称为完整沙箱,会导致错误的安全承诺。
5.3 文件工具把拒绝转换成模型可处理的结果
packages/fs/tool-fs/src/sandbox.ts 和写入工具负责把 FS_SANDBOX_DENIED 转成结构化错误、[sandbox: ...] 文本和一次批准的升级提示。这样模型知道失败属于策略拒绝,而不是把它当作文件不存在继续盲目重试。若审批没有批准,工具不会直接调用 danger-full-access。
六、Shell:在 spawn 前限制子进程的文件效果
6.1 Shell provider 先区分 request 和 spec
packages/shell/shell/src/index.ts 明确要求消费者先调用 resolve(request),再把完整 ShellExecSpec 交给 run() 或 start()。实现可以在这一步填充 cwd、超时和输出上限,并对配置做上限裁剪。
run() 对非零退出、超时和取消采取"返回结果而不是 reject"的语义;真正的基础设施失败才会 reject。后台 start() 立即返回句柄,组合销毁时由 subprocess provider 停止并等待仍在运行的进程。
6.2 ctx.sandbox.confine() 返回新的 argv
packages/sandbox/sandbox/src/index.ts 的 SandboxProvider.confine(argv, policy) 要么返回被包裹的 ConfinedArgv,要么以 SANDBOX_UNAVAILABLE 失败。返回值不仅有 argv,还有 enforcement、拒绝诊断和 runner 失败规则,让 Shell 消费者能区分"命令运行后被拒绝"和"沙箱 runner 根本没有启动"。
本地 provider 按平台选择 runner:Linux 优先 bwrap,必要时使用 Landlock;macOS 使用 Seatbelt;Windows 使用 ACL restricted-token runner。调用方看到的形状始终是:
text
[runner, ...policy arguments, "--", ...original argv]
原始命令被完整放在 -- 之后。runner 先建立文件效果约束,再执行目标程序,子进程继承这套限制,而 Harness 主进程不被反向锁住。
6.3 fail-closed 不是口号
当 read-only 或 workspace-write 被请求但没有可用 runner,SandboxUnavailableError 会被抛出。它不会悄悄退回无沙箱执行;只有调用方明确选择 danger-full-access,才表示接受没有文件约束的执行世界。
这里存在一个现实代价:不同操作系统和较旧内核可能只能报告 partial enforcement。需要绝对边界的消费者不能把 partial 当作 full。安全设计要把"无法完全强制"作为事实返回,而不是把平台差异藏在成功状态里。
七、审批:让"人是否同意"也成为可恢复事实
7.1 ctx.approval 的结果只有四种
packages/interaction/user-approval/src/types.ts 定义了四个闭合结果:allowed-once、rejected、cancelled、unavailable。只有 allowed-once 是授权,其余都不能继续放大能力。
ApprovalService.request() 要求会话处在打开的 turn 内,然后追加:
approval/asked:记录工具名、可选 call id 和原因;- 调用
approval/requestwaterfall; - 把 answerer 的异常、缺失或非法返回归一成
unavailable; approval/decided:记录最终结果。
如果请求在 turn 外发起,服务会在追加任何事件前抛错。原因不是 UI 限制,而是审批问答对必须被同一个持久回合包住;否则恢复时无法判断一个孤立事件是否是崩溃尾部。
7.2 never 策略适合无人值守场景
ApprovalPolicy 只有 ask 和 never。never 在 decide() 内部先于 answerer waterfall 判断,每次需要审批的操作都会稳定得到 rejected。这为 CI、headless 批处理和没有交互界面的部署提供了确定行为。
策略还会通过 approval/policy 事件持久化,并由 System Prompt 贡献当前会话上下文。切换策略时,服务向 Agent 注入一条来源为插件的消息,让模型知道后续动作会如何处理。这是"策略状态"和"用户可见提示"各有记录的例子。
八、E2B:替换执行世界,不是绕过安全层
packages/e2b/e2b/src/index.ts 创建一个共享的 E2B Sandbox,先创建 cwd 和私有的 .dsh-e2b 运行目录,确认后设置为 0700。subprocess-e2b 和 fs-e2b 都注入同一个 ctx.e2b,因此文件操作和进程操作看到的是同一台远端 Linux 环境。
这属于 capability seam 的 provider 替换:原有 dsh-tool-fs 和 dsh-tool-bash 不需要 E2B 分支,只要把 ctx.fs、ctx.subprocess 换成对应 provider。E2B 仍然保留在 Harness 外的会话、日志和模型请求;它不是整个 Agent 的容器化运行时。
源码还主动处理了几项边界:控制命令使用随机 HOME,避免 E2B 固定的 login shell 读取可变用户配置;subprocess provider 会清理环境变量并管理远端进程组;服务销毁时停止进程、等待句柄,再删除 Sandbox。与此同时,README 也明确列出限制:网络策略取决于 E2B 基础镜像,控制状态与普通沙箱用户同 UID,私有目录权限不能替代真正的用户隔离。
九、Guard:限制失控,不代替授权
9.1 timeout-policy 是协作式预算
packages/guard/timeout-policy/src/index.ts 从工具注册表读取 ToolDefinition.timeoutMs,用 deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT') 替换派发期间的信号。工具遵守信号并返回后,插件确认确实是自己的 deadline 触发,才替换为结构化的 TOOL_TIMEOUT 结果。
它不负责强杀进程,也不会给未声明预算的工具套一个全局默认值。因此只有能够向下转发 AbortSignal 的工具才应该声明 timeoutMs。Shell 的进程级超时属于 Shell provider 自己的语义,两者不能混为一谈。
9.2 repeat-tool-reminder 是有来源的模型上下文
packages/guard/repeat-tool-reminder/src/index.ts 在 tools/post-execute 观察调用:它按 Agent、工具名和深度排序后的完整 JSON 参数计算 key,达到配置阈值后注入一条 source.kind = 'plugin' 的 UserMessage。用户插话会清空该 Agent 的连续链;被排除的工具既不计数也不重置。
这个插件默认只提醒,不 veto。即使下游把调用 block,它仍把提醒附加到结果上下文中,并调用 next() 把决定交给后续 listener。它的价值是减少无效循环,而不是放宽文件或命令权限。
十、设计亮点、约束与代价
这套分层设计有三个明显优点:
- 策略可替换。 文件 provider、Shell provider 和远端 E2B provider 共用同一组 Service Definition;工具不绑定本地实现。
- 拒绝可解释。 文件拒绝有
FS_SANDBOX_DENIED,沙箱不可用有SANDBOX_UNAVAILABLE,审批不可用有unavailable,模型得到的是可路由的结果而不是一段无法分类的字符串。 - 权限范围短。 升级由一次调用携带,审批结果是
allowed-once,不会把会话永久切换为危险模式。
代价也必须正面写出:
- 路径 containment 是 trusted code fence,不是内核隔离;
- 沙箱完整性可能是
partial,部署必须按目标平台评估; - Guard 的 timeout 是 cooperative,工具不响应信号就不会自动结束;
- E2B POC 仍受网络、同 UID 控制文件和 SDK 输出保留等限制;
- 安全行为分散在工具、服务、事件和 provider 中,阅读门槛高于单体 CLI。
十一、实际使用或扩展方式
如果要给一个 headless profile 增加受限执行,优先组合现有 provider,而不是修改 Agent Loop:
yaml
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
defaultMode: workspace-write
- id: fs
name: '@deepseek-ai/dsh-fs-sandbox'
config:
cwd: /workspace/project
- id: shell
name: '@deepseek-ai/dsh-bash-sandbox'
config:
cwd: /workspace/project
- id: approval
name: '@deepseek-ai/dsh-user-approval'
config:
policy: never
要改成远端执行,只需让同一 profile 挂载 dsh-e2b、dsh-fs-e2b 和 dsh-subprocess-e2b,再移除本地 provider 的重复注册。要增加新的权限决策,可实现 approval/request answerer;要增加新的模型可见 guard,应在 tools/post-execute 注入带来源的上下文,并保证它调用 next()。
扩展时有两条硬规则:
- 不要把
targetKey当作路径解析,也不要在工具里绕过ctx.fs或ctx.shell; - 不要把"不确定时允许"当作兼容策略。无法确认沙箱、审批或执行状态时,应返回结构化拒绝或不可用结果。
十二、小结与下一篇衔接
DeepSeek Harness 的安全边界不是一层"禁止执行"的总开关。文件系统在 provider 内做稳定身份、版本和原子操作;文件沙箱做受信代码中的 containment;命令沙箱在 spawn 前建立进程继承的文件效果约束;审批把人类决定变成 turn 内的审计事件;Guard 限制超时和重复调用;E2B 通过替换 provider 把同一套工具搬到远端执行世界。
这些层次共同遵守一个事实:无法确认约束存在时,不应假装执行是安全的。下一篇将离开单个能力,横向比较这种"没有特权核心"的组合方式适合哪些产品,以及怎样从 headless profile 开始实际落地。