人机协作平面:审批、权限与提问

专栏:DeepSeek Harness · 第 12 / 14 篇
DeepSeek Harness安全审批

第 9 篇的工具流水线里留了一个伏笔:ask 决策之后发生了什么。这篇把它讲完,并把 dsh 里所有「模型想做事、需要人类点头」的机制串成一张网——审批、权限预设、凭据、主动提问。

审批:一个从 fail-closed 角度设计的 seam

审批服务(ctx.approval)的词汇小得惊人,但每个词都经过推敲:

// packages/interaction/user-approval/src/index.ts:50-68(节选)
/**
 * - `'ask'` (the default) — delegate to the composed answerers; with none
 *   composed the chain falls through to the fail-closed `'unavailable'`.
 * - `'never'` — never prompt anyone: every ask resolves `'rejected'`
 *   deterministically. The strict headless stance (CI, unattended runs) …
 */
export type ApprovalPolicy = 'ask' | 'never'

/** Model-facing statement for the deterministic 'never' policy. */
const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation …'

请求入口的仪式感值得全读——先审计后决策,审计事件对必须落在轮次边界内(轮次之间的裸事件等于崩溃尾巴,回放时无法归属):

// packages/interaction/user-approval/src/index.ts:207-226
async request(req: ApprovalRequest): Promise<ApprovalOutcome> {
  const session = req.agent.session
  if (!hasOpenTurn(session)) {
    throw new Error(
      'approval.request() outside an open turn: the approval/asked + approval/decided audit pair '
      + 'must be turn-enclosed ... Ask from inside the turn that needs the decision.',
    )
  }
  const id = ApprovalRequestId(randomUUID())
  session.append('approval/asked', {
    id, toolName: req.toolName,
    ...req.callId !== undefined ? { callId: req.callId } : {},
    ...req.reason !== undefined ? { reason: req.reason } : {},
  })
  const outcome = await this.decide(req, session)
  session.append('approval/decided', { id, outcome })
  return outcome
}

决策核心有两处精妙。其一,never 策略在分发之前就确定性拒绝——否则一个 prepend 注册的监听器可能绕过它:

// packages/interaction/user-approval/src/index.ts:258-283(节选)
private async decide(req: ApprovalRequest, session: Session): Promise<ApprovalOutcome> {
  const signal = req.signal
  if (signal?.aborted) return 'cancelled'
  // The 'never' policy is decided HERE, before any dispatch: a listener
  // registered with `prepend: true` after this service mounts would sit
  // ahead of any gate LISTENER, so a listener-shaped gate cannot keep the
  // documented promise …
  if (this.effectivePolicy(session) === 'never') return 'rejected'
  const answer: Promise<ApprovalOutcome> = Promise.resolve().then(
    () => this.ctx.waterfall(
      scopeTarget(req.agent, req.agent), 'approval/request', req,
      () => Promise.resolve<ApprovalOutcome>('unavailable'),   // 无应答者 = fail-closed
    ),
  ).then(
    outcome => OUTCOMES.includes(outcome) ? outcome : 'unavailable',  // 异常应答也 fail-closed
    () => 'unavailable',
  )

其二,四个结果里只有 allowed-once 是授权词,且只授权所问的那一次操作;rejected/cancelled/unavailable 全是拒绝,但理由彼此不同(模型能区分「人类说不」和「没有审批通道」)。审计对(asked/decided)只写日志、不进模型 transcript。

审批流水线全景

图表(human-in-the-loop.md)

两个细节见功力:审批请求刻意不含工具参数(UI 渲染的是日志里已有的那次调用,避免参数副本漂移);卡片只提供一次性决定,持久策略归 Host 侧审批服务所有。

另一条支线是沙箱升权:被沙箱拦下的命令可以带 sandbox_permissions + justification 一次性重试,tool-bash 在执行任何东西之前经同一个 ctx.approval.request() 求批准——同一扇门,不同的敲门理由。

权限预设:两个旋钮的具名组合

dsh 把权限建模成两个独立的旋钮

  • 沙箱模式:read-only / workspace-write / danger-full-access(故障安全默认是 read-only)
  • 审批策略:ask / never

预设只是旋钮组合的具名快照:

// packages/interaction/permission-presets/src/index.ts:164-181
static Config: z<Config> = z.object({
  presets: z.dict(z.object({
    sandbox: z.union(SANDBOX_MODES as SandboxMode[]).required(),
    approval: z.union(APPROVAL_POLICIES as ApprovalPolicy[]).required(),
    name: z.string(),
    description: z.string(),
  })).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.',
    },
  }),
  defaultPreset: z.string(),
})

注意三点:默认预设只有两档,read-only+ask 这种组合读回 custom(仅供显示,不可选);预设服务不拥有强制执行——set() 先落一条意图事件,再调两个旋钮自己的权威 setter(沙箱模式、审批策略),移除预设包后最后一次取值依然生效;defaultPreset 只在新会话创建时固定,改设置不影响进行中的会话。

凭据:配置只携带引用

凭据 seam 的核心准则一句话:配置里永远不出现机密本身,只出现它的名字

// packages/credentials/credentials/README.zh.md:48-59(节选)
const ref = credentialRef('DEEPSEEK_API_KEY')          // 环境变量形状的引用(branded 类型)
const hit = await ctx.credentials.resolve(ref)         // { value, source } | undefined
const info = await ctx.credentials.describe(ref)       // { configured, source?, writable } — 永不含值
await ctx.credentials.set(ref, 'sk-…')                 // 只读源遮蔽时拒绝

两个键空间:CredentialRef(「这个名字背后是什么值」,值存提供方——默认是一个仅同 OS 用户可读的私有 YAML,读取顺序 env > file);CredentialKey(「这个插件为这个 id 持有什么凭据」,如授权 grant)。设计上的狠招:describe() 的返回类型里值的容身之处都没有,于是整个「读状态」半区可以安全跨越网络到达浏览器。resolve 按调用读取、绝不缓存——这就是第 11 篇「密钥轮换免重启」的机制来源。

模型主动提问:ask_user

除了「模型请求做事 → 人类批准」,还有反向通道「模型主动问人」:ask_user_question 工具(第 14 篇会看到它的注册代码)把问题转成带稳定 id 的请求,经 ctx.userQuestions.ask() waterfall 派发,阻塞到有应答者(Web UI)接住,答案以紧凑 JSON 作为普通工具结果回流。子 agent 不许提问(DELEGATED_CALLER 拒绝)——提问权属于直接面对人类的那个 agent。

权限旋钮的架构位置

图表(human-in-the-loop.md)

与其他模块的联系

  • tools(第 9 篇):审批是 pre-execute 之后的第五环,allowed-once 是唯一授权词;
  • 沙箱(第 10 篇):升权(sandbox_permissions)与工具审批走同一个 ctx.approval.request 门;
  • Web GUI(第 13 篇):浏览器审批卡片是 approval/request waterfall 的应答者,callId 挂到已流式输出的调用上;
  • session(第 6 篇):asked/decided 审计对必须落在轮次边界内,回放可查。

读完这篇再看 dsh 的安全模型,会发现它从不依赖「模型自觉」:策略在服务内部确定性裁决(never)、单调 guard 不可翻案、审批 fail-closed、沙箱在 OS 层包装 argv——模型能做的只是在既有的门里敲门,而每扇门的锁都在模型够不到的地方。

下一篇看这些能力如何被包装成三种产品形态:Web、headless 与 SDK。

← 返回文章列表