青雲的博客
深入浅出 DeepSeek Harness 第三部:工具执行——不是调一个函数那么简单 第 17 章

审批三件套:Permission、Approval、AskUser

Permission 是配置打包,Approval 是服务编排,AskUser 是模型工具——三个独立系统协作构成完整的人机审批闭环。本章把 PreToolDecision 三态、ApprovalService 的 fail-closed 决策链、ToolGuard 的单调否决、sandbox escalation 的 deny-then-retry 编排,以及 ask_user_question 与审批流的边界逐层拆开讲清楚。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

你可能觉得”权限审批”就是弹框问用户”允不允许”。但在 DeepSeek Harness 内部,这件事由三个完全独立的子系统协作完成,而且它们做的事情本质不同。混淆它们是理解 Harness 安全模型的第一个障碍,也是写自定义插件时最容易踩的坑。

这章把这三套东西拆开讲清楚:Permission 是配置打包层(把 sandbox mode 和 approval policy 这两个旋钮绑成预设),Approval 是真正的审批服务编排(发起请求、做决策、记审计,但它自己不画 UI),AskUser 则只是一个普通工具(ask_user_question),让模型向用户提问拿信息,跟审批流不是一回事。

第一层:PreToolDecision 三态——allow / deny / ask

工具执行前的决策不是布尔值。tools/pre-execute waterfall 里,每个 listener 返回的是一个三态联合类型:

type PreToolDecision =
  | { kind: 'allow' }
  | { kind: 'deny'; reason: string }
  | { kind: 'ask'; reason?: string }

allow 放行,deny 直接拒绝(materializes 为 isError 结果返回给模型),ask 进入审批流——交给 ApprovalService 决定。

这三态的设计意图是:listener 不需要自己实现审批 UI。它只需要说”这个调用需要人类确认”,具体怎么问、问谁、用什么 UI,全部委托给 ApprovalService。listener 和 approval 解耦。

waterfall 的默认行为是 allow——如果没有任何 listener 干预,调用直接放行:

const gate = await this.ctx.waterfall(
  carrier, 'tools/pre-execute', exec,
  () => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
)

当 gate 结果是 ask 时,进入 serviceAsk() 方法——这是 pre-execute 阶段连接 ApprovalService 的唯一桥梁。

第二层:ToolGuard——单调否决,不可覆盖

在 waterfall 之后、工具体执行之前,还有一道防线:ToolGuard。

type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined

Guard 只能否决,不能放行。返回一个 string 就是否决理由,返回 undefined 就是”我不反对”。注意:它没有 allow 选项——这是故意的。注释写得很清楚:

“Because guards have no allow result, listener ordering cannot turn a denial back into permission.”

这叫单调否决(monotonic denial)。waterfall listener 的执行顺序可能因注册时机不同而变化,但 guard 只能收紧不能放松——任何一个 guard 否决了,后面的 guard 不能把它改回允许。这消除了一类安全隐患:不会因为插件加载顺序的偶然变化而放过一个本该被拒绝的调用。

Guard 的评估顺序是 global 层先、然后 scope 链从最远的祖先开始:

private guardReason(exec: ToolExecution): string | undefined {
  const globalReason = this.layers.global.guardReason(exec)
  if (globalReason !== undefined) return globalReason
  // ...scope chain traversal
}

第三层:ApprovalService——决策链的完整拓扑

当 pre-execute waterfall 返回 askserviceAsk() 被调用。它做的事情比你想的复杂:

3.1 无服务降级

const approval = this.ctx.get('approval')
if (approval === undefined) {
  return {
    decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval (not yet supported)` },
    approvalCancelled: false,
  }
}

如果部署环境没有组合 ApprovalService(比如一个精简的 headless 部署),ask 直接降级为 denyFail-closed——没有审批能力就拒绝,不会默认放行。

3.2 无 Agent 降级

if (exec.agent === undefined) {
  return {
    decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` },
    approvalCancelled: false,
  }
}

没有 agent 的调用(测试场景、诊断工具)也无法审批——没有 session 记审计日志,没有 UI 路由目标。同样 fail-closed。

