DeepSeek Harness 源码解读(八):文件、命令、审批与沙箱如何协作

DeepSeek Harness 源码解读(八):文件、命令、审批与沙箱如何协作

DeepSeek Harness 让模型读文件、改代码、运行命令时,安全不是一个开关,而是一组按能力分层的约束。本文从 ctx.fsctx.shellctx.sandboxctx.approval 和两个 guard 插件出发,解释一次危险操作怎样被解析、审批、限制、执行并留下可追溯结果。

项目地址:https://github.com/deepseek-ai/deepseek-harness

源码基线:仓库版本 0.1.0-rc.5。重点路径是 packages/fspackages/shellpackages/sandboxpackages/interaction/user-approvalpackages/guardpackages/e2b

一、本章要回答的问题

一个能修改工作区的 Agent 至少面对四类风险:路径可能越界,命令可能触碰不该触碰的文件,用户可能没有批准高风险操作,模型还可能因为超时或重复调用把同一动作无限执行。把这些风险都交给一个"安全插件"并不能解决问题,因为文件写入、子进程创建、用户决定和回合控制分别发生在不同的生命周期。

本文要回答五个问题:

  1. read-onlyworkspace-writedanger-full-access 是在哪里解析的?
  2. 文件工具的路径检查和命令沙箱为什么必须分开?
  3. 审批没有 UI、用户取消或 answerer 抛错时,系统会怎样处理?
  4. E2B 远端执行世界与本地沙箱之间是什么关系?
  5. 超时和重复调用 guard 是安全边界,还是运行卫生机制?

二、核心结论:安全由多层约束共同完成

DeepSeek Harness 当前的安全模型可以概括成四层。

第一层是能力契约。 @deepseek-ai/dsh-fs@deepseek-ai/dsh-shell 只定义"文件系统"和"命令执行"能做什么,消费者不会直接依赖某个本地实现。FsTargetKeyFsVersionSandboxExecutionPolicy 等类型把目标身份、版本和每次调用的策略明确传递下去。

第二层是调用前的策略。 文件工具把会话工作目录和有效模式解析成一次调用的 SandboxExecutionPolicyfs-sandbox 在真正写入前重新解析路径并做 containment 检查;bash-sandbox 则在 spawn 前把原始 argv 包在 bwrap、Landlock、Seatbelt 或 Windows ACL runner 后面。

第三层是审批与失败策略。 ctx.approval 只授予当前请求的一次性结果,并把 approval/askedapproval/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.tspackages/e2b/subprocess-e2b/src/index.ts 让文件和进程适配器共享一个 E2B 沙箱

这里有一个容易忽略的分工:dsh-fs-sandbox 不负责模型提示和审批,dsh-bash-sandbox 也不负责解释工具参数。模型面对的是 dsh-tool-fsdsh-tool-bash,它们把同一个会话策略传递给底层能力。

四、一次受限执行的真实调用链

下面的调用链以"模型请求修改工作区外的文件"为例。文件工具先解析会话 cwd,再由能力提供者决定是否执行;需要人工批准时,审批事件发生在同一回合内。

flowchart TD A[模型 tool call] --> B[dsh-tool-fs / dsh-tool-bash] B --> C[读取 session cwd 与 sandbox policy] C --> D{需要升级权限?} D -- 否 --> E[按当前 policy 执行] D -- 是 --> F[ctx.approval.request] F --> G{allowed-once?} G -- 否 --> H[返回拒绝或 unavailable] G -- 是 --> I[单次调用使用更宽 policy] E --> J{能力类型} I --> J J -- 文件写入 --> K[fs-sandbox 重新 resolve + containment] J -- 命令执行 --> L[bash-sandbox 包裹 argv] K --> M[ctx.fs.writeText/editText] L --> N[ctx.subprocess.spawn] M --> O[结构化 tool/result] N --> O O --> P[session log] P --> Q[模型可见结果]

图中"更宽 policy"不是永久切换。SandboxExecutionPolicy 带着本次调用的 modeworkspaceRoot 和可选 sessionId,提供者只处理已经解析好的策略;会话策略的默认值和升级规则由更上层的 policy/tool 插件负责。

五、文件系统:受信代码中的策略围栏

5.1 ctx.fs 只定义稳定的文件操作

packages/fs/fs/src/index.tsFileSystem 定义了 resolvestatlstatreadTextstreamTextreadByteslistDirwriteTexteditText 等原语。目标由 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.tsSandboxedFileSystem 继承 LocalFileSystem,只覆盖 writeTexteditTextread-only 直接抛出 FS_SANDBOX_DENIEDworkspace-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.tsSandboxProvider.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-onlyworkspace-write 被请求但没有可用 runner,SandboxUnavailableError 会被抛出。它不会悄悄退回无沙箱执行;只有调用方明确选择 danger-full-access,才表示接受没有文件约束的执行世界。

