人机协作平面:审批、权限与提问
专栏:DeepSeek Harness · 第 12 / 14 篇第 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。
审批流水线全景
两个细节见功力:审批请求刻意不含工具参数(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。
权限旋钮的架构位置
与其他模块的联系
- 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。