DeepSeek Harness 的工具审批:没找到默认发起 ask 的策略插件
先把前提摆在最前面:deepseek-ai/deepseek-harness 这个仓库建立于 2026-08-13,我们采集事实的时间是 2026-08-16,前后只差三天;根 package.json 里的版本是 0.1.0-rc.5,GitHub 上一个 Release 都没有,README 自述处于开发者预览阶段并明写未来会出现破坏兼容性的变更。下面提到的每一个文件路径、每一行行号、每一个字段名,都是我们在快照 47f9438 上实读所见,随时可能变。
起因:ask 这个决策,到底是谁返回的
翻 dsh 的工具子系统时,最先注意到的是 tools/pre-execute 这个 waterfall 的返回类型:
type PreToolDecision =
| { kind: 'allow' }
| { kind: 'deny'; reason: string }
| { kind: 'ask'; reason?: string }
这段在 docs/subsystems/tools.md:385-389,源码同型在 packages/core/tools/src/index.ts:584-591。注释原文写着「Input rewriting is excluded because arguments are already logged and presented.」——也就是说这个扩展点被刻意限定成只做放行、拒绝、询问三件事,不许改参数。
三个分支里,allow 和 deny 都好理解,ask 特殊:它意味着「停下来问人」。管线图(docs/tool-execution-pipeline.md:9-58)里也确实给它留了一整条边:pre --ask--> approval,然后 approval --allowed-once--> guards,或者 approval --rejected, cancelled, unavailable--> denied。页尾 :60 还有一句「ctx.approval resolves asks before monotonic guards」,明确了审批发生在 monotonic guards 之前。
落地实现也在:prepareExecution()(packages/core/tools/src/index.ts:1463-1507)先跑 tools/pre-execute waterfall,默认值是 { kind: 'allow' }(:1475-1478),若结果是 ask 就调 serviceAsk(:1479-1481),之后才算 guard 的拒绝理由(:1486-1488)。serviceAsk 本身在 :1689-1729,五条分支各自的返回与拒绝理由字符串都写在源码里。
一整条链路从类型、文档、流程图到实现都齐了。那么问题来了:谁来返回那个 { kind: 'ask' }?
判定动作一:把所有监听点 grep 出来
这个问题有个很直接的确认办法——tools/pre-execute 是个事件名字符串,直接搜就行:
grep -rln "'tools/pre-execute'" packages/ --include=*.ts | grep -v "/tests/"
在这份快照的非测试源码里,这条命令命中七个文件:
| 文件 | 它在这条链上的角色 |
|---|---|
packages/core/tools/src/index.ts | 注册表自身 |
packages/core/tools/src/invariant.ts | 不变量检查 |
packages/core/scope/src/scoped-events.generated.ts | 生成的 scope 事件表 |
packages/extensions/tool-cordis/src/api-catalog.ts | API 目录数据 |
packages/hooks/hooks-claude-code/src/index.ts | Claude Code hook 桥 |
packages/hooks/hooks-codex/src/index.ts | Codex hook 桥 |
packages/jobs/tool-jobs/src/index.ts | 记录输出上限后直接 next()(:233-237) |
前四个是基础设施:注册表、不变量、生成的事件表、目录数据,都不是「策略」。tool-jobs 那个监听器按仓库里的说法是记录输出上限后直接 next()(packages/jobs/tool-jobs/src/index.ts:233-237),不做决策。剩下两个是 hook 桥。
也就是说,在这份快照的非测试源码里,没有一个独立的、已发布的插件,会在默认情况下对普通工具调用返回 ask。
判定动作二:guard 那一侧也不是入口
顺手把另一个策略面也核了。ToolGuard 的类型是:
type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
在 docs/subsystems/tools.md:324。同页 :313 原文说得很直白:guard 的返回类型故意没有 allow,返回 undefined 保留 waterfall 的决定,返回字符串只能降低权限,所以「listener ordering cannot turn a denial back into permission」。换句话说 guard 单调收紧,压根不能发起询问。
那它被用在哪?
grep -rn "tools\.guard(" packages/ --include=*.ts | grep -v "/tests/"
非测试源码里的真实调用点只有一处:packages/subagent/subagent-in-process-driver/src/structured.ts:109。另一处命中在 packages/core/tools/src/index.ts:1114,那是注册表内部的 { label: 'tools.guard()' } 标签,不是调用点。
两条路都走完,结论就是前面那句:我们没能在仓库里找到一个默认就会对普通工具调用发起 ask 的独立策略插件。这只是我们在快照 47f9438 上的查找结果,不推断原因,也不据此评价这个项目。
仓库里 ask 的两个已知来源
「没有默认策略插件」不等于 ask 永远不会出现。我们在这份快照里核到的 ask 来源有两个,都是有条件触发的:
其一,两个 hook 桥的 PreToolUse 映射。 packages/hooks/hooks-claude-code/README.md:37-45 那张表里写着 PreToolUse→tools/pre-execute(waterfall),并注明 deny→PreToolDecision.deny、ask→PreToolDecision.ask。也就是说你配的外部 hook 返回 ask,桥会把它翻成管线里的 ask。但同一份 README 也把限制写在明处:只有 shell 形式的 type: 'command' hook 会跑,http / mcp_tool / prompt / agent 会被解析后跳过并告警(:97 的「Handler and config support is partial」);多个 hook 在同一点上串行、按配置顺序执行,并按 deny > ask > allow 做 most-restrictive 折叠(:49)。这两个桥包里还挂着一串未完成项,比如 packages/hooks/hooks-claude-code/src/index.ts:50 的 TODO(per-session-hook-config)、:189 的 TODO(hook-continue-false)、:205 的 TODO(session-start-gating)、:269 的 TODO(stop-loop-guard),Codex 桥在 :48 / :172 / :187 / :257 有四个同名 TODO。README 里还有 SessionStart is partial、Stop is partial 两条明确标注。
其二,沙箱升级路径。 packages/sandbox/sandbox/src/escalation.ts:157-189 的 approveEscalation() 会发审批请求,reason 拼成 escalate sandbox to ${mode}: ${justification}(:177)。注意它前面有三道硬关:先查是否严格加宽,不加宽直接抛错,注释原文是「A non-widening request never prompts a human.」(:150);没有审批服务抛错;没有 agent 可路由也抛错。升级阶梯是硬表(:28-30):read-only → ['workspace-write','danger-full-access'],workspace-write → ['danger-full-access'],read-only 是地板。
这两条路都不是「默认对任意工具发问」,而是各自绑定在具体机制上。
审批那一侧其实是齐的,缺的是发起方
另一侧的情况正好相反:审批子系统的类型、兜底路径与限制条目在仓库里都是齐的,文档明写它是 fail-closed 的。
结果词表是封闭四值:type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'(docs/subsystems/approval.md:28,源码 packages/interaction/user-approval/src/index.ts:82 的 OUTCOMES 数组同型)。文档 :21 原文写「A missing, non-owning, throwing, or non-conforming answerer becomes unavailable rather than opening the gate.」——答不上来就归到 unavailable,而不是放行。源码侧也一一对应:无 answerer 兜底 'unavailable'(:320)、返回值不在词表内归一化成 'unavailable'(:325)、抛错同样归一化(:328)、signal abort 时 resolve 'cancelled'(:334)。
而 serviceAsk 那五条分支里,第一条就是「ctx.get('approval') 为 undefined」时直接 deny,理由字符串原文是 tool "${exec.name}" requires approval (not yet supported)(packages/core/tools/src/index.ts:1696)。这句里的 not yet supported 是写死在拒绝理由里的。
再看 packages/interaction/user-approval/README.md 的 Known Limitations 段,其中一条原文是「No built-in answerer — headless or incompletely composed deployments resolve unavailable and fail closed; the service itself never prompts a human.」另一条是「Only one-shot grants exist — the outcome vocabulary has allowed-once but no allow-always, remembered rule, revocation, or grant store; session policy is only ask / never.」
把这几处并排看:审批服务不自己弹窗、不自己发问,它等着别人调;而在这个快照的非测试源码里,我们没找到那个「别人」。
别把权限预设当成这件事的答案
很容易顺手把 permission 预设理解成「谁发起 ask」的开关,但代码里这是两件事。
预设服务管的是两个 knob:sandbox mode 加 approval policy。packages/interaction/permission-presets/src/index.ts:161-178 的默认表是两档——workspace-write(workspace-write + ask)与 danger-full-access(danger-full-access + never)。而 packages/bundle/base/cordis.patch.yml:193-205 给已发布组合配的是三档,多一个 read-only(read-only + ask)。这两处不一致,我们只记录差异位置,以实读到的仓库状态为准。
关键在于 approval 这个 knob 的取值只有 'ask' | 'never' 两档(docs/subsystems/approval.md:46,源码 user-approval/src/index.ts:94),它作用的位置在 user-approval/src/index.ts:306-312:先 if (signal?.aborted) return 'cancelled',再 if (this.effectivePolicy(session) === 'never') return 'rejected',两个判断都在 waterfall 派发之前,注释原文写「a listener registered later with prepend cannot bypass it」。而「要不要发起 ask」的判断在另一处——prepareExecution() 里 pre-execute waterfall 的返回值。两个入口在代码里是分开的。
什么情况说明你碰到的不是这件事
排查到这一步,得把边界划清楚,否则很容易把别的现象也算到这条账上:
- 如果你看到的是
[sandbox: file access denied under <mode> mode]或者[sandbox: escalation available — retry this exact ... with sandbox_permissions ... + justification; the approval prompt asks the user]这两串模型可见标记(escalation.ts:71-73、:84-86),那走的是沙箱升级路径,跟「有没有通用策略插件」是两回事。 - 如果你配了 Claude Code 或 Codex 的 hook,那
ask有来源,只是要按上面那些 partial 条目对一遍你用的字段在不在支持范围里。 - 如果拒绝理由里带
(not yet supported),说明压根没组合审批服务,问题在组合配置层,不在策略插件层。 - 如果会话策略是
never,判断在 waterfall 之前就结算了,再挂多少监听器也进不到那一步。
真要自己补一个,文档里给了哪段
docs/cookbook/extension-cookbook.md:11-33 有一段 permission-gate 示例,这是我们在这个仓库文档里找到的唯一一段可抄的「拒绝策略插件」代码:
export const name = 'permission-gate'
export function apply(ctx: Context) {
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (!(await isAllowed(exec))) {
return { kind: 'deny', reason: 'Denied by policy.' }
}
return next()
})
}
要留意的是:它示范的是 deny,不是 ask。ask 虽然在 PreToolDecision 联合类型里是合法分支,但文档里没有给对应的示例代码,我们也没有运行验证过任何一种写法。真要动手,请以官方文档与仓库最新源码为准。
另外,docs/cookbook/adding-a-tool.md:59 把几个策略面的分工列在一处,值得抄在手边:tools/pre-execute 做可扩展的 allow / deny / ask;ctx.tools.guard() 做最终的单调 deny;tools/execute 加 deadline / retry / metrics;tools/post-execute 换内容或换值、block、附加上下文;tools/result 只观察不可变结果。你要往哪一层挂东西,先照这句对位置。
最后提醒一句:dsh 是会在本机执行工具、跑 shell、起子进程与沙箱的东西,工具调用的放行策略直接关系到本机上会发生什么。这份快照里我们没找到通用询问插件这一事实,只描述仓库状态,不构成对任何部署方式的建议。
延伸阅读
- 从头读起:DeepSeek Harness 是什么:建仓三天、13 万 star 的 Agent 框架
- 本专题共 45 篇,完整分组目录见专题页
- DeepSeek Harness 的 bash 工具参数:目录五个,源码再展开两个
- DeepSeek Harness 的工具调用管线:从 pre-execute 到结果落盘
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-16,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库建立于 2026-08-13,README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。