LLM之Agent(113)|DeepSeek-Harness(二十)权限审批与沙箱体系

dsh 把"要不要限制文件访问""要不要弹窗问人""默认给哪一档权限"拆成了三个各自独立、各自持久化的旋钮------ sandbox/mode、 approval/policy、 permission/preset------而不是一个笼统的"权限等级"开关。四种平台各自用不同的内核机制(bwrap、Seatbelt、Landlock、Windows 受限令牌 + Low 完整性标签)兑现同一份"read-only / workspace-write / danger-full-access"契约,一次升级请求必须先过严格单向递增表、再过人工审批,才能拿到一次性的更宽权限。本篇把这套体系的旋钮关系、平台实现、升级流程和远程执行路径的沿革依次讲清楚。

学习目标

  • 理解 sandbox/mode、approval/policy、permission/preset 三个旋钮各自的职责边界,以及它们如何通过会话事件持久化、可重放。
  • 通读 SandboxProvider/SandboxPolicyService/ApprovalService/PermissionPresetService 四个 Service 的真实实现,理解"策略解析"和"策略执行"是如何被拆到不同层的。
  • 理解 dsh 在 Linux/macOS/Windows 三个平台上分别选用 bwrap、Landlock、Seatbelt、Windows ACL 四种沙箱实现的取舍,以及 Windows 方案强制力边界的现状(原"Everyone 写洞"已被 Low 完整性标签堵上,剩余缺口是什么)。
  • 理解沙箱升级(escalation)机制的完整流程:严格单向递增表、审批请求、一次性授权。
  • 了解远程执行路径的沿革------E2B microVM 家族曾把 ctx.fs/ctx.subprocess 换成云端远程后端、后因传输约束被整体移除(远程执行仍是预留能力,SSH 助手族是当前落地的远程形态)------以及 dsh 与 Claude Code/Codex 相比在"权限档位"和"编辑回滚"上的两点差异。

背景与设计动机

"给不给这条命令写文件的权限"和"要不要为这次操作弹窗问用户"其实是两个完全独立的问题:一个命令可能被沙箱允许执行,但部署方仍然希望每一次都有人确认;反过来,一个部署可能完全不想弹窗(比如 CI 无人值守场景),但仍然需要沙箱把破坏性操作限制在工作区之内。如果把这两件事合并成一个"权限等级",要么会出现"CI 场景下想要严格沙箱但不能有弹窗"这种表达不出来的组合,要么就要为每一种组合单开一个特殊等级,越加越乱。

dsh 的解法是把它们拆成三个正交的持久化事件------sandbox/mode(文件效果模式)、approval/policy(要不要走审批)、permission/preset(面向用户的"套餐"记录,把前两者打包成一个好记的名字)------每一个都可以独立读取、独立切换,permission/preset 只是一层方便用户操作的糖衣。三者都通过会话事件写入,天然可重放:恢复一个历史会话时,重新回放事件日志就能得到当时生效的确切权限状态,不需要额外的快照机制。

核心机制详解

旋钮一:sandbox/mode------文件效果模式

ctx.sandboxPolicy(packages/sandbox/sandbox-policy/src/index.ts)是这个旋钮的策略归宿。它不做任何实际的沙箱包装,只负责"给定一次调用,解析出这次调用应该用哪个模式、哪个工作区根目录":

复制代码
// packages/sandbox/sandbox-policy/src/index.ts
export class SandboxPolicyService extends Service {
  static Config: z<Config> = z.object({
    mode: z.union(['read-only', 'workspace-write', 'danger-full-access'] as const).default('read-only'),
    workspaceRoot: z.string(),
  })

  static inject = ['sessionProjections']

  constructor(ctx: Context, config: Config) {
    super(ctx, 'sandboxPolicy')
    // ...
    ctx.sessionProjections.register({
      key: 'sandboxMode',
      stateVersion: 1,
      stateSchema: sandboxModeStateSchema,
      init: () => null,
      apply: (state, event) => (event.type === 'sandbox/mode' ? event.data.mode : state),
    })
    // ...
  }

  resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy {
    const { session } = request
    return {
      mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode,
      workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot),
      ...session === undefined ? {} : { sessionId: session.id },
    }
  }
}

