Skip to content

Plugin Events zh

Dev edited this page Sep 7, 2026 · 1 revision

English · 中文

插件事件订阅

对应 v0.12.0(3a762b1)。ABI 是 wit/nanopi-extension.wit(world extension-events); 授权在 src/agent/hook::parse_event_grants 里校验,交集在 src/wasm/loader.rs 里算, 投递走 src/subscriber.rs(跨 guest 边界的那一段在 src/wasm/loader.rs)。 设计笔记和完整理由:docs/v0.12-events.md。 重新验证:cargo test --features wasm -- --test-threads=1

v0.12 新增:插件可以观察全部十一个生命周期事件——和 shell hook 看到的是同一批事件、同样的 payload。每个事件是什么、什么时候触发,见 Hooks-zh。


只能观察,而且这是结构上的

插件不能挡住一个事件、不能取消一轮对话、不能改写 prompt。这不是一道以后可以放宽的权限检查,而是写在类型里的:

  • handle-event 的返回值被丢弃。返回 "{}" 就行。
  • 宿主侧的 trait 方法根本没有返回值(src/subscriber.rs:fn handle_event(&self, event: &str, payload_json: &str);),所以实现者连"不小心"交回一个否决或改写都做不到。

为什么。 否决和改写是用户的特权,通过用户自己写的配置文件行使。把它交给一个别人打包的沙箱二进制,等于颠倒了谁在掌控。要拦住一件事,写 hook。


两个列表必须同时同意

投递要求这个事件同时在两边出现:

  1. 插件自己的 list-events 导出——它要求什么,以及
  2. 它那条 [[extensions]] 里的 events 列表——你授予什么。
[[extensions]]
path = "~/.nanopi/extensions/audit-watcher.wasm"
events = ["tool_execution_start", "session_start", "session_shutdown"]
export list-events:  func() -> string
export handle-event: func(event: string, payload-json: string) -> string

list-events 返回一个 PI 事件名的 JSON 数组,加载时只调一次,之后再也不调:

["tool_execution_start", "turn_start"]

交集在加载时算一次,永不重算。这就是插件的 list-events 无法自我扩权的原因。插件要了而你没给的事件,会在启动时被报出来:

requested event ["input"] but the config's `events` did not grant it — not delivered.

所以一个"订阅了却什么都没发生"的事件,会告诉你为什么。

events 不写或为空,就什么都不授。 一个导出了 list-events 的插件,在它要的名字也出现在你的配置里之前,收不到任何投递——和 url_allowlist、allow_tools 是同一种默认拒绝的形状。

事件名

十一个 PI 名字,也正是 [[hooks.*]] 的配置键:

tool_execution_start   tool_execution_end   input
before_agent_start     turn_start           turn_end          message_end
session_start          session_shutdown     session_before_compact   session_compact

events 里出现未知名字是插件加载时的一条告警,不是配置错误,告警会列出所有合法名字。四个已退役的 Claude Code 名字会得到和 hook 配置一样的改写建议:

unknown event "pre_tool_use" — did you mean "tool_execution_start"? (see docs/v0.12-events.md §2.1)

有两个名字对插件永久不可投递,而且会明说而不是被静默丢掉:

event "message_update" fires per streaming delta and is permanently undeliverable to plugins (see docs/v0.12-events.md §5.2)

message_update 和 tool_execution_update 是按流式 delta 触发的——每轮上百次跨边界调用——再怎么授权都不会改变这一点。

events 里的未知项不能是硬配置错误,因为 [[extensions]] 整块必须在不带 wasm 特性的标准二进制里保持"可忽略 + 告警"。对比之下,拼错的授权键就是硬错误——见 Configuration-zh。


payload 和 hook 收到的逐字节相同

payload-json 就是同一个事件下 shell hook 从 stdin 收到的那份 JSON,由同一个函数构造。插件和 hook 看到的东西完全一样——Hooks-zh 的线上协议一节对两者都是参考。

event 是 PI 对这个事件的命名:tool_execution_start,不是 pre_tool_use。

有一个细节值得知道:在 tool_execution_start 上,投递给插件的 payload 带的是改写前的参数——模型要求的那份——即使你的某个 hook 改写过它。而且当一个 hook 挡下这次调用时,插件仍然会收到这个事件:阻断被暂存起来,投递先跑,这是故意的。一个只观察的审计插件,不能恰好在策略拒绝了某件事的地方有盲区——被拒绝的那些调用正是它最在意的。


一个忙着的插件永远拖不长一轮对话

如果这个插件已经在另一次 guest 调用里面(一次工具执行,或者另一次事件投递),这次投递会直接丢弃,不等待。宿主用的是 try_lock,从不用阻塞锁:

nanopi: dropped event delivery, plugin busy [event=turn_start]

为什么丢弃是对的答案。 事件位于一轮对话的关键路径上。阻塞锁意味着一个慢插件把自己的延迟加到每一个事件上,而一个卡在循环里的插件会把整个 agent 拖停。既然 handler 只能观察,一次被丢弃的投递损失的只是信息,别无其他——没有任何决策押在它上面。这个取舍之所以成立,正是因为返回值被丢弃——这是"只能观察"是结构性而非政策性的第二个理由。

被污染(poisoned)的锁不算作"忙":它会被恢复,因为一次 panic 的调用不该让本会话余下的投递全部停摆。