这里存在一个现实代价:不同操作系统和较旧内核可能只能报告 partial enforcement。需要绝对边界的消费者不能把 partial 当作 full。安全设计要把"无法完全强制"作为事实返回,而不是把平台差异藏在成功状态里。

七、审批:让"人是否同意"也成为可恢复事实

7.1 ctx.approval 的结果只有四种

packages/interaction/user-approval/src/types.ts 定义了四个闭合结果:allowed-oncerejectedcancelledunavailable。只有 allowed-once 是授权,其余都不能继续放大能力。

ApprovalService.request() 要求会话处在打开的 turn 内,然后追加:

  1. approval/asked:记录工具名、可选 call id 和原因;
  2. 调用 approval/request waterfall;
  3. 把 answerer 的异常、缺失或非法返回归一成 unavailable
  4. approval/decided:记录最终结果。

如果请求在 turn 外发起,服务会在追加任何事件前抛错。原因不是 UI 限制,而是审批问答对必须被同一个持久回合包住;否则恢复时无法判断一个孤立事件是否是崩溃尾部。

7.2 never 策略适合无人值守场景

ApprovalPolicy 只有 askneverneverdecide() 内部先于 answerer waterfall 判断,每次需要审批的操作都会稳定得到 rejected。这为 CI、headless 批处理和没有交互界面的部署提供了确定行为。

策略还会通过 approval/policy 事件持久化,并由 System Prompt 贡献当前会话上下文。切换策略时,服务向 Agent 注入一条来源为插件的消息,让模型知道后续动作会如何处理。这是"策略状态"和"用户可见提示"各有记录的例子。

八、E2B:替换执行世界,不是绕过安全层

packages/e2b/e2b/src/index.ts 创建一个共享的 E2B Sandbox,先创建 cwd 和私有的 .dsh-e2b 运行目录,确认后设置为 0700subprocess-e2bfs-e2b 都注入同一个 ctx.e2b,因此文件操作和进程操作看到的是同一台远端 Linux 环境。

这属于 capability seam 的 provider 替换:原有 dsh-tool-fsdsh-tool-bash 不需要 E2B 分支,只要把 ctx.fsctx.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.tstools/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-e2bdsh-fs-e2bdsh-subprocess-e2b,再移除本地 provider 的重复注册。要增加新的权限决策,可实现 approval/request answerer;要增加新的模型可见 guard,应在 tools/post-execute 注入带来源的上下文,并保证它调用 next()

扩展时有两条硬规则:

  1. 不要把 targetKey 当作路径解析,也不要在工具里绕过 ctx.fsctx.shell
  2. 不要把"不确定时允许"当作兼容策略。无法确认沙箱、审批或执行状态时,应返回结构化拒绝或不可用结果。

十二、小结与下一篇衔接

DeepSeek Harness 的安全边界不是一层"禁止执行"的总开关。文件系统在 provider 内做稳定身份、版本和原子操作;文件沙箱做受信代码中的 containment;命令沙箱在 spawn 前建立进程继承的文件效果约束;审批把人类决定变成 turn 内的审计事件;Guard 限制超时和重复调用;E2B 通过替换 provider 把同一套工具搬到远端执行世界。

这些层次共同遵守一个事实:无法确认约束存在时,不应假装执行是安全的。下一篇将离开单个能力,横向比较这种"没有特权核心"的组合方式适合哪些产品,以及怎样从 headless profile 开始实际落地。

相关推荐
阿里云云原生1 小时前
经验自进化:自动挖掘经验资产,消融实验验证真实收益丨AgentLoop 数据飞轮实践(五)
agent
ovO2 小时前
DeepSeek Harness 源码解读(六):Provider、Consumer 与能力接缝
开源·agent
李燚2 小时前
把规则搬回家:三个 BC 的贫血→充血重构实录(第103篇)
golang·agent·ddd·领域驱动设计·eino·deepflux·eino adk
2601_962304913 小时前
2026年健康科普视频怎么制作:一条开源工具链从全手动到半自动的工程复盘
开源·音视频
ClouGence3 小时前
CloudDM 支持达梦、KingbaseES、GoldenDB,国产数据库也能统一管起来
数据库·sql·开源
zzzll11113 小时前
Langfuse:开源 LLM 可观测性与评估平台实战指南
开源
举个栗子。3 小时前
DBX:20MB 驾驭 90+ 种数据库的极简开源数据库管理器
数据库·开源
sbjdhjd3 小时前
PHP eval 型 RCE:flag 正则过滤与通配符绕过实操 | 01
安全·web安全·网络安全·开源·系统安全·php·网络攻击模型
举个栗子。3 小时前
OpenMontage:首个开源的 Agent 化视频制作系统,用自然语言一键出品影片
开源·音视频