优先级链写得很清楚:一次调用如果带了显式的、经过审批的 mode override,它优先;否则看会话自己的 sandbox/mode 事件历史;最后才落到部署配置的 defaultMode------部署默认值是 read-only,这是一处刻意的"failsafe 默认"选择:一个没有显式声明"我允许工作区可写"的部署,永远从最保守的模式起步。

注意 overrideOf 的实现已经换了引擎:旧版是"每次调用时把会话事件列表当场折叠"(effectiveSandboxMode(session.events)),现在是通过 ctx.sessionProjections(会话投影服务 ,packages/session/session-projection)------构造函数里注册一个名为 sandboxMode 的投影单元(init 是 null,apply 看到 sandbox/mode 事件就把状态换成事件载荷),查询侧一行 this.ctx.sessionProjections.stateOf(session, 'sandboxMode')。投影是有版本号的增量状态(带 stateVersion/stateSchema),回放事件日志时随放随更新,查询时直接读快照------同一个"从事件推导当前态"的语义,性能特性和复用方式完全不同,effectiveSandboxMode 这个纯函数辅助已经从 packages/ 里彻底移除。

真正把这个模式兑现 成一次具体的进程限制,是另一个独立的 seam------ctx.sandbox(packages/sandbox/sandbox/src/index.ts)。SandboxProvider 的契约只有一个方法:

复制代码
// packages/sandbox/sandbox/src/index.ts
export abstract class SandboxProvider extends Service {
  abstract confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv>
}

confine 接收调用方即将 spawn 的原始 argv,异步地 返回一个包裹过的新 argv,调用方直接拿新 argv 去 spawn。注意两个相对旧契约的变化:返回值从同步的 ConfinedArgv 改成了 Promise<ConfinedArgv>------包装过程可能要真的跑一次探测、创建目录 ACL 或者连接远程 helper,异步签名给这些实现留了活口;新增的 signal 参数让"执行方已取消"可以中途打断包装(第一篇 SandboxBashExecutor 把 executeArgv 的准备信号一路透传到这里,准备本身超时或取消会落成一个正常的超时/取消结算,而不是悬挂)。JSDoc 特别强调"必须返回强制生效的 argv 或者在包装/运行时 fail closed;绝不允许静默地直通不加沙箱的原始命令"------SandboxUnavailableError 就是这条 fail-closed 承诺的具体载体:找不到可用的沙箱后端时,dsh 选择直接拒绝执行,而不是退化成"不沙箱化也照样跑"。

旋钮二:approval/policy------要不要走审批

ctx.approval(packages/interaction/user-approval/src/index.ts)只有两档:

复制代码
// packages/interaction/user-approval/src/index.ts
export type ApprovalPolicy = 'ask' | 'never'

'ask' 是默认值,交给已注册的"回答者"(answerer,比如某个 UI 插件)去决定;没有回答者时 fail-closed 成 'unavailable'。'never' 是确定性拒绝 ------不经过任何 waterfall 分发,直接返回 'rejected':

复制代码
private async decide(req: ApprovalRequest, session: Session): Promise<ApprovalOutcome> {
  const signal = req.signal
  if (signal?.aborted) return 'cancelled'
  if (this.effectivePolicy(session) === 'never') return 'rejected'
  // ...才进入 approval/request waterfall...
}

代码注释解释了为什么这个检查必须写在 decide() 内部而不是作为一个监听器:"一个用 prepend: true 注册、排在这个检查之前的监听器,如果是一个 listener 形态的门,就没法保证 'never' 无条件拒绝这个文档化的承诺------只有这个服务自己的请求路径能保证"。这是"确定性"这个词的真正含义:'never' 策略下的拒绝结果在代码路径上就不可能被任何后注册的插件绕过,这正是无人值守 CI 场景需要的强保证。

关键事实:ApprovalOutcome 的取值只有四种,里面没有"永久允许":

复制代码
// packages/interaction/user-approval/src/index.ts
const OUTCOMES: readonly ApprovalOutcome[] = ['allowed-once', 'rejected', 'cancelled', 'unavailable']

allowed-once 明确是"一次性"的------没有一个 allowed-forever 或类似的档位。这意味着每一次需要审批的操作都要重新走一遍审批流程,不存在"用户批准一次,后续所有类似操作自动放行"的机制。

旋钮三:permission/preset------把前两者打包成套餐

