ARTICLE · 990315
DeepSeek-Harness 源码剖析(七):没有一个权限模块,tools 插件凭什么管住所有调用
摘要:dsh-tools 的 1946 行里没有一个集中的权限模块:注册表管模型看见什么,强校验 schema 子集保证所见即所执,钩子链管放行,单调守卫让拒绝不能翻案,fuseToolSignals 让取消甩不掉。这篇跟着一次 bash 调用过完全部关卡。
上一篇走完 agent-loop 的主循环,模型在 assistant 消息里伸出手:要调 bash,参数是删掉某个临时目录的命令。这只手能不能伸出去?参数合不合法?没人值守的时候谁替用户点头?跑一半被取消了算谁的?
这一连串问题的答案都在 ctx.tools,也就是 packages/core/tools 的 ToolRuntime(index.ts:787)。
先说一个违反直觉的事实:这个 1946 行的文件里,没有权限模块。审批服务、超时策略、沙箱限制,ToolRuntime 一个都不认识,它们全是挂在管线钩子上的独立插件。准入的活被摊开了:注册表决定模型看见什么,钩子链决定放行什么,守卫负责否决,谁也不揽全局。
这篇就跟着那次 bash 调用走全程,一道关卡一道关卡过。
一、先过可见性:注册表挂在作用域链上
工具注册表不是一张扁平的 Map。它的状态是 ScopedLayers(index.ts:811),每个作用域一层,层与层之间按 scope 链组织。ToolLayer(index.ts:714)聚合四类贡献:
tools:本层注册的工具,同层重名直接抛错; restrictions:allow/deny 集合,沿链逐个求交; guards:单调守卫,后面细讲; mode:本作用域的展示模式,native 工具调用还是 Code Mode。
这四类贡献在源码里就是 ToolLayer 的四个字段(index.ts:714):
class ToolLayer implements ScopeLayer { readonly tools: NamedEntries<ToolDefinition> readonly restrictions = new AnonymousEntries<CompiledToolRestriction>() readonly guards = new AnonymousEntries<ToolGuard>() // … mode: ToolPresentationMode | undefined}注意 mode 是单个槽位,不是一张表。源码注释的原话:two answers to 「which form does the model see」 is a contradiction, not a merge,同一个作用域对「模型该看到哪种形态」给出两个答案,那叫矛盾,没有合并这回事。
模型能看见哪些工具,由 view(scope)(index.ts:1152)统一解析。规则三条:继承面等于全局层加祖先链各层,近者遮蔽远者;限制条件沿整链求交,祖先任何一层都能给嵌套作用域遮掉一个工具名;本层自有注册豁免于限制(index.ts:1178-1184)。
第三条值得停下看一眼。委派运行时会把「汇报进展」「交结构化输出」这类应答工具注册进子代理自己的层,能力过滤如果连这套工具都剥掉,子代理就没法应答了。所以自有的注册不受限制约束,这条豁免是给「它用来干活的那套家什」留的活口。
还有一条豁免级别更高:run_code 传输工具最后插入、豁免一切过滤,连 restrict() 点名它都会被拒绝,报错原文写在 index.ts:1086:cannot name reserved Code Mode presentation transport。
注意这里的安全语义分了层。restrictions 让工具从模型视野里消失,模型根本不知道它存在;管线里的拒绝是模型看见了、伸手了、被挡回来。看不见和看见了被拒是两回事,dsh 把它们分开建模,前者管能力面,后者管审计面。
我们的 bash 调用过了第一关:bash 在可见集里。但模型看见的不只是工具名,还有一份参数 schema。这份 schema 可信吗?
二、所见即所执:schema 只收强校验子集
工具作者用 defineTool(schema.ts:545)注册工具:隐式参数表编译成 JSON Schema,包裹后的 execute 先 validate(args),有违例就抛 ToolArgsError,错误带路径,用户函数碰不到脏参数。
关键在编译器的严格程度。dsh 的 schema DSL 只收一个子集(json-schema.ts,656 行):单值 type、object 的 properties/required/additionalProperties:boolean、array 的 items、标量 enum/const、exact-one oneOf。不支持或放错位置的关键字,一律拒绝。
「拒绝」不是抛个异常完事,走读器把违例全攒进一个数组(json-schema.ts:236):
if (task.kind === 'one-of-tail') { for (const key of ONE_OF_SIBLING_KEYWORDS) { if (Object.hasOwn(task.node, key)) violations.push(`${task.path}.${key} is not supported beside oneOf`) } continue}这段查的是 oneOf 旁边不许出现的兄弟关键字。注意 push 不是 throw:违例记完接着走下一节点,整棵 schema 走完才统一抛 unsupported JSON schema: …,错误码 UNSUPPORTED_SCHEMA。
差别在哪?通用 JSON Schema 校验器遇到不认识的关键字,惯例是「接受但忽略」。工具作者写了 pattern 或 minimum,校验器装作没看见,模型却从 wire schema 里读到了这条约束,以为执行层会查。dsh 把这条路堵死:模型看到的约束与运行时执行的约束恒等,所见即所执。需要 pattern 这类表达力的工具,就得在 execute 里手动校验,明码实价。
这套走读还是栈安全且全函数的。schema 校验用显式任务栈而非递归,带 seen 集合检出循环 schema;值校验遇到带敌意的 getter 抛异常,回退到容错帧把子树记为非法。schema 走读一次收集全部违例,作者一轮修完,不用改一个跑一次。数值边界排除非有限数和 -0,跨 realm 对象用原生构造器指纹识别。我核稿时把这 656 行翻了一遍,光防御性细节就能列满一屏。
展示路径另有一条软校验通道。presentCall/presentResult 可能跑在旧日志的任意参数回放上,校验失败返回 undefined 落到通用卡片,不抛错;isConcurrencySafe 同样软校验,失败就不并发。宁可降级,不冒险。
一份 ToolDefinition 在这里派生出五种视图:wire schema(native 调用)、TS/Python SDK 文本(Code Mode)、子代理结构化输出的校验、UI 卡片(presentation.ts,三种挂起卡片 + 六种完成卡片)、执行契约(output schema + finalizeContent)。新增展示面不用改工具作者一行代码。
schema 过了,参数合法。现在调用真正进管线。
三、管线前半:先毙掉没得跑的,再问该不该跑
execute()(index.ts:1343)只有一行,是 prepareExecution 加 completeScheduledExecution 的组合。分阶段的版本经 symbol 键 [TOOL_RUNTIME_SCHEDULER](index.ts:466)从公共服务 API 里隐去,只有知道这个符号的调度方,agent-loop 和 run_code 桥,拿得到。这是「半公开 API」的惯用法:私有能力靠闭包和 symbol 传给拥有者,不摊在服务面上。
第一个阶段 createExecution(index.ts:1364)做三件事:生成 Symbol token(不可伪造)、snapshotJsonValue 给参数做无损 JSON 快照再 deepFreeze、在参数固化之前捕获 finalizer(index.ts:1407,防止参数 getter 在快照期间替换已注册回调)。
然后是我全篇最想讲的一段:模式折叠检查排在策略执行之前。
Code Mode 下,模型如果直呼了一个非 run_code 的工具,这次调用注定失败,没有任何策略能救它。dsh 的处理是在 pre-execute、审批、守卫之前就用 UNKNOWN_TOOL 终止(index.ts:1375-1381 的注释把原则写死了:策略监听器绝不能看到、更不能批准一个只会失败的调用)。拒绝消息还带路由提示,我核稿时把原文翻了出来(index.ts:1441):
only `run_code` is callable directly — call `bash` from inside a`run_code` program instead模型收到的不只是拒绝,还有正确的路怎么走。
第二阶段 prepareExecution(index.ts:1463)是权限的主场,依次四步:
caller 取消检查,没启动就 ABORTED_BEFORE_DISPATCH;tools/pre-executewaterfall,每个策略插件给出三值决策:allow、deny、ask; ask 走 serviceAsk(index.ts:1689),经ctx.get('approval')找审批服务。注意这是机会性消费:用时读取,非静态 inject。部署里没有审批服务、或者压根没有 agent 在场,ask 直接降级为 deny。无人值守的环境不会卡在等待人类点头上,它按最坏情况处理;allow 之后还要过 guardReason,全局层加 agent 链的单调守卫。
第 2 步的策略插件吐的决策就三种(PreToolDecision,index.ts:589):
export type PreToolDecision = | { kind: 'allow' } | { kind: 'deny'; reason: string } | { kind: 'ask'; reason?: string }注意 deny 必带 reason、ask 的 reason 可选:拒绝的理由注定要作为结果回给模型,询问的理由只是给人看的注脚。
第 4 步的守卫跑起来长这样(index.ts:1119):
private guardReason(exec: ToolExecution): string | undefined { const globalReason = this.layers.global.guardReason(exec) if (globalReason !== undefined) return globalReason if (exec.agent === undefined) return undefined for (const layer of this.layers.chainLayers(exec.agent)) { const reason = layer.guardReason(exec) if (reason !== undefined) return reason } return undefined}这段从全局层到 agent 作用域链逐层问守卫。注意返回类型 string | undefined:没有 allow 这个返回值,函数能做的只有报一个拒绝理由,或者什么都不说。
守卫的单边性就来自这里:插件挂载的顺序、数量的多寡,都无法把一个已经落下的拒绝翻回允许。deny 的理由会作为工具结果回给模型,所以我们的 bash 调用如果在这一关被拒,模型看得见理由,下一轮可以改用 read 去绕。三值决策、单调守卫、理由回传,这三件套就是 dsh 权限的全部「模块」。
每个异步检查之后都会重查一次取消。放行通过了,接下来才是真跑。

六道关主链路(view 可见性→createExecution 冻结→prepareExecution 权限→dispatch 执行→post 治理→finish 入账)+ 三值决策分支 + 取消双出路
四、管线下半:取消甩不掉,结果动不了
dispatchScheduledExecution(index.ts:1569)跑 tools/execute 的 around-waterfall,中间件可以包裹工具体。但围绕包装器有一条硬限制:只许换 signal。
换 signal 是取消语义的刚需(比如给工具加超时,就要给一个会超时 abort 的信号)。可如果包装器换上的信号跟调用者的取消断开了,用户按 Esc 就杀不掉正在跑的工具。dsh 的解法是 fuseToolSignals(index.ts:1889),手工实现双向 relay,把原始 caller 信号熔接到包装器信号上。替换再多次,取消链不断。核心执行体 dispatchToolBody(index.ts:1532)长这样:
const fused = fuseToolSignals(state.callerSignal, wrapperSignal)state.bodyInvoked = trueconst returned = await tool.execute(exec.arguments, exec)const result = this.createSuccessResult(exec, tool, returned)return isAborted(signal) ? toolAbortedResult(result) : result这段是工具体执行的骨干。注意两处:bodyInvoked 在真正调用前置位,取消时靠它区分「没启动」和「启动后被杀」两种错误码;body 完成后还要再查一次信号,已 abort 就把成功结果替换成 ABORTED。取消不抛弃已启动的 promise,等它跑完再定性。
执行完进 finalizeScheduledExecution(index.ts:1609)跑 tools/post-execute:accept 可以换 content 或 value,二者互斥;block 把纠正性 feedback 变成 isError 结果,且丢掉工具体 defer 的上下文,只暴露 blocking 决策显式给出的部分。超长输出截断、脱敏,都挂在这。
最后 finishScheduledExecution(index.ts:1631)物化结果:snapshot 加 deepFreeze,finalizeContent 对每个结果恰好调用一次,再固化一次。notifyResult(index.ts:1657)在 Object.freeze(exec) 之后 emit tools/result,每个监听器的异常吞掉记 warn。观察通道永远不成为结果通道,日志、UI、遥测谁崩了都不影响结果交付。这条管线还配了个 128 行的 invariant 文件(invariant.ts),断言阶段单调性和结果冻结,跑时就查。
到这里,单次调用闭环。可 dsh 还有一类工具会「套娃」:run_code 里再调工具。
五、run_code:把调度器整条借给模型
Code Mode 下模型不逐个敲工具,写一段 TS 或 Python 程序来编排。createRunCodeTool(code-mode.ts:296)构建这个保留工具,而它最值得看的设计是调度器复刻(code-mode.ts:358 起):桥不自己实现工具执行,直接调用注册表的分阶段接口 prepare/dispatch/finalize/finish,并复刻原生循环的顺序语义。源码注释里专门写着 matching the native scheduler's lazy reclassification,排队期间的注册表变更同样能把一个调用翻成 exclusive。
桥内排队调度的类型底座(code-mode.ts:359):
interface PendingDispatch { /** Ordered stage: append the start event, await prepare (pre-execute/guards), launch the body into `flight`. */ start(): Promise<void> classify(): 'parallel' | 'exclusive' /** Ordered stage: post-execute + context deferral + settle event, in submission order. */ commit(): Promise<void> // …}const pendingQueue: PendingDispatch[] = []const commitQueue: PendingDispatch[] = []let exclusiveActive = false这段是每个子调用的生命周期骨架。注意 start 和 commit 是拆开的两段:body 可以进并行池先跑,post-execute 却按提交顺序过,exclusive 的屏障一直 hold 到自己的 commit 完成,跟原生调度器一个节拍。
复刻的收益是直接的:run_code 程序里的每个子调用自动获得 pre-execute 策略、审批、守卫的全部行为。模型换了个写法,安全语义一寸不少。
注册表的私有能力(requireRuntime、peekRuntime、maxParallel)以闭包注入桥(code-mode.ts:269-281),不放到公共服务 API 上。只有拥有者才能执行的操作,通过闭包传出去,跟前面 symbol 键是同一个思路。
子调用的可观测性也做了分层。每次子派发落 tool/code-dispatch-start 和 tool/code-dispatch 两个事件,UI 和持久化看得到每个子调用;但两者都是 log-only,deriveMessages() 忽略它们,子调用永不重入模型上下文。模型只看到外层筛选过的 run_code 结果,上下文不膨胀,账本却全有记录。上一篇讲 repair 扫未配对的 tool/call,这里的事件就是同一本账。
背压也没漏:慢后端落盘期间,超过并行上限的任务在有序 lane 里等,I/O 和内存不无界增长;而程序本身拿值是立即的,日志监听器拖慢不了绑定。
六、设计取舍
为什么没有权限模块?因为工具集是 Agent 能力面上变化最快的部分,不同 preset、不同子代理、不同部署挂的工具完全不同,而执行管线是稳定内核。把准入摊成注册表、钩子、守卫三层,审批和沙箱作为独立插件挂上来,ToolRuntime 就不需要知道它们的存在。ctx.get('approval') 用时读取也是刻意的:静态 inject 会把整个 tools 插件扣死在「必须存在审批服务」上,纯 native 无人值守部署就没法跑了。
替换单个工具的成本压到了地板:defineTool 产出定义,register 入表,schema 编译、类型推断、管线、UI 卡片全部自动获得。替换 ToolRuntime 本体则要重写 agent-loop 的调度契约、system-prompt 的 schema 提供者、Code Mode 整套传输,没人会干这个,也正因如此接缝才敢这么切。
扩展入口有五个现成的:写自定义工具(声明 isConcurrencySafe 进并行池)、挂 tools/pre-execute 做细粒度权限、挂 tools/execute 做超时/降权/审计(只能换 signal,别想吞取消)、挂 tools/post-execute 做结果治理、自定义审批后端接 ask 决策。
已知代价两条。schema 表达力受限,无 pattern、minimum、anyOf、$ref,需要这些约束就得在 execute 里手动查;view() 每次全量重算继承和限制,无缓存,工具数上千时会成为热点。run_code 桥内嵌的第二调度器与原生调度器是两份实现,语义必须保持同步,这是复刻方案的账单。
七、结语
回到开头那次 bash 调用。它过了可见性(bash 在子代理的可见集里),过了 schema(参数合法),在 pre-execute 拿到一个 deny,理由作为 isError 结果写回对话,下一轮模型改用了 read。整条路径上没有一个人工确认的卡点,因为没人值守时 ask 自动降级 deny;也没有任何一个注定失败的调用走到策略层,模式折叠在更早的位置就把它毙了。
管线的形状就是一份准入清单:看得见(注册表)→ 看得真(schema 子集)→ 该不该(三值决策 + 单调守卫)→ 甩不掉(fuseToolSignals)→ 动不了(双冻结)。1946 行,没有一行叫「权限」。
门是过了。可命令真的跑起来的时候呢?bash 敲下去的每一条命令、读写的每一个路径,谁拦着它不越界?下一篇拆 sandbox。
你的 Agent 里,权限判断写在哪个位置?是集中一个模块,也摊在执行链上?评论区聊聊。
本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。