跳到主要内容
路径文档

交互缝:审批、权限预设与命令

一句话版packages/interaction 把"人怎么介入运行中的 agent"拆成几条彼此独立的缝——ctx.approval 问一次"这个动作能过吗"(ApprovalOutcome 四值闭集,缺 answerer 就 fail closed)、ctx.permissionPresets 把沙箱模式与审批策略打包成一个用户可见的预设选择器、ctx.commands 让插件注册人类斜杠命令(不经模型轮次);第四个缝 ctx.userQuestions(模型向人提问)已在 专页 讲过,本页只画地图并交叉链接。

审计基线 0.1.5-alpha.1 @ 5dda764ed3:包名、ctx key、事件名、方法、类型与配置键均与官方源码逐点核对。

本页是"交互面"的核心课。读完你能回答:一次审批从工具请求到放行到底经过哪些环节、为什么答案只有四个值、answerer 怎么写和怎么组合、预设切换是怎么"写穿"到审批策略的、以及一个斜杠命令的注册、寻址、生命周期事件与结果去向。

一、四条缝的分工(地图)

ctx key谁发起答案 / 结果形态本页
审批ctx.approvalpackages/interaction/user-approval工具流水线、沙箱升级重试ApprovalOutcome 四值闭集二~五节
权限预设ctx.permissionPresetspackages/interaction/permission-presets用户(/permission预设名 + 两个旋钮事件六节
人类命令ctx.commandspackages/interaction/commands用户(UI 里敲 /xxxCommandResult,直接渲染七节
向人提问ctx.userQuestionspackages/interaction/user-questions模型(ask_user_questionAskUserQuestionAnswer专页

三条关系要点:

  • 预设写穿审批,审批不依赖预设permissionPresetsset() 调用 user-approval 导出的 setApprovalPolicy();反过来 ApprovalService 不知道预设存在,它只折叠 approval/policy 事件。所以移除预设包后,最后一次旋钮值仍然生效。
  • 命令注册表不属于模型面。命令的元数据、输入、直接输出都不进模型请求;只有命令产出者显式经 Agent 调度的工作才计 token。
  • 审批与提问是两条不同的路:审批是"要动作,请授权"(由工具/沙箱发起,人给一次性许可);提问是"我需要信息"(由模型发起,人给结构化答案)。两者都走 Agent 作用域 waterfall,但服务、事件名与答案类型完全不同。

用法层面的配置、环境变量与 /permission 操作见 权限;执行边界本身见 沙箱与安全

二、ctx.approval:API 与决策词汇

ApprovalServiceService'approval')是这条缝的服务定义(user-approval/src/index.ts:142)。公开面:

成员签名语义
requestrequest(req: ApprovalRequest): Promise<ApprovalOutcome>问一次;要求会话处于 open turn,审计成对落日志
setPolicysetPolicy(agent: Agent, policy: ApprovalPolicy): void切换 live agent 的策略,并给模型注入一条"策略已变更"的 user message
overrideOfoverrideOf(session: Session): ApprovalPolicy | undefined读日志里最后一条 approval/policy;没有 override 时返回 undefined
setApprovalPolicy模块级 setApprovalPolicy(session, policy): void唯一 durable 写路径(会话初始化直接用它);非法值先抛错再写
APPROVAL_POLICIESreadonly ApprovalPolicy[]['ask', 'never'],用于选项广播与运行时校验

有效策略由私有折叠函数给出:overrideOf(session) ?? config.policy ?? 'ask'user-approval/src/index.ts:235)。配置只有一项:

- name: '@deepseek-ai/dsh-user-approval'
config:
policy: ask # ask | never,缺省 ask

决策词汇是闭集user-approval/src/types.ts:32):

返回值含义调用方应当
allowed-once唯一的授权值:只对本次请求动作有效,不形成记忆规则放行这一次
rejected明确拒绝拒绝
cancelled请求被中止(signal abort)拒绝
unavailable没有 answerer、answerer 抛错、或返回了闭集外的值fail closed:按拒绝处理