ctx.permissionPresets(packages/interaction/permission-presets/src/index.ts)不引入新的强制力,它只是把 sandbox/mode 和 approval/policy 的组合包装成一个用户友好的名字,默认表:

复制代码
// packages/interaction/permission-presets/src/index.ts
}).default({
  'workspace-write': {
    sandbox: 'workspace-write', approval: 'ask',
    name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.',
  },
  'danger-full-access': {
    sandbox: 'danger-full-access', approval: 'never',
    name: 'danger-full-access', description: 'Full file access without approval prompts.',
  },
}),

切换一个 preset,底层是连续写两个各自独立的事件:

复制代码
/** Apply one preset through its durable identity and canonical knob setters. */
private apply(session: Session, name: string, setApproval: (policy: ApprovalPolicy) => void): void {
  const spec = this.resolve(name)
  if (name === AUTO_PRESET) this.autoAdmit?.()
  const current = this.current(session)
  const knobs = this.permissionState(session)
  const updateKnobs = (): void => {
    if (spec.sandbox !== (knobs.sandbox ?? this.ctx.shell.sandboxMode)) {
      setSandboxMode(session, spec.sandbox)
    }
    if (spec.approval !== (knobs.approval ?? this.ctx.approval.config.policy ?? 'ask')) {
      setApproval(spec.approval)
    }
  }
  if (current !== name) session.append('permission/preset', { preset: name })
  updateKnobs()
}

permission/preset 事件本身只是"日志用户意图"------真正生效的仍然是紧随其后写入的 sandbox/mode 和 approval/policy 事件。这个 excerpt 相对旧版有两处实质变化:this.current(session) 和 this.permissionState(session) 都是通过 ctx.sessionProjections.stateOf(session, 'permissions') 读投影快照 (preset/sandbox/approval 三个旋钮各自最近一次的载荷,null 表示尚未 override),不再当场折叠 session.events;AUTO_PRESET = 'auto' 是一个实验性的"逐调用审核"套餐(沙箱 danger-full-access + 审批 never,由 Auto 审核集成在运行时发布一个仅当前会话可见的固定 preset,带同步准入检查),选中它时还要先跑 autoAdmit() 准入回调。这也解释了为什么会存在一个 CUSTOM_PRESET = 'custom' 的特殊值:如果两个旋钮当前的实际取值凑不出表里任何一行(比如用户手动把 sandbox 调到了 read-only 但 approval 还留在 never),derive() 就会返回 'custom'------这不是一个可以切换到的目标,只是"当前状态无法归类到任何已知套餐"的诚实汇报。

四种平台的沙箱实现对比

ctx.sandbox 的本地实现 LocalSandboxProvider(packages/sandbox/sandbox-local/src/index.ts)按平台选择一条"运行器链"(runner chain):

复制代码
// packages/sandbox/sandbox-local/src/index.ts
const PLATFORM_CHAINS: Record<string, readonly SelectedRunner['runner'][]> = {
  linux: ['bwrap', 'landlock'],
  darwin: ['seatbelt'],
  win32: ['windows-acl'],
}

Linux 有两个候选,按声明顺序做功能性探测 (真的 spawn 一次极简进程验证内核/工具是否可用)来仲裁:优先 bubblewrap(bwrap,基于 mount namespace,profile 语义最贴近 dsh 的模式词汇),探测失败才退到 landlock-run(基于 Landlock LSM,不需要 mount 权限,更适合受限容器环境)。macOS 和 Windows 各只有一个候选,不需要探测,选中即用,执行期失败再 fail closed。四条链各自的 profile 构造(packages/sandbox/sandbox-local/src/profiles.ts):

复制代码
// packages/sandbox/sandbox-local/src/profiles.ts
export function bwrapProfileArgs(policy: SandboxPolicy): string[] {
  const args = ['--ro-bind', '/', '/', '--dev', '/dev', '--unshare-pid', '--proc', '/proc', '--die-with-parent']
  if (policy.mode === 'workspace-write') {
    args.push('--tmpfs', '/tmp')
    args.push('--bind', policy.workspaceRoot, policy.workspaceRoot)
  }
  return args
}

export function seatbeltProfileArgs(policy: SandboxPolicy): string[] {
  const forms = ['(version 1)', '(allow default)', '(deny file-write*)', `(allow file-write* (literal ${sbplString('/dev/null')}))`]
  const roots = writableRoots(policy)
  if (roots.length > 0) {
    forms.push(`(allow file-write* ${roots.map(root => `(subpath ${sbplString(root)})`).join(' ')})`)
  }
  return ['-p', forms.join(' ')]
}