每个 handler 跑在 spawn_blocking 上并被 await。panic 的 handler 会被记录,投递继续走向下一个订阅者——一个坏插件不会挡住其余插件的投递。一个零订阅者的事件只花一次 hashmap 查找,别无开销:构造 payload 的闭包连调都不会被调用——这也是 事件 → 订阅者 的索引在加载时预先算好、而不是每次触发时推导的原因。


2 秒的 epoch 预算

handle-event 里的 guest 代码有 2 秒,刻意小于工具调用的 30 秒(EVENT_EPOCH_BUDGET_TICKS = 2 vs EPOCH_BUDGET_TICKS = 30,且有一条编译期断言保证前者小于后者)。一个事件 handler 跑的时候屏幕上什么都不显示,它在一轮对话的关键路径上,而且没有任何人在等它的结果。

这个预算只管guest 代码。一个调用了 host-http-get 的 handler,还要再叠上那个函数自己的 10 秒超时——所以最坏情况大约 12 秒。

不要在事件 handler 里发网络请求。 如果一个 handler 需要访问网络,让它把看到的东西记进自己的 store,把真正的活儿放到有人在等结果的地方去做。

在受限网络下这条更要认真:一个被 GFW 拖到超时的 host-http-get,会让每一个触发它的事件都吃满那 10 秒。所以"记下来,晚点在别处做"不只是风格建议——它是在这种环境下唯一能用的形状。


怎么看自己的订阅

/tools 会列出当前所有在观察的插件:

Watching events (1 plugins)
  audit-watcher       tool_execution_start, session_start, session_shutdown

这一段列的是生效的订阅——交集,不是插件要求的那份。如果你授予的某个事件在这一行里没出现,说明插件从没要求过它;如果整行都不见了,说明这个插件什么都没订阅上。

/tools 还会给每个插件一行 Plugin grants,同一份订阅会在那里以 events(...) 这个 token 和其他授权并列出现。见 Plugin-Capabilities-zh。


安全:这是能力模型上最大的一次变化

events 是比 allow_fs 或 allow_network 更大的一次能力跃迁。 v0.12 之前,插件只能看到模型选择作为工具参数交给它的那些东西。现在:

  • 一个 tool_execution_start 订阅者看到每一次工具调用的参数——每条命令、每个路径、每次文件写入
  • 一个 input 订阅者看到你敲的每一句 prompt,原文
  • 一个 tool_execution_end 订阅者看到每个工具的输出

同一个插件上再加 allow_network = true,这就是一条外泄通道,nanopi 会在启动时对这个组合告警:

has both `events` and `allow_network = true` — this plugin can observe lifecycle
events AND reach the network, and could exfiltrate event payloads. Grant both only
if you trust the plugin.

两个能力各自都是显式 opt-in 的,所以这个组合得到的是告警而不是拒绝。但授予 events 时,请用你授予一个日志上传器的谨慎程度。

顺带说一句在国内环境里格外要注意的:一个 url_allowlist 只写了国内域名的插件,看起来"很本地",但它照样能把 payload 送出去——外泄的门是 allow_network 加白名单里的那些 host,和这些 host 在境内境外无关。别用"它只连国内"来替代阅读白名单。

--no-hooks 会连插件的事件投递一起关掉。 所有投递点都在和 shell hook 相同的 hooks_active() 判断后面(src/agent/permission.rs),所以这个紧急开关覆盖的是每一个生命周期观察者,不只是你配置里的那些。


写插件时要知道的

目标世界是 world extension-events

这几个世界是一条线性阶梯,不是格:

extension  ⊂  extension-commands  ⊂  extension-events

WIT 无法表达"可选的导出"——声明了却没实现,wasm-tools component new 就会失败——所以一个以 extension-events 为目标的插件必须同时导出 list-commands 和 execute-command,即使它一个命令都不注册。两行空实现是接受下来的代价。做成格的话,今天是四个世界,下个特性之后是八个。

import 和 export 不同,它是叠加的、不需要新世界:它们全都挂在 world extension 上,向上继承。

要了就得处理

通过 list-events 声明了事件却没有导出 handle-event,是硬加载错误。和命令不同,插件一旦说了"把这些投给我",就不存在一个"那就不派发"的合理退路。

反过来,缺少 list-events 导出是软的:插件正常加载,什么都不订阅。这正是让那些以 extension 和 extension-commands 为目标的插件不用改就继续能用的原因。

list-events 返回非法 JSON、trap、或者返回超过 32 个名字(MAX_EVENTS_PER_PLUGIN),对该插件都是硬错误:

list-events returned invalid JSON: <e> (got …)
list-events returned <n> events, more than the 32 allowed
list-events trapped: <e>

handler 要写得无聊

能用的形状是:读 payload、更新自己的 store、返回 "{}"。想告诉用户点什么,host-notify 没有门(每插件每轮限 10 行)。想对看到的东西采取行动,那是 allow_tools 或 allow_send_message——都是独立的授权,都在 Plugin-Capabilities-zh。


还没做的事

message_end 的 payload 带的是 turn_count 和 response_length——不是助手的文本。一个想对模型实际说了什么做出反应的插件,今天没法从事件里拿到它。这仍然是一个开放的设计问题,不是一个等着开关的特性。


相关

Clone this wiki locally