ApprovalRequestuser-approval/src/index.ts:103)刻意不带工具参数:answerer 通过 callId 把提示挂到已经流式呈现的 tool call 上,而不是再渲染一份可能漂移的参数副本。

字段必填说明
agent替谁问;决定作用域路由与审计落在哪个会话
toolName提问针对的工具(展示 + 审计)
callId确切的工具调用 id,让 UI 把提示贴到它已经流式的调用上
reason提问方给出的人类可读理由
signalabort 即撤题:请求立刻 settle 成 cancelled,晚到的答案被丢弃

三、request() 的时序与 fail-closed 规则

request()user-approval/src/index.ts:207)的顺序是固定的:

  1. open turn 前置检查。会话日志必须处于一个 turn/start 尚未被 turn/end 关闭的区间;不满足直接抛错,不写任何事件。原因是审计对必须被 turn 包住——turn 是持久日志的提交/重放边界,夹在两个 turn 之间的裸事件在重载时与崩溃残尾无法区分。
  2. 生成一个全新的 ApprovalRequestId(randomUUID()),append approval/asked
  3. decide() 求一个 outcome:
    • signal 已 abort → cancelled
    • 有效策略是 neverrejected在任何 answerer 分发之前
    • 否则进入 ctx.waterfall(scopeTarget(req.agent, req.agent), 'approval/request', req, () => 'unavailable'),返回值不在闭集内 → unavailable,抛错 → unavailable(同步抛错也在同一条 containment 里);
    • signal 时与 abort 赛跑,abort 先到就返回 cancelled,之后 answerer 的晚到答案被丢弃。
  4. append approval/decided,返回 outcome。

三条 fail-closed 规则decide()user-approval/src/index.ts:258):没有 answerer 认领 → unavailable;answerer 抛错 → unavailable;返回非闭集值 → unavailable。另外,若审计 append 在提交点前失败,请求reject——返回一个没被记录的决策会破坏审计对。

消费方举例:工具流水线在 packages/core/tools/src/index.ts:1696ctx.get('approval') 机会性取服务,把 ask 决策映射成 allow/deny;沙箱升级重试共用同一缝(approveEscalationpackages/sandbox/sandbox/src/escalation.ts,细节见 沙箱与安全)。

四、answerer 的组合:approval/request 瀑布

服务本身没有 provider 注册表,接入点是 Cordis waterfall approval/requestuser-approval/src/types.ts:85):