bwrap 用 mount bind 把整个根文件系统只读挂载,再把工作区目录额外 bind 成可写;seatbelt(macOS 的 sandbox-exec -p,SBPL profile 语言)反过来是"默认允许,显式拒绝文件写入,再对工作区路径开白名单"。两种截然不同的策略语言,最终都要收敛到同一份"read-only / workspace-write / danger-full-access"契约上------这正是 seam 抽象在起作用:ctx.sandbox.confine() 的调用方完全不需要知道 SBPL 和 bwrap 的 mount 参数语法差在哪里。

四条链的强制完整性 (SandboxEnforcement,只有 'full' 和 'partial' 两档)并不一致:

复制代码
// packages/sandbox/sandbox-local/src/index.ts
const STATIC_ENFORCEMENT: Record<SelectedRunner['runner'], SandboxEnforcement> = {
  bwrap: 'full',
  landlock: 'full',
  seatbelt: 'full',
  // Everyone stays in both restricting lists for process initialization, but
  // the Low label denies the write authority it used to confer. NTFS hard
  // links still alias a granted workspace file to a path outside it, reads
  // stay unconfined, and an https://zhida.zhihu.com/search?content_id=285503900&content_type=Article&match_order=1&q=AppContainer&zhida_source=entity-ACL'd tree is unreadable to the
  // child: the backend enforces the remaining ACL-https://zhida.zhihu.com/search?content_id=285503900&content_type=Article&match_order=1&q=addressable&zhida_source=entity surface but
  // must not advertise the absolute promise.
  'windows-acl': 'partial',
}

Windows 方案(packages/sandbox/sandbox-windows-acl)用的是"受限令牌"(restricted token)运行子进程,配合 ACL(访问控制列表)显式授予工作区的写权限。这里有一次值得记录的实战加固(对应设计笔记 2026-09-19-windows-acl-mandatory-integrity-confinement.md):仅受限令牌这一层覆盖的只是"对象自身安全描述符上的访问检查",而 Windows 还允许经由父目录的 FILE_DELETE_CHILD 权限 执行删除,cmd /c del 能删掉工作区外的文件;另外受限列表里必须保留的 Everyone 组意味着"本来就对 Everyone 开放写权限的外部对象"仍然可写。修复方案是给受限令牌叠加强制完整性控制 (Mandatory Integrity Control):一次 SetNamedSecurityInfoW 里同时落三件事------能力 SID 的允许 ACE(原有的写权限授予,不变);给世界 SID(world SID)在 FILE_DELETE_CHILD 上挂一个仅容器继承(CONTAINER_INHERIT_ACE)的 DENY ACE;以及给工作区根目录贴一个 Low 完整性标签 (S-1-16-4096,SYSTEM_MANDATORY_LABEL_NO_WRITE_UP),同时把受限令牌本身降到 Low 级别(通过 SetTokenInformation(TokenIntegrityLevel))。内核在做权限判定时,无论写权限是来自对象自身 ACE 还是父目录的 delete-child 权限,都会先过完整性策略这一关------于是 Medium 级别的、工作区之外的对象写不进也删不掉,Everyone 组遗留的写入路径就此堵死。这个洞的代价是真实的:Low 标签和 ACE 一样要常驻 在工作区根目录上(撤销时机是"该目录上不再有任何完好的能力授权"),不能按会话挂撤------所以 windows-acl 依旧诚实地标记为 'partial':NTFS 硬链接仍能把已授权文件别名到工作区外的路径;读取完全不受约束 (这套方案只管写);而 AppContainer-ACL 化的目录树对子进程整个不可读,会坏事。注释原话:"the backend enforces the remaining ACL-addressable surface but must not advertise the absolute promise"(后端把剩下 ACL 能管到的那部分都管住了,但绝不能宣传成"绝对承诺")。四条链各自的"拒绝方言"(denial signature,即拒绝发生时 stderr 里会出现的特征字符串)也不同:

