课程总纲 / 阶段 3 · 插件开发
L15

事件与拦截:权限门实战

用四种事件模式写拦截逻辑——审计、权限、改写全靠它。

预计阅读 35 分钟难度 ★★★★☆拦截类插件的必修课

一、先分清:两种"事件"Two Kinds of Events

最高频混淆点:tool/calltool/resultturn/*会话事件(L07 账本里的记录),要观察它们必须监听 session/event 再检查 event.typetools/resultagent/request 这类才是 Cordis 插件事件。写监听前先问:要"账本记录"还是"拦截点"?
// ✔ 观察账本里的工具结果(会话事件)
ctx.on('session/event', (_session, event) => {
  if (event.type === 'tool/result') { /* 记录 */ }
})

// ✔ 拦截工具执行管线(Cordis 事件)
ctx.on('tools/result', (exec, result) => { /* 观察 */ })

二、五种派发模式实战选型Dispatch Modes

模式语义典型用途
emit广播,无返回值通知类:统计计数、日志
waterfall处理链,可改写可短路拦截类主角:权限、改写、网关
parallel并发通知全部,await 全部互不依赖的并行处理
serial按序执行,首个有效结果截断有先后依赖的装配阶段
bail短路检查:第一个说"有"的定案快速否决检查

三、waterfall 深入:洋葱模型The Onion

监听器按注册顺序层层包裹,像洋葱(L05 讲过军规,这里画出形状):

ctx.on('tools/pre-execute', async (exec, next) => {
  // ── 进门:请求经过我(可检查、可改写 exec)
  if (!await isAllowed(exec)) {
    return { kind: 'deny', reason: 'Denied by policy.' }   // ← 不调 next = 短路定案
  }
  const decision = await next()      // ← 调 next = 交给下一层(洋葱更深处)
  // ── 出门:结果回来经过我(可再加工)
  return decision
})

四、实战:一个完整的权限门Permission Gate

import type { Context } from '@deepseek-ai/cordis'
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'

declare function isAllowed(exec: ToolExecution): Promise<boolean>

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()   // 放行给后续策略
  })
}

返回 ask 则转人工审批(L09 讲过的审批弹窗就是走这条路)。注意这个插件没有 inject——监听事件不需要等服务,事件天然在双方都在场时才发生。

五、工具管线四兄弟怎么选Choosing the Hook

工具执行管线上有四个观察/拦截点,选错位置是这类插件最常见的 bug 来源:

扩展点能做什么什么时候选它
tools/pre-execute返回 allow / deny / ask可扩展的策略:要"问人"选项时(唯一能 ask 的)
ctx.tools.guard()最终否决,后面的层改不回来需要不可绕过的单调拒绝(安全底线)
tools/execute包住整个执行生命周期超时、重试、指标——包住"干活的全程"
tools/post-execute替换展示内容/返回值、附加上下文结果时
tools/result只观察不可变最终结果审计、日志、统计——绝不改结果时

记忆口诀:问人 pre、底线 guard、包全程 execute、改结果 post、只看 result

六、实例:工具调用日志插件A Logger Plugin

import type { Context } from '@deepseek-ai/cordis'

export const name = 'tool-logger'

export function apply(ctx: Context) {
  ctx.on('tools/result', (exec, result) => {
    console.log(`[tool] ${exec.name}(${JSON.stringify(exec.arguments)})`)
    const text = result.content
      .map(b => b.type === 'text' ? b.text : '')
      .join('')
    console.log(`[result] ${text.slice(0, 100)}`)
  })
}

tools/result 而非 post-execute,因为 logger 只观察不改——选 result 保证你物理上改不了结果,错误从类型系统层面消失。

七、其他高频拦截点Other Hot Points

事件时机能干嘛
agent/pre-step(瀑布)认领输入后、发给模型前改写将进入模型的输入,或直接拒绝
agent/request(瀑布)模型请求组装完最终改写请求(如注入系统级内容)
agent/turn-stopping(串行)轮次将结束没有 next();可强制再走一步
llm/stream(瀑布)流式响应包住模型流
短路 short-circuit
不调 next() 直接定案,后续层不执行。
洋葱模型
监听器层层包裹:进门可改,出门可再改。
tools.guard()
单调最终否决,后续层无法推翻。
session/event
观察账本记录的统一入口。
✏️ 动手练习
  1. 把 tool-logger 挂进 scratch-plugin,跑几个任务观察日志输出。
  2. 写一个"禁用名单"权限门:Config 里配 blockedTools: string[],名单内工具在 pre-execute 返回 deny。验证模型被拒后如何反应。
  3. 进阶:把 deny 改成 ask,体验审批弹窗路径(需要 Web UI)。
  4. 对比实验:同一逻辑分别挂在 tools/result 和 tools/post-execute 上,理解"能改/不能改"的差异。
📝 自测(点击展开答案)
1. 我想观察"模型最终看到的每个工具结果"做审计,选哪个扩展点?为什么?
tools/result——给出不可变的最终规范结果,物理上无法篡改,审计语义精确。要改结果才用 post-execute。
2. waterfall 监听器里"return 没有 await next()"意味着什么?
短路:后续监听器不执行,你的返回值成为最终结果。决策类监听器(如权限门 deny)用它是设计意图;观察类误用会静默截断管线。
3. 为什么权限门插件可以不写 inject?
它不消费服务,只监听事件;事件在双方在场时才发生,不需要等服务就绪的时序保证。(对比:注册工具必须 inject ['tools']。)
4. "必须不可绕过的安全底线"应该挂在哪里?和 pre-execute 有何区别?
ctx.tools.guard()——拒绝是单调最终的,后面的监听无法推翻。pre-execute 是可扩展策略层(还能 ask),后注册的监听可能改写前面的决策。
5. 怎么监听 turn/end(会话事件)?
监听 session/event,检查 event.type === 'turn/end'。turn/* 是账本记录不是 Cordis 事件,直接 ctx.on('turn/end') 是错的。
权威出处:framework/events.zh.md(本课主源) · extension-cookbook(权限门示例) · cordis-primer(Waterfall Semantics)