declare module '@deepseek-ai/cordis' {
interface Events {
/**
* Ask composed answerers for one decision. Return an outcome to claim the
* request or call `next()` to delegate. Scope-filtered dispatch
* (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'approval/request'(
this: Scoped<Agent>,
req: ApprovalRequestEvent,
next: () => Promise<ApprovalOutcome>,
): Promise<ApprovalOutcome>
}
}
  • 返回 outcome 即认领,第一个返回值结束瀑布;return next() 表示"我不处理"。
  • 作用域过滤:请求经 scopeTarget(req.agent, req.agent) 派发,只有该 agent 作用域的 listener 收到;scope 层从 args[0].agent 取键(packages/core/scope/src/scoped-events.generated.ts:24)。
  • 终点默认是 unavailable,所以一个部署应当组合恰好一个终点 answerer;兄弟 listener 的先后顺序不是策略优先级。
  • listener 是普通 ctx.on(...),随插件 fiber 卸载自动注销。

官方组合里的两个真实 answerer:

实现位置行为
浏览器 UIpackages/client/ui-approval/src/client/index.tsctx.remote.$on('approval/request', …):把请求渲染成待决交互面板;Host 侧由 packages/api/remotes/src/remote-events.ts:18{ event: 'approval/request', mode: 'waterfall' } 转发到浏览器
ACP 机器决策packages/acp/acp/src/index.ts:155只回答它拥有的会话,且请求必须带 callId,否则 next();向客户端只提供 allow-once / reject-once 两个一次性选项,绝不从陌生响应推断持久授权

never 绕不过:策略在服务自身的 request() 路径上判断,早于任何瀑布分发,所以一个后注册的 prepend: true listener 也无法让 never 会话发出提问。

五、策略与审计事件

user-approval 声明四个事件:

事件模式载荷用途
approval/requestwaterfallApprovalRequestEventanswerer 接入点
approval/asked仅日志{ id, toolName, callId?, reason? }审计对前半,id 配对后半
approval/decided仅日志{ id, outcome }审计对后半,每次 ask 恰好一条
approval/policy仅日志{ policy, source?: 'delegation' }会话策略 override,最后一条生效;source: 'delegation' 标记播种进子代理的 override

两条审计事件不进模型 transcript:模型看到的是消费方最终的 tool 结果,加上运行上下文快照里的策略句子。

策略语义(ApprovalPolicyuser-approval/src/index.ts:63):

  • ask(默认)——委派给已组合的 answerer;一个都没有则链条落到 fail-closed 的 unavailable
  • never——不询问任何人,每次 ask 确定性地 rejected;这是 CI / 无人值守的严格立场。模型侧会收到一句"审批提示已禁用,不要请求沙箱升权(不要设 sandbox_permissions)"。

模型可见面由 systemPrompt 上下文贡献 approval:policy 提供(user-approval/src/index.ts:155):两种策略各贡献一句完整当前语义,追加在保留历史之后,切换策略不会重写稳定的系统提示前缀。setPolicy() 额外注入一条带来源的 user message 通告变更。

审计不变式user-approval/src/invariant.ts)在加载与新增事件两条路径上校验:approval/asked / approval/decided 必须在 open turn 内、按 id 一一配对、outcomepolicy 必须落在闭集内。失败消息形如:

approval/asked appended outside any open turn
approval/asked toolName must be non-empty
approval/asked repeated open id "<id>"
approval/decided has no matching approval/asked for id "<id>"
approval/decided carries unknown outcome "<value>"
approval/policy carries unknown policy "<value>"

错误形态:这条缝没有错误码枚举(对比 user-questionsUserQuestionError + 稳定错误码)。它的"失败分类"就是闭集 outcome;只有编程错误抛异常:

触发抛出
无 open turn 时调用 request()Error: approval.request() outside an open turn: …
setApprovalPolicy() 传非法策略TypeError: approval policy must be one of "ask" or "never"

六、ctx.permissionPresets:预设如何写穿到审批

PermissionPresetServiceService'permissionPresets'permission-presets/src/index.ts:162)把两个独立的强制旋钮——沙箱模式 sandbox/mode 与审批策略 approval/policy——打包成用户可见的命名预设。它自己不做强制:执行、提示与重放仍然各读各的旋钮折叠值。

一个预设就是一条表项(PresetSpecpermission-presets/src/index.ts:58):

字段含义
sandbox该预设写入的 sandbox/mode
approval该预设写入的 approval/policy
name客户端展示标签;省略时用表键
description一句用户可读说明;未配置则省略

插件源码里的默认表只有两项workspace-writeworkspace-write + ask)与 danger-full-accessdanger-full-access + never)。官方 base bundle 在 packages/bundle/base/cordis.patch.yml:229 覆盖成三项,多出 read-onlyread-only + ask)——你在 dsh web --dump-config 里看到的就是这张表。custom保留名:表项命名 custom 会在插件加载时抛错,因为它只用于派生的"拼不出任何预设"状态。

写穿路径set()apply()permission-presets/src/index.ts:379:384):

  1. resolve(name) 解析表项,未知名字抛错;
  2. 若当前有效预设不等于目标名,append permission/preset(仅日志的用户意图);
  3. 逐个比较旋钮的有效值,只对确实变化的那一个调用它自己的 canonical setter——setSandboxMode(来自 dsh-sandbox-policy)或 setApprovalPolicy(来自 dsh-user-approval)。净零选择不写任何事件。

current(session) 的推导顺序(permission-presets/src/index.ts:308):仍与旋钮匹配的上次选择优先(这样两个预设共享同一 bundle 时也能保住用户意图)→ 表内声明顺序的第一个匹配项 → 否则返回派生的 CUSTOM_PRESET'custom',只可显示、不可选中)。

读侧permissions 会话投影(permission-presets/src/index.ts:237):key 为 permissionsstateVersion: 2,折叠 permission/preset / sandbox/mode / approval/policy / session/end-seed 四个事件,wire 视图是 PermissionSelect(表内全部可切换预设 + 恰好当前时追加的 custom + currentValue)。投影 key 不存在 = 没有组合权限服务,客户端隐藏该控件。

默认值与会话钉入:设置命名空间 permissiondefaultPreset 只影响未来会话session/created 与已存在会话都会 pinInitialPermission()——全新会话写入默认预设与两个旋钮事实,种子/半初始化会话保留既有旋钮、只补缺失的持久事实。

/permission 命令是 web 客户端使用的唯一写入路径(permission-presets/src/index.ts:256;服务级写路径是 set()):裸命令报告当前值与可用表,带参数则 apply(..., policy => ctx.approval.setPolicy(agent, policy))。注意这个差异——直接 setApprovalPolicy() 只写日志,而 /permissionsetPolicy(),因此还会给模型注入策略变更通知

预设如何解析成审批行为:预设的 approval 旋钮最终落成一条 approval/policy 事件;ApprovalService 折叠它得到有效策略;never 在 answerer 分发之前短路,ask 才进入瀑布。预设与审批之间没有第二条通道

配置错误在加载期就失败(permission-presets/src/index.ts:192):

permission: "custom" is reserved for the derived not-a-preset state and cannot name a table entry
permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration
permission: composed sandbox and approval defaults match no preset; configure defaultPreset explicitly
permission: unknown preset "<name>" (known: <names>)
permission: permissions session projection is not registered

服务声明 static inject = ['shell', 'approval', 'sessions', 'sessionProjections']permission-presets/src/index.ts:183):没有会限制的 ctx.shell 执行器就没有 sandboxMode 事实,预设无从打包,因此组合失败而不是静默降级。

七、ctx.commands:插件命令注册表契约

CommandRuntimeService'commands'commands/src/index.ts:258)是插件拥有的人类命令注册表,由交互式 UI 适配器消费。用法级教程见 自定义命令与用户交互;本节只讲注册表契约。

注册契约

export interface CommandDefinition {
readonly name: string // 小写,^[a-z][a-z0-9_-]*$
readonly description: string // 发现 UI 里的说明,非空
readonly input?: CommandInputDescriptor // { hint: string; attachments?: boolean }
readonly recordInput?: boolean // 默认 true
readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
}

register(definition)commands/src/index.ts:280)会先校验再冻结一份脱离调用方的副本(normalizeDefinition:176):名字必须匹配 /^[a-z][a-z0-9_-]*$/udescription 必须是非空字符串,handler 必须是函数,input.hint 必须是非空字符串,input.attachments 给了就必须是布尔。返回精确的 disposer 用于注销;同一层内重名在注册时抛错。

所有权与作用域

  • 普通上下文里的注册是全局;挂在某个 agent 上下文下、声明了 commands 注入的子插件注册的是该 agent 作用域命令,对那个 agent 遮蔽同名全局命令。
  • 层由 ScopedLayers 合并:list(agent) 返回按名字排序的、无 handler 的 CommandDescriptorfind(agent, name) 返回作用域遮蔽后的 effective definition。
  • 注册/注销会 emit commands/change:87,emit 模式)。观察者失败被逐条 containment,既不能否决注册变更,也不会饿死后面的观察者。

寻址与参数

  • CommandId 是 brand 类型(commands/src/brand.ts:20);每次执行 mint 一个 cmd-<instanceToken>-<seq>,实例 token 前缀保证同一条被恢复的日志上重启后不会重复。
  • parseCommand(line)commands/src/index.ts:122)用 /^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u 切分:名字之后的全部字节(含分隔空白)原样作为 rawInput,命令自己定义语法。
  • 附件:只有声明了 input.attachments: true 的命令才接收;admission 在 execute() 内完成——图片经 admitEncodedImages 提交,文件经唯一的 registerFileReceiptResolver 提供的 resolver 解析 receipt,并按用户选择顺序还原混合顺序。不合规(未声明、无附件存储、未知 receipt、超限)在 handler 之前就 settle 成 error。

调用路径与生命周期

适配器调用 Host 侧 execute(agent, line, attachments, signal)commands/src/index.ts:356):

  1. parseCommand 或名字解析失败 → 返回 undefined不写任何日志(它从未进入 handler);
  2. mint commandId,在 handler 之前 append command/runrecordInput: false 时省略 args);
  3. admission 附件;
  4. 调 handler,normalizeResult 校验返回值形状;
  5. append command/done,返回 { commandId, result }

两条生命周期事件都是独立、仅日志的 append:没有 turn 包裹它们,持久化在普通 checkpoint 落盘;handler 抛错或被 abort 时 settle 成 kind: 'error'command/run append 失败让执行响亮失败;handler 失败路径上的 command/done append 失败被 containment,以保住 handler 自己的错误。

结果 CommandResult 只有两种形状——{ kind: 'success', text?, sourceEventSeq? }{ kind: 'error', text }——由派发它的 UI 直接渲染,不进模型历史sourceEventSeq(仅 success)指向更早的权威域事件,让客户端把命令生命周期与该域投影拼起来,而不必解析 text

浏览器半部的 Remote 门面以 sessionId 寻址(packages/client/ui-commands/src/client/service.ts:365),Host 侧方法的第一参数则是 agent

注册/结果校验的错误全是 TypeErrorcommands/src/index.ts:176:223):

command name "<name>" must match /^[a-z][a-z0-9_-]*$/u
command "<name>" description must be a string
command "<name>" description must not be empty
command "<name>" handler must be a function
command "<name>" input hint must be a string
command "<name>" input hint must not be empty
command "<name>" input attachments flag must be a boolean
command "<name>" handler must return a CommandResult
command "<name>" success text must be a string when supplied
command "<name>" success sourceEventSeq must be a non-negative safe integer when supplied
command "<name>" error text must be a non-empty string
command "<name>" returned unknown result kind "<kind>"

另外:第二个 registerFileReceiptResolverError: commands: a file receipt resolver is already registered;未知的文件 receipt 抛 AttachmentError('File upload receipt is unknown for this session.', 'ATTACHMENT_NOT_FOUND')。命令不变式(commands/src/invariant.ts)校验 command/done 必须配对同一会话日志里先前的 command/run,且 sourceEventSeq 必须指向更早的、非 command 事件。

源码里可见的注册方:/permissionpermission-presets/src/index.ts:256)、/planpackages/plan/plan-mode/src/index.ts:225)、/compactpackages/compaction/command-compact/src/index.ts:100)、/feedbackpackages/feedback/command-feedback/src/index.ts:61)、/goalpackages/goal/command-goal/src/index.ts:190)、/exportpackages/session-query/session-log-export/src/index.ts:78)。

源码佐证

文件符号 / 行
packages/interaction/user-approval/src/index.tsApprovalService :142;request() :207;setPolicy() :176;overrideOf() :244;setApprovalPolicy() :92;effectivePolicy() 折叠 :235;decide() :258;never 短路 :266;OUTCOMES :48;APPROVAL_POLICIES :63;approval/policy 事件声明 :33;approval:policy 上下文贡献 :155
packages/interaction/user-approval/src/types.tsApprovalRequestId :17;ApprovalOutcome :32;approval/asked :44;approval/decided :55;ApprovalRequestEvent :63;approval/request waterfall :85
packages/interaction/user-approval/src/invariant.tsvalidateApprovalEvent() :28(turn 内、id 配对、闭集校验)
packages/interaction/permission-presets/src/index.tsPresetSpec :58;CUSTOM_PRESET :73;PERMISSION_SETTINGS_NAMESPACE :76;Config :143;PermissionPresetService :162;static inject :183;permissions 投影注册 :237;/permission 命令 :256;current() :308;selectFor() :333;resolve() :350;optionOf() :365;set() :379;apply() :384;pinInitialPermission() :404
packages/interaction/permission-presets/src/types.tsPresetOption :13;PermissionSelect :27;SessionProjectionMap.permissions 声明
packages/interaction/commands/src/index.tsCOMMAND_NAME :31;CommandInvocation :40;CommandDefinition :60;parseCommand() :122;normalizeDefinition() :176;normalizeResult() :223;CommandRuntime :258;register() :280;registerFileReceiptResolver() :294;list() :310;find() :323;execute() :356;command/run append :368;command/done append :375;mintCommandId() :441;notifyChange() :470
packages/interaction/commands/src/types.tsCommandResult :34;CommandExecution :49;CommandDescriptor :57;commands/change :87;command/run :103;command/done :110
packages/interaction/commands/src/brand.tsCommandId :20
packages/interaction/commands/src/invariant.tsrun/done 配对与 sourceEventSeq 校验
packages/bundle/base/cordis.patch.yml审批配置 :225;三项预设表 :229
packages/core/tools/src/index.tsserviceAsk() 消费 ctx.approval :1696
packages/sandbox/sandbox/src/escalation.tsEscalationOutcome / approveEscalation:升权重试共用同一缝
packages/acp/acp/src/index.ts机器 answerer :155
packages/client/ui-approval/src/client/index.ts浏览器 answerer(ctx.remote.$on('approval/request', …)
packages/api/remotes/src/remote-events.tsapproval/request 远程转发 :18
packages/core/scope/src/scoped-events.generated.ts作用域键取 args[0].agent :24

验证

SRC=~/.dsh/source/official

# 1) 组合树:三个服务都在 web profile 里(含 base 覆盖后的预设表)
dsh web --dump-config | grep -nE "dsh-user-approval|dsh-permission-presets|dsh-commands"

# 2) 审批:闭集 outcome、瀑布声明、策略写路径
grep -n "allowed-once\|approval/request\|setApprovalPolicy" \
$SRC/packages/interaction/user-approval/src/types.ts \
$SRC/packages/interaction/user-approval/src/index.ts

# 3) 预设:保留名 custom、permission/preset 事件、写穿的两个 setter
grep -n "CUSTOM_PRESET\|permission/preset\|setSandboxMode\|setApprovalPolicy" \
$SRC/packages/interaction/permission-presets/src/index.ts

# 4) 命令:名字正则、解析函数、生命周期事件
grep -n "COMMAND_NAME\|parseCommand\|command/run\|command/done" \
$SRC/packages/interaction/commands/src/index.ts \
$SRC/packages/interaction/commands/src/types.ts

# 5) 运行时:审计对、命令生命周期与预设切换都写进会话日志(只读)
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd 2>/dev/null \
| grep -E '"approval/(asked|decided|policy)"|"command/(run|done)"|"permission/preset"' | head

第 1 条在本机 0.1.5-alpha.1 上会打印出 dsh-user-approvaldsh-permission-presetsdsh-commands 三个插件条目,以及 read-only / workspace-write / danger-full-access 三项预设。

下一步