复制代码
const DENIAL_SIGNATURES = {
  bwrap: ['read-only file system'],
  landlock: ['permission denied'],
  seatbelt: ['operation not permitted'],
  // pwsh/.NET: "Access to the path '...' is denied."; cmd: "Access is denied.";
  // Node EACCES: "permission denied"; EPERM: "operation not permitted".
  'windows-acl': ['access is denied', 'access to the path', 'permission denied', 'operation not permitted'],
  runnerCommand: ['read-only file system', 'permission denied'],
} as const satisfies Record<SelectedRunner['runner'] | 'runnerCommand', readonly string[]>

这份表存在的意义是让上层(比如 tool-bash)能够从一次失败的命令输出里,准确判断"这是沙箱拒绝了文件操作"还是"命令自己执行失败了"------如果匹配了对应后端的拒绝方言,才会在模型看到的结果里附加 [sandbox: file access denied under <mode> mode] 这个标准化标记,而不是把两种性质完全不同的失败混为一谈。新增的 runnerCommand 一档对应"部署方自带运行器"的配置路径(runnerCommand 配置项):如果部署方提供了一个自定义运行器命令,LocalSandboxProvider 会复用 bwrap 的 profile 参数、把它套在这个自定义命令前面来沙箱化([...runnerCommand, ...bwrapProfileArgs(policy), '--', ...argv]),这一档的拒绝方言就单独登记,不和平台内置四个运行器混用。

文件系统层的第二道防线:进程内的路径围栏

沙箱运行器解决的是"进程级别能不能碰某个路径",但 dsh 在文件系统 Provider 内部还叠加了一层进程内 的二次防护------packages/fs/fs-sandbox/src/containment.ts 的 isPathUnder:

复制代码
// packages/fs/fs-sandbox/src/containment.ts
export async function isPathUnder(
  path: string, root: string,
  caseSensitive = process.platform !== 'win32',
): Promise<boolean> {
  if (isLexicallyUnder(path, root, caseSensitive)) return true
  const rootInfo = await statIfPresent(root)
  if (!rootInfo) return false
  let ancestor = path
  while (true) {
    const ancestorInfo = await statIfPresent(ancestor)
    if (ancestorInfo && sameIdentity(ancestorInfo, rootInfo)) return true
    const parent = dirname(ancestor)
    if (parent === ancestor) return false
    ancestor = parent
  }
}

先走词法前缀比较(把目标路径和根目录字符串比较是否有前缀关系),如果字符串不匹配,再退而求其次,沿着目标路径的每一级祖先目录做文件系统身份(dev+ino)比较------这一步专门是为了识别 Windows 上"长文件名/8.3 短文件名"或大小写差异造成的路径别名,不让字符串层面的伪装绕过围栏。这道防线和 ctx.sandbox 的进程级隔离是互补关系:一个在内核/OS 层拦截 open() 系统调用,一个在 Node 进程内部对"这次要写的目标到底是不是工作区内的路径"做二次校验,两者叠加才是 dsh 对"workspace-write"这个词的完整承诺。

让模型知道自己处在什么权限下:系统提示词里的策略陈述

三个旋钮的当前取值,不只是内部策略检查用的状态,还会被翻译成一段自然语言,拼进每次请求的系统提示词里,让模型对自己的处境有明确认知,而不是只能通过"试了才知道被拒绝"来摸清边界。SandboxPolicyService 的构造函数里注册了这样一段:

复制代码
// packages/sandbox/sandbox-policy/src/index.ts
ctx.inject(['systemPrompt'], (scope: Context) => {
  scope.systemPrompt.context({
    name: 'sandbox:policy',
    order: scope.systemPrompt.getContextOrder('SANDBOX_POLICY'),
    text: (context) => {
      const session = context.agent?.session
      return session === undefined
        ? ''
        : renderPolicyContext(this.resolve({ session }))
    },
  })
})

function renderPolicyContext(policy: SandboxExecutionPolicy): string {
  switch (policy.mode) {
    case 'read-only':
      return 'Current DSH file policy: read-only. ... Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.'
    case 'workspace-write':
      return `Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: ${JSON.stringify(policy.workspaceRoot)}. ...`
    case 'danger-full-access':
      return 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.'
  }
}