3.3 四种 outcome 的精确映射

switch (outcome) {
  case 'allowed-once': return { decision: { kind: 'allow' }, approvalCancelled: false }
  case 'rejected': return { decision: { kind: 'deny', reason: ... }, approvalCancelled: false }
  case 'cancelled': return { decision: { kind: 'deny', reason: ... }, approvalCancelled: true }
  case 'unavailable': return { decision: { kind: 'deny', reason: ... }, approvalCancelled: false }
}

只有 allowed-once 变成 allow,其余三种全是 deny——但 reason 不同,让模型能区分”用户说不”和”没有人可以问”和”审批被取消了”。

第四层:ApprovalService.request()——审计先于决策

ApprovalService 的 request() 方法是所有审批请求的入口。它的执行顺序非常重要:

  1. 检查 open turn——hasOpenTurn(session.events)。审计事件必须在 turn 内,否则 replay 时会被当作 crash tail 丢弃。
  2. 先写 approval/asked——在问任何人之前就把”我问了这个问题”写进 session log。
  3. 调用 decide()——实际分发给 answerers。
  4. 写 approval/decided——记录最终结果。

为什么 asked 在回答前就写?因为审计 trail 需要包含未回答的问题。进程崩溃时,log 里必须有记录表明”这里有一个悬而未决的审批”。

async request(req: ApprovalRequest): Promise<ApprovalOutcome> {
  if (!hasOpenTurn(session.events)) {
    throw new Error('approval.request() outside an open turn...')
  }
  const id = ApprovalRequestId(randomUUID())
  session.append('approval/asked', { id, toolName: req.toolName, ... })
  const outcome = await this.decide(req, session)
  session.append('approval/decided', { id, outcome })
  return outcome
}

第五层:‘never’ policy——waterfall 之前的硬切

ApprovalService 的 decide() 方法内部,‘never’ 策略的检查位于 waterfall 分发之前:

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'
  // ...然后才进入 waterfall
}

源码注释解释了为什么:

“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 that ‘never’ rejects deterministically regardless of registration order”

如果 ‘never’ 是一个 listener,任何人都能用 prepend: true 注册一个优先级更高的 listener 返回 allowed-once 来绕过它。这不可接受——‘never’ 是一个安全合约:“此 session 中所有需要审批的操作自动拒绝”。这个合约必须由服务自身强制,不能委托给 listener 链。

另外注意 answerer 的错误处理:

const answer = Promise.resolve().then(
  () => this.ctx.waterfall(...),
).then(
  outcome => OUTCOMES.includes(outcome) ? outcome : 'unavailable',
  () => 'unavailable',  // answerer 抛错 → fail-closed
)

answerer 抛异常?unavailable。answerer 返回非法值?unavailable。全部 fail-closed。

第六层:Sandbox Escalation——deny-then-retry 编排

bash 和 fs 这两个第一方沙箱工具不使用 pre-execute ask。它们用一种完全不同的模式:

  1. 用当前 effective sandbox mode 尝试执行
  2. 被沙箱拒绝时,返回特定格式的 denial marker:[sandbox: file access denied under read-only mode]
  3. 同时附带 escalation hint:[sandbox: escalation available — retry this exact command once with sandbox_permissions ...]
  4. 模型看到提示,如果认为确实需要更宽权限,重试并携带 sandbox_permissions + justification 参数
  5. approveEscalation() 先验证严格 widening,再通过 ApprovalService 问人

严格 widening 表

export const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
  'read-only': ['workspace-write', 'danger-full-access'],
  'workspace-write': ['danger-full-access'],
}

danger-full-access 不在表中——已经是最宽的了,没有可以升级到的目标。