这段陈述每次请求都重新按当前策略生成 ,而不是写死在系统提示词的静态部分------这样切换 sandbox/mode 之后,模型在下一步就能读到更新后的陈述,不需要重启会话。特别值得注意的是 read-only 分支的最后一句:"不要仅凭这条策略陈述就拒绝执行一个必要的修改------正常尝试可用的工具,并遵循它返回的拒绝与升级指引"。这是一处刻意的措辞选择:系统提示词只负责告知 当前处于什么模式,不负责替模型做决策 ------真正"这次操作行不行"的判断权始终留在执行时的沙箱强制和审批流程手里,模型不应该因为读到"当前是 read-only"就自己提前放弃尝试,毕竟同一轮里升级机制随时可能让这次操作变得可行。ApprovalService 也用同样的模式挂了一段 approval:policy 陈述,'never' 策略下明确告诉模型"不要发起沙箱升级请求(不要设置 sandbox_permissions)"------因为在 'never' 策略下,升级请求百分之百会在审批这一步被确定性拒绝,提前把这个事实告诉模型可以避免一轮无意义的尝试。

升级机制(Escalation):从拒绝到一次性放宽

工具被沙箱拒绝之后,模型可以在同一轮 里附带升级请求重试同一个调用。这条流程的核心是"严格单向递增表",定义在 packages/sandbox/sandbox/src/escalation.ts:

复制代码
// packages/sandbox/sandbox/src/escalation.ts
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']

WIDER_MODES 是执行期 才会查的表------"从 read-only 只能升到 workspace-write 或 danger-full-access,从 workspace-write 只能升到 danger-full-access"这条规则故意不写进工具的 schema 枚举(ESCALATION_TARGETS 是给 schema 用的,是"这个部署可能升到的全部目标"的静态并集),因为 schema 是注册表级别全局共享的,而"从哪个模式升级"是每次调用的运行期事实------两者必须分开校验。真正的判定在 approveEscalation:

复制代码
// packages/sandbox/sandbox/src/escalation.ts
export async function approveEscalation<A, C>(request: EscalationRequest, approval: EscalationApproval<A, C>): Promise<SandboxMode> {
  const { requestedMode: mode, effectiveMode, justification, subject } = request
  if (mode === effectiveMode) return effectiveMode
  // Strict widening is an EXECUTION check against the call's effective mode ---
  // deliberately not a schema constraint (the enum is the closed target
  // vocabulary; the effective mode is per-call truth).
  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`)
  }
  if (approval.agent === undefined) {
    throw new Error(`sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`)
  }
  const outcome = await approval.approver.request({
    agent: approval.agent,
    toolName: approval.toolName,
    callId: approval.callId,
    reason: `escalate sandbox to ${mode}: ${justification}`,
    ...approval.signal ? { signal: approval.signal } : {},
  })
  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`)
    default: return assertNever(outcome, 'EscalationOutcome')
  }
}

完整顺序是:先看请求的是不是就是当前生效模式 ------是的话连审批都不用走,直接放行("repeating it needs no approval",这同时让模型带着 sandbox_permissions 无害重放同一个模式时不会吃到无谓的弹窗);再做严格单向递增校验(不满足直接拒绝,连审批都不问);再检查有没有配置审批服务和调用方 agent(缺任何一个都直接拒绝);最后才真正发起 approval.request(...),取消(cancelled)和"没有回答通道"(unavailable)也各自有专属的报错文案。批准的结果只是这一次调用 的 mode override,不会写回会话级别的 sandbox/mode 事件------下一次同样的命令如果再被拒绝,还要再走一次完整的升级流程。顺带一提:同文件还导出了配套的 validateEscalationArgs() 工具函数(校验 sandbox_permissions 与 justification 必须成对出现、理由必须非空),这套函数与文案是 tool-bash 和 tool-fs 两个强制执行家族共用的同一个事实来源,模块 doc 开头就写明"one home keeps the two families' approval ordering and verbatim error texts from drifting apart"。packages/shell/tool-bash/src/index.ts 里 bash 工具的描述文本把这套流程翻译成了给模型看的操作指引,核心是"不要先去聊天里问权限,直接重试触发的审批弹窗就是用户同意的方式;一次被拒绝的升级对这条命令是终局的------停下来解释,不要绕路"。

E2B 远程 microVM 路径已被移除(2026-09 更新)

早期版本这里讲的是 packages/e2b------把 ctx.fs 和 ctx.subprocess 两个 seam 的 Provider 都换成基于 E2B 云端 microVM 的远程实现(fs-e2b、subprocess-e2b),由一个统一持有 E2B SDK 句柄的 E2BRuntime 支撑。这条路径在 2026-09-11 被整体移除 (packages/e2b 已经从仓库里彻底删除,fs-e2b/subprocess-e2b/E2BRuntime 全部搜不到任何引用),仓库里归档的设计笔记(2026-09-11-remove-e2b-providers.md)说明了真实原因,不是速度或成本问题,而是一个具体的技术约束:本篇上一节讲的 PTC Node 后端需要一条独立于程序自身 stdin/stdout/stderr 的双向控制通道,而当时锁定的 e2b@2.29.1 SDK 只暴露标准的进程流和 PTY 输出,没有额外的描述符传输能力------在 VM 内部创建一个新的描述符,并不会自动让 Harness 主机这一侧看到它,必须自己搭一条独立的、带鉴权的中继流,而这条中继流还得独立解决背压、有界内存、断线清理等一整套问题。笔记原话是:"继续做下去,这个实验就会变成一个独立的远程传输项目"------权衡之后,团队选择先移除这个 provider 家族,而不是为了保留它去做这整套额外工程。

值得强调的是,这次移除不代表 ctx.fs/ctx.subprocess 这两个 seam 本身不再支持远程实现------笔记明确写道"remote execution remains an intended capability"(远程执行依然是一个明确的目标能力),只是"这一个具体的 provider 家族"被移除了,seam 的抽象契约(第一篇讲的 Service Definition)完全没有变化,将来完全可能有新的远程 Provider 重新接入进来。这也从另一个角度印证了 seam 设计的价值:移除一整条能力路径,不需要动 ctx.fs/ctx.subprocess 的契约本身,也不影响 tool-fs、bash-local 这些消费方------受影响的只是"当前有哪些 Provider 可选"这一个纯粹的部署配置问题。事实上这个"将来"已经现身:packages/ssh 下的 SSH 助手族(ctx.ssh 持有一条认证的 OpenSSH 连接,其上挂 fs-ssh/subprocess-ssh/sandbox-ssh 三个远端 Provider)就是当前实际落地的远程形态,第一篇的 ctx.fs Provider 表里已经能看到它。

与 Claude Code/Codex 相比的两点差异

调研过程中注意到两处和一些同类工具不同的设计:一是 ApprovalOutcome 的取值确实没有"永久允许"这一档,每次审批都是一次性的;二是全仓库搜索 undo_edit、apply_patch、multi_edit 均无命中,dsh 的编辑工具没有提供撤销(undo)能力,安全网是下一篇会讲到的"乐观并发版本控制"(先读后写、写入带版本号校验),而不是"记录历史然后可以撤销"。这两点都是根据源码搜索得出的调研发现,具体的设计取舍原因源码注释里没有直接说明,不确定是有意为之还是尚未实现,这里只如实陈述现象,不做过度推测。

常见问题/易踩坑

Q:sandbox/mode 调到 workspace-write 之后,是不是工作区里所有文件都可以随便改?

不完全是。workspace-write 只是把"可写范围"从空集扩大到"工作区根目录加平台临时目录",并不代表沙箱内部没有更细粒度的限制------比如 fs-observation-policy 的"先读后写"CAS 检查(第四篇会详细展开)仍然要求写入前必须先观察过目标文件的版本,这条检查和 sandbox/mode 是完全独立的两层。同样,workspace-write 也不代表工作区之外绝对 不可写------如果沙箱后端本身强制不完整(比如 Windows ACL 方案标记为 partial),理论上仍然存在绕过余地(Windows 上原"Everyone 可写"那条洞已被 Low 完整性标签堵上,目前躺着的是 NTFS 硬链接别名这一条:同一个文件对象可以在授权路径之外别名出现)。"模式名字听起来涵盖的范围"和"实际的强制完整性"是两个独立的问题,后者要看 SandboxEnforcement 的取值。

Q:审批请求(approval/asked)和沙箱升级请求(escalation)是一回事吗?

不是一回事,但升级请求最终会借用 审批这个 seam。审批(ctx.approval)是一个通用机制,任何工具都可以在自己的 tools/pre-execute 里返回 { kind: 'ask' } 来触发一次审批;沙箱升级(approveEscalation)是专门给"沙箱拒绝之后想更宽权限重试"这一个场景设计的更高层封装,它在校验完严格单向递增之后,内部调用的正是 ctx.approval 的 request() 方法。可以理解成审批是通用的"问人"基础设施,升级是这套基础设施在沙箱场景下的一个专用调用方------两者的关系类似第一篇讲的 seam 和其消费方。