非 widening 请求(比如在 workspace-write 下请求 read-only)直接抛错,不问人:

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`)
}

参数配对验证

validateEscalationArgs() 强制 sandbox_permissionsjustification 必须同时出现:

if (sandboxPermissions !== undefined && justification === undefined) {
  throw new Error('invalid escalation: sandbox_permissions requires a justification')
}
if (justification !== undefined && sandboxPermissions === undefined) {
  throw new Error('invalid escalation: justification is only valid together with sandbox_permissions')
}
if (justification !== undefined && justification.trim().length === 0) {
  throw new Error('invalid justification: expected a non-empty sentence')
}

这不是建议——是硬约束。不给理由不问人,空理由不问人。模型必须解释为什么需要更宽权限。

approveEscalation 的完整决策路径

export async function approveEscalation<A, C>(
  request: EscalationRequest,
  approval: EscalationApproval<A, C>,
): Promise<SandboxMode> {
  // 1. 非 widening → 直接抛错,不问人
  if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) {
    throw new Error(...)
  }
  // 2. 没有 approval service → 抛错
  if (approval.approver === undefined) {
    throw new Error(...)
  }
  // 3. 没有 agent → 抛错
  if (approval.agent === undefined) {
    throw new Error(...)
  }
  // 4. 通过 ApprovalService.request 问人
  const outcome = await approval.approver.request({
    agent: approval.agent,
    toolName: approval.toolName,
    callId: approval.callId,
    reason: `escalate sandbox to ${mode}: ${justification}`,
    ...
  })
  // 5. 只有 allowed-once 返回被批准的 mode
  switch (outcome) {
    case 'allowed-once': return mode as SandboxMode
    case 'rejected': throw new Error(...)
    case 'cancelled': throw new Error(...)
    case 'unavailable': throw new Error(...)
  }
}

注意审批 reason 是自动拼接的:escalate sandbox to ${mode}: ${justification}——用户在审批 UI 里看到的是模型给的理由,前面加了”想升级到什么 mode”的上下文。

第七层:PermissionPresetService——两个旋钮的打包

PermissionPresetService 不做审批,不做执行。它只管配置——把两个独立旋钮打包成用户友好的预设:

interface PresetSpec {
  sandbox: SandboxMode    // 沙箱模式
  approval: ApprovalPolicy // 审批策略
  name?: string
  description?: string
}

默认预设表:

预设名sandboxapproval含义
workspace-writeworkspace-writeask可写工作区,宽操作需审批
danger-full-accessdanger-full-accessnever完全访问,不弹审批框

切换预设时,service 把两个旋钮的值分别写入 session log(sandbox/modeapproval/policy 事件),再追加一个 permission/preset 事件记录用户的意图选择。执行层读的是旋钮事件,不是预设事件——预设只是 UI 语义糖。

为什么 danger-full-access 配 never?因为 danger-full-access 本身就不拒绝任何操作,没有东西会触发 escalation,所以审批框永远不会弹——设成 ask 也不会问到人,但 never 语义更清晰:明确告诉系统和模型”不要尝试问人”。

第八层:ask_user_question——完全不同的东西

这是最容易和审批混淆的一个。ask_user_question 是注册在工具注册表中的一个普通工具,模型可以主动调用它来向用户提问。

它和 ApprovalService 没有任何关系

  • ApprovalService:系统发起,在工具执行前拦截,问用户”允许这个操作吗?“用户只能 allow/reject/cancel。
  • ask_user_question:模型发起,在模型认为需要信息时主动调用,问用户任意问题,支持选项/多选/自由文本。

它背后依赖的是 UserQuestionServicectx.userQuestions),一个完全独立的 cordis 服务,有自己的 provider 注册机制:

export const inject = ['tools', 'userQuestions']

export function apply(ctx: Context): void {
  ctx.tools.register(defineTool({
    name: 'ask_user_question',
    description,
    parameters: { questions: { type: 'array', ... } },
    async execute(args, exec) {
      const result = await ctx.userQuestions.ask({
        questions: args.questions.map(...),
        ...exec.agent !== undefined ? { agent: exec.agent } : {},
        signal: exec.signal,
      })
      return { answers: result.answers.map(...) }
    },
  }))
}

UserQuestionService 有自己的安全约束:

  • 只有 runtime root agent 可以问用户——delegated child agent 不行(会抛 DELEGATED_CALLER
  • 没有注册 provider 时抛 NO_PROVIDER——不会静默失败

这意味着:子 agent 不能直接问用户问题。如果子 agent 需要用户输入,它必须把未解决的问题作为结果返回给父 agent,由父 agent(root)调用 ask_user_question。

取消协调:approvalCancelled 的精确语义

回到 pre-execute 流程。serviceAsk() 返回的 ToolAskResolution 有一个 approvalCancelled 字段:

interface ToolAskResolution {
  readonly decision: Extract<PreToolDecision, { kind: 'allow' | 'deny' }>
  readonly approvalCancelled: boolean
}

只有 outcome 是 cancelled 时它才为 true。这个标志被用在后续的取消协调中:

if (this.callerCancelled(exec) && askResolution.approvalCancelled) {
  return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })
}

含义是:如果调用者的 signal 被 abort 了,并且审批也是因为 cancel 被拒的,那这是一个”整体取消”场景——用户取消了正在进行的操作。结果用 toolAbortedBeforeDispatchResult() 而不是普通的 deny 错误。

如果调用者 signal abort 了但审批是因为 rejected(用户主动点了拒绝),那不走这个分支——这是”用户拒绝了操作”,不是”操作被取消了”。语义不同,模型收到的错误信息也不同。

完整流水线图

把所有层次画在一起:

flowchart TD
    accTitle: Permission/Approval/AskUser 三层架构
    accDescr: 从 pre-execute waterfall 到 ToolGuard 到 ApprovalService 的完整决策流水线,以及独立的 ask_user_question 工具路径。
    
    subgraph "Pre-Execute Pipeline"
        W1["tools/pre-execute waterfall"] --> W2{"决策?"}
        W2 -->|"allow"| G1["ToolGuard chain"]
        W2 -->|"deny"| ERR["isError result → 模型"]
        W2 -->|"ask"| SA["serviceAsk()"]
        SA --> SA1{"ApprovalService<br/>存在?"}
        SA1 -->|否| ERR
        SA1 -->|是| SA2{"agent 存在?"}
        SA2 -->|否| ERR
        SA2 -->|是| SA3["ApprovalService.request()"]
        SA3 --> SA4{"outcome?"}
        SA4 -->|"allowed-once"| G1
        SA4 -->|其他三种| ERR
        G1 --> G2{"任何 guard<br/>返回 reason?"}
        G2 -->|是| ERR
        G2 -->|否| EXEC["执行工具体"]
    end
    
    subgraph "Sandbox Escalation (独立路径)"
        E1["bash/fs 执行被沙箱拒绝"] --> E2["denial marker + hint"]
        E2 --> E3["模型重试带<br/>sandbox_permissions"]
        E3 --> E4["validateEscalationArgs"]
        E4 --> E5{"strict widening?"}
        E5 -->|否| E6["throw,不问人"]
        E5 -->|是| E7["approveEscalation →<br/>ApprovalService.request"]
        E7 --> E8{"allowed-once?"}
        E8 -->|是| E9["用请求的 mode 执行"]
        E8 -->|否| E6
    end
    
    subgraph "ask_user_question (完全独立)"
        Q1["模型调用 ask_user_question"] --> Q2["ctx.userQuestions.ask()"]
        Q2 --> Q3["UI provider 显示问题"]
        Q3 --> Q4["用户回答"]
        Q4 --> Q5["答案作为工具结果<br/>返回给模型"]
    end

ApprovalService 的 policy 切换机制

策略不是静态的——可以在 session 生命周期内动态切换:

setPolicy(agent: Agent, policy: ApprovalPolicy): void {
  const previous = this.effectivePolicy(agent.session)
  if (previous === policy) return
  setApprovalPolicy(agent.session, policy)
  agent.inject(createUserMessage({
    content: [{ type: 'text', text: `The approval policy changed from "${previous}" to "${policy}" (changed by the user).` }],
    source: { kind: 'plugin', plugin: 'user-approval' },
  }))
}

切换时做了两件事:

  1. approval/policy 事件到 session log(持久化,replay 可恢复)
  2. 注入一条 user message 告诉模型策略变了

模型需要知道策略变化,因为它影响行为:在 never 下,模型不应该尝试 sandbox escalation(系统提示里直接说了 “do not request sandbox escalation (do not set sandbox_permissions)”)。

策略的 runtime-context 表达通过 systemPrompt context 实现:

  • 'never':告诉模型 “Approval prompts are disabled in this session: actions that require approval are rejected automatically”
  • 'ask':告诉模型 “Operations that require approval may ask through the configured answerers”

关键设计决策的背后逻辑

为什么没有 always-allow?

ApprovalOutcome 只有四个值:'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'。没有 allowed-always,没有 allow-for-session

这是安全边界设计。模型可能被 prompt injection 攻击——如果存在”永久允许”机制,一旦攻击者诱导模型获得了持久化的 rm -rf / 权限,后续所有恶意操作都没有第二道防线。one-shot 意味着每次危险操作都需要独立的人类确认,attack surface 被限制在单次调用。

为什么 sandbox escalation 不用 pre-execute ask?

因为 bash/fs 的 90% 操作在当前 sandbox mode 下就能成功。如果每次执行前都弹审批框(pre-execute ask 模式),用户每跑一个 lscat 都要点允许——体验极差。

deny-then-retry 模式的优势:

  • 绝大部分操作静默通过,不打扰用户
  • 只有真正被拒绝时才问——而且是模型主动判断要不要申请,不是系统自动弹
  • 模型必须提供 justification——用户看到的不只是”bash 想写文件”,而是”bash 想写文件,原因是 xxx”

为什么 ask_user_question 不让 child agent 调用?

if (!agents.roots().includes(agent)) {
  throw new UserQuestionError(
    'human interaction is unavailable while the calling agent is owned by another live agent; '
    + "include the unresolved question or decision in the child agent's final result",
    'DELEGATED_CALLER')
}

因为 child agent 的执行上下文由 parent agent 管理。如果 child 能直接弹框问用户,parent 对控制流的推理就被破坏了——parent 不知道 child 什么时候会暂停等用户输入。强制 child 把问题返回给 parent,保持了 delegation 链的可预测性。

常见误区

误区一:以为 bash 每次执行前都问用户。

不对。bash 只在被沙箱拒绝 + 模型带 sandbox_permissions 重试时才问。普通 lscat、在工作区内写文件,都不经过审批。

误区二:以为 pre-execute listener 返回 allow 就一定能执行。

不对。listener allow 之后还有 ToolGuard。任何 guard 都能单独否决。而且 guard 是 per-scope 的——agent-level 注册的 guard 只对那个 agent 的调用生效。

误区三:以为 ask_user_question 是审批的一部分。

完全不是。ask_user_question 是模型主动发起的信息获取工具,和 ApprovalService 没有代码调用关系。它走 UserQuestionService,有自己的 provider 注册和错误体系。

误区四:以为可以用 prepend listener 绕过 ‘never’。

不能。decide() 在进入 waterfall 之前就检查了 policy。你的 listener 根本不会被执行。

误区五:以为 sandbox_permissions 可以请求更窄的 mode。

不行。non-widening escalation 直接 throw,不到 ApprovalService。在 danger-full-access 下请求 workspace-write?直接错。在 workspace-write 下请求 read-only?直接错。

误区六:以为 approval/asked 和 approval/decided 可以不在 turn 内。

不行。request() 第一件事就是检查 hasOpenTurn(),不在 open turn 内直接抛错。这是 replay 安全的硬约束——turn 外的事件在 reload 时被当作 crash tail 丢弃。

三者的交互矩阵

场景PermissionApprovalAskUser
用户切换权限预设写 sandbox/mode + approval/policy收到新 policy不涉及
插件想拦截危险工具不涉及pre-execute ask → request()不涉及
bash 被沙箱拒绝后重试不涉及approveEscalation → request()不涉及
模型需要用户确认方案不涉及不涉及ask_user_question
CI headless 模式preset=danger-full-accesspolicy=‘never’,无 answerer无 provider

Permission 是用户的入口——切预设。Approval 是系统的拦截器——在工具执行路径上做决策。AskUser 是模型的工具——主动获取信息。三者通过 session 事件系统共享状态,但各自独立运作。


下一章讲审批之后的事:被批准的 bash 命令具体怎么执行,沙箱怎么包装 argv,子进程环境怎么清理。