Q:为什么升级批准之后,mode override 不直接写回 session.append('sandbox/mode', ...),变成会话级别的新常态?

因为升级请求的措辞和校验逻辑通篇强调的都是"这一次"(allowed-once、"a one-shot retry"、"consumed by the one call that asked")。如果批准结果写回会话级别的持久事件,等价于把一次性的人工确认变成了永久性的权限提升,这正是审批机制想要避免的------ApprovalOutcome 里没有"永久允许"这一档不是疏漏,是刻意的设计立场,升级机制的"一次性"完全是这条立场在沙箱场景下的自然推论。如果确实想要"以后都用更宽的模式",正确的路径是通过 /permission 命令或者 PermissionPresetService.set() 显式切换会话的 sandbox/mode,让这次变更成为一条可审计的、独立的会话事件,而不是依附在某一次工具调用的升级批准上。

小结与思考题

dsh 的权限体系由三个正交旋钮构成:sandbox/mode 决定文件效果的强制范围,approval/policy 决定要不要走人工审批,permission/preset 只是把两者打包成方便切换的套餐。所有旋钮都通过会话事件持久化、由 ctx.sessionProjections 投影成快照,天然可重放。四个平台各自用完全不同的内核机制兑现同一份三档契约,强制完整性诚实地区分 full 与 partial(Windows 方案在强制完整性加固后仍标记为 partial:剩余的缺口是 NTFS 硬链接别名、读取不受限和 AppContainer 目录树不可读)。升级机制要求严格单向递增加人工审批,批准结果只对这一次调用生效;重复当前生效模式的请求免审批直接放行。E2B 远程 microVM 家族已经因为传输约束被整体移除,远程执行作为预留能力由 SSH 助手族落地。

思考题:

  1. approval/policy 的 'never' 档位选择在 decide() 内部硬编码检查,而不是注册一个优先级最高的 approval/request 监听器。如果确实需要给某个特殊场景开一个"'never' 策略下仍允许某类操作通过"的例外,应该怎么设计,才不破坏"'never' 无条件拒绝"这条文档化的承诺?
  2. Windows ACL 方案在 2026-09 通过"受限令牌 + Low 完整性标签"堵上了经父目录 FILE_DELETE_CHILD/Everyone 授权写入的删除逃逸。剩余的缺口(硬链接别名、读取不受限、AppContainer 目录树不可读)各属于不同层面:硬链接是"文件身份已经合法关联到授权路径"的问题,读取是"方案目标只管写"的范围问题,AppContainer 目录树是"别的 ACL 模型没有预料的被拦截"的问题。如果要进一步收紧其中的硬链接一项,可能需要在哪个环节(比如 grant 前的枚举、或运行时的 file-identity 校验)补一道什么性质的检查?
相关推荐
Spatial3Ddddd1 小时前
2026空间三维重建服务商横向对比
java·服务器·人工智能
嵌入式学习菌1 小时前
利尔达 LR2021(868 版本)LoRa 模组测试信道选型
人工智能
莪_幻尘1 小时前
Agent 工厂体检:幽灵注册、版本漂移、文档失联,AI 体系的三种病
人工智能·llm·agent
龙亘川1 小时前
健康服务规范化背后,数字化如何 “辅助赋能“
大数据·前端·人工智能·智慧城市·数据可视化
逸模2 小时前
主材偏差从 15% 降到 5%:连锁公装算量是怎么做到的
大数据·人工智能·施工·连锁公装·开店
data analyse 4562 小时前
SourceMap 上传版本与报错版本对不上?版本校验与错误还原设计
大数据·前端·人工智能·数据分析
ai_xiaogui2 小时前
Panelai 企业级AI基础设施操作系统:AI应用一键部署、私有化交付与算力统一纳管实践
人工智能·私有化部署·一键部署·企业级ai·ai基础设施·多租户隔离·api聚合
墨林陌2 小时前
AI 热点日报(2026-10-16):2026人工智能产业大会开幕,智能体算力需求井喷,AI芯片融资潮汹涌
人工智能
空堂与归2 小时前
Gemini 4 Argon 发布:物理推理模型控机器人?
人工智能·gpt·ui·ai·机器人