Skip to content

Plugin Capabilities zh

Dev edited this page Sep 7, 2026 · 2 revisions

English · 中文

插件能力门

对应 v0.12.0(3a762b1)。实现在 src/wasm/loader.rs,各道门的具体逻辑拆在 src/wasm/store.rs、src/wasm/notify.rs、src/plugin_context.rs、 src/plugin_tools.rs 和 src/plugin_send.rs;语义由 tests/wasm_plugin_integration.rs 以及上述各模块内的单元测试固定。 重新验证:cargo test --features wasm -- --test-threads=1


总原则

默认全关。 一个插件装上去之后,不显式打开开关,它读不了任何文件、连不上任何网络。

拒绝是带内的,不是 trap。 所有拒绝和失败都以 error: 前缀的字符串返回给 guest,而不是让它崩溃。这样插件可以把"没权限"当成普通失败处理,也能把原因转达给模型。

能力是两层 opt-in。 网络尤其如此:allow_network 只是第一道门,第二道是 host 白名单,空白名单拒绝一切。所以"打开网络开关"本身访问不到任何东西。

输出不算访问。 有两个 import 没有门——host-log 和 host-notify——因为它们碰不到任何东西、读不到任何东西、也不留下任何东西。它们唯一能做的坏事是淹没你的注意力,所以约束它们的是速率上限,不是授权。凡是能碰、能读、能持久化、能花钱的,一律有门。

你要过却没拿到的授权会大声说出来。 [[extensions]] 里拼错的键是配置加载错误;allow_tools 里不是内置工具的名字是插件加载错误。要排除的失败模式正是"静默地什么都没授权"——见 Configuration-zh。

v0.12 的完整面貌

Import 门 默认
host-log — 始终可用
host-notify — 始终可用,有速率上限
host-fs-read allow_fs 关
host-http-get allow_network + url_allowlist 关,且空列表拒绝一切
host-store-get / host-store-set allow_store 关
host-set-context allow_context 关
host-call-tool allow_tools,逐个工具 [] — 拒绝所有工具
host-send-user-message allow_send_message 关

事件订阅是另一套独立的授权,有自己的页面:插件事件订阅。


allow_fs — host-fs-read

[[extensions]]
path = "/abs/path/plugin.component.wasm"
allow_fs = true
默认 false
作用范围 只读;没有写入能力
路径限制 必须落在会话工作目录内

路径先 canonicalize 再检查,所以 ../ 和指向外部的符号链接都会被拒。这一点很关键:直接对未 canonicalize 的路径做 starts_with 前缀比较是不够的——<cwd>/../../etc/passwd 字面上确实以 cwd 开头。


allow_network + url_allowlist — host-http-get

[[extensions]]
path = "/abs/path/plugin.component.wasm"
allow_network = true
url_allowlist = ["api.github.com", "127.0.0.1"]

判定顺序

顺序是刻意的,为了给出有用的错误:

  1. allow_network 为 false → error: network access denied (set allow_network = true ...),不发任何请求
  2. host 不在 url_allowlist → error: url_allowlist does not permit <url> ...,不发任何请求
  3. 以上都过了才真正发请求

先查白名单会在能力本身就是关着的时候,告诉插件作者"你白名单写错了"——而真正的问题是开关没开。

空白名单拒绝一切

allow_network = true
url_allowlist = []        # ← 什么都访问不到

这是文档承诺的契约,不是疏漏。allow_network 单独打开等于没打开。

匹配规则:比对 host,不是子串

这是最容易写错的地方。不能用 url.contains(entry)——URL 是插件提供的,也就是完全受攻击者控制的。以下三个 URL 都能骗过 contains("api.github.com"):

URL 实际会访问 判定
https://evil.com/?x=api.github.com evil.com 拒绝(在 query 里)
https://api.github.com@evil.com/ evil.com 拒绝(userinfo 段)
https://api.github.com.evil.com/ 攻击者的子域 拒绝(entry 是前缀不是后缀)

实际的解析步骤:

  1. 空白名单 → 直接拒绝
  2. scheme 必须是 http:// 或 https://(大小写不敏感)。没有 scheme 也拒绝。这一条挡的是 file:// ——否则网络能力就变成了文件读取,绕过独立的 allow_fs 门
  3. authority = scheme 之后到第一个 /、? 或 # 为止
  4. host = authority 剥掉 userinfo(到最后一个 @ 为止)和 :port 后缀。以 [ 开头的是 IPv6 字面量,host 取到匹配的 ],端口从 ] 之后找
  5. 两边都转 ASCII 小写后比较。匹配条件:host == entry 或 host.ends_with(".{entry}")

第 5 条里那个前导点是全部的诀窍:

白名单 entry host 结果
github.com github.com 允许(相等)
github.com api.github.com 允许(.github.com 后缀)
github.com evilgithub.com 拒绝(没有点边界)
github.com github.com.evil.com 拒绝(entry 是前缀)

端口不参与比较(host 里已经剥掉了),所以 127.0.0.1 这一条能匹配任意端口——这正是本地测试能在不知道临时端口的情况下写白名单的原因。

配置里的 entry 会用同样的方式归一化,所以本想写主机名却写成 https://api.github.com/ 的人也能得到预期行为。

三种写法,也只有三种

写法 覆盖范围
example.com 该 host 及其子域,任意端口
*.example.com 只覆盖子域
* 任意 http/https host

* 是给那些确实事先无法知道要访问哪些 host 的插件用的,它等于把第二道门关掉——allow_network 成了唯一的检查,包括 link-local 元数据端点在内。nanopi 启动时看到它会告警,/tools 也会把这个授权渲染成 allow_network(*),免得这一行看起来像个收窄了的白名单。

星号出现在别的位置(api.*.com)是拒绝,而不是放宽。半懂不懂的通配符正是白名单悄悄退化成"没有白名单"的途径。

不跟随重定向

redirect::Policy::none()。一个 3xx 会变成 error: HTTP 30x,而不是跟过去。

原因:白名单只在请求前检查一次,比对的是插件给的那个 URL;没有任何逻辑会去复查 Location 头。所以只要跟随重定向,一个白名单内的 host 就能用 302 把这次抓取带到任何地方去——云元数据端点、RFC1918 内网、loopback。不跟随本身就是那道控制。

这条由 http_get_does_not_follow_redirect_off_the_allowlist 固定。该测试做过变异验证:把策略换成 Policy::limited(10) 会让它在 0.72 秒内失败。

其他限制

限制 值 原因
超时 10 秒 插件不能把一轮对话挂死
响应体上限 1 MiB 对齐 guest 侧 arena 尺寸
scheme 仅 http / https 挡 file:// 绕过 allow_fs
非 2xx error: HTTP <status> —

受限网络下的一点提醒

host-http-get 走的是 host 进程自己的 HTTP 客户端,也就是 host 进程的 http_proxy / https_proxy 环境变量对它同样生效——代理不需要、也不能写进 url_allowlist。白名单比对的是插件请求的那个 URL 里的 host,不是代理的地址。

所以在 GFW 环境里要访问被墙的站点,正确做法是:

  • 启动 nanopi 前把 http_proxy / https_proxy 设好,
  • url_allowlist 里照常写目标 host(api.github.com、registry.npmjs.org),

而不是把代理的地址加进白名单。反过来说,如果 nanopi 是在没有代理的环境里跑的,一个白名单写得完全正确的插件也会以 10 秒超时或者连接错误告终——这时先怀疑网络,不要怀疑白名单。

如果插件访问的是国内镜像(mirrors.tuna.tsinghua.edu.cn、registry.npmmirror.com、goproxy.cn),那就把镜像的 host 写进白名单,别写上游的——白名单比对的是实际请求的 host,写了上游的名字并不会让它匹配镜像。


allow_store — host-store-get / host-store-set

[[extensions]]
path = "/abs/path/memory.wasm"
allow_store = true
import host-store-get: func(key: string) -> string
import host-store-set: func(key: string, value: string) -> string

get 返回存的值,key 不存在则返回 ""。set 成功返回 ""。两者都可能返回 error: 字符串。

为什么这件事需要一道门。 存在实例内存里的状态,随实例一起消失。能跨重启存活的状态是另一种能力:它和 allow_network 合在一起就是一份可持久积累的用户画像,所以 nanopi 在同一个插件上看到这两个授权时会在启动时告警。

是 key,不是路径

插件从来不指定文件系统位置。它给一个 map key,由宿主决定这条记录落在哪个文件里——每个插件一个 JSON 对象,位于 ~/.nanopi/extensions/<stem>/store.json,<stem> 是 .wasm 文件的主干名。

所以 host-fs-read 必须去防的那一整类问题——../、指向外部的符号链接、非普通文件——在这里根本不存在。没有可以穿越的东西。

一个插件读不到另一个插件的 key,因为目录是按 stem 分的。而两个 .wasm 文件 stem 相同、且其中任一开了 allow_store,是两个都硬加载失败——不是"后来的覆盖前面的"。如果是后来者胜,哪个插件拥有这个 store 就取决于配置里的先后顺序,那么调整一下配置顺序就会把一个插件的记忆悄悄交给另一个。错误信息会告诉你去重命名一个文件。两个 stem 相同但都没开这个授权的插件,谁都不碰 store,正常加载。

set 返回 "" 意味着字节已经落盘

不是落在缓冲区。宿主先写一个临时文件,然后在同一目录里 rename 覆盖 store.json,rename 完成才返回。同目录 rename 是原子的,所以 set 中途崩溃留下的要么是完整的旧文件、要么是完整的新文件,不会是撕裂的。

没有 fsync:这能扛住进程崩溃,扛不住断电。 你依赖的是哪一种,值得心里有数。

值同样能活过 guest 的 trap——store 之所以放在宿主侧,正是因为 trap 会重置实例并带走 guest 内存。

上限,全部在写入任何东西之前检查

上限 值 被拒时
序列化后的整个 store 1 MiB error: store quota exceeded (1 MiB)
key 数量 1000 error: store key limit exceeded (1000 keys)
key 长度 128 字符(含——128 是合法的) error: store key too long (<n> chars, limit 128)

被拒时什么都不写。 插件绝不会被告知失败而值其实进去了。另外,替换一个已存在的 key 不计入 key 数量上限,所以卡在 1000 个 key 的插件仍然能更新自己已有的内容。

唯一的歧义,明说而不是糊过去

不存在的 key 读出来是 ""。如果你存的是 JSON,这没有歧义——"" 不是合法 JSON。但一个存了字面上以 error: 开头的字符串的插件,将无法把那次读取和一次拒绝区分开。这是这套 ABI 的带内错误约定带来的。存 JSON。


allow_context — host-set-context

[[extensions]]
path = "/abs/path/memory.wasm"
allow_context = true
import host-set-context: func(text: string) -> string

这是唯一一个改变 agent 所"相信"的东西的 import,而不是改变插件所知道的东西。这段文本会被模型当作指令读。这就是它需要一道门的原因,也是它和 allow_network = true 组合起来会拿到三条组合告警里最尖锐的那一条的原因:一个由插件控制的远端来源,从此可以塑造 agent 的行为,而完全不必碰任何工具。

它是替换,永远不追加

每次调用都替换这个插件上一次的贡献。它是状态,不是日志。传 "" 就整段清掉。

这正是它在一次投递被丢掉的情况下依然安全的原因:这个值是幂等的,所以一次宿主没做成的调用只是意味着上一次的贡献多留一轮。追加语义则会无界增长,而且会让一次迟到的投递变得可观测。

它在下一轮组装时生效

宿主每轮一次地把各插件的贡献折进 system prompt,在请求构造之前。一轮已经在飞的时候发生的调用,落在下一轮。不存在"部分生效"的贡献——还是上面那个幂等状态的框架。

每插件 4 KiB,按字节算

小是故意的:这段文本会进入本会话此后的每一个请求,所以一段无界的贡献会悄悄地把每一轮的成本都乘上去。每个插件有自己独立的 4 KiB。

按字节而不是按字符算,因为请求付费付的是字节。这一点对中文尤其要注意:4096 个汉字按 UTF-8 是约 12 KiB,远超上限——4 KiB 大约只装得下 1300 多个汉字。超限的调用返回

error: context contribution exceeds 4 KiB (<n> bytes, limit 4096) — the previous contribution is unchanged

而且上一次的贡献仍然有效。 在替换语义下,一次拒绝如果还顺手把已有的值清了,就等于把"被拒绝"变成了"丢掉了原本拥有的东西"。

署名由宿主加,而且每次变更都会公告

文本渲染在一个由宿主根据插件 .wasm 文件 stem 构造的标头之下:

[context contributed by extension "memory"]

正文里出现一行长得像标头的内容,无法开出一个署名给别的插件的块——宿主会把这个序列中和掉。和 host-notify 不同,这里多行文本是正常情况,会原样保留。

而且贡献的每一次变更都会出现在你的 scrollback 里。贡献这种东西天生看不见——你从来不看 system prompt。一个插件悄悄重写 agent 的指令,正是 nanopi 拒绝允许的事。公告是按变更发的——重复声明同样的文本什么也不说——并且走宿主自己的额度,所以插件没法靠耗尽自己的 notify 配额把它埋掉。

和 store 一样,一段贡献能活过 guest 的 trap:它是宿主侧状态。


allow_tools — host-call-tool

[[extensions]]
path = "/abs/path/indexer.wasm"
allow_tools = ["find", "read"]     # 可以遍历和读;不能写、不能执行
import host-call-tool: func(name: string, args-json: string) -> string

name 是内置工具名,args-json 是那个工具平常的 JSON 参数对象。

逐个工具,不是逐个插件——这正是重点

allow_tools = ["bash"] 就是任意命令执行。它直接绕过 allow_fs 的 cwd 限制,也绕过 url_allowlist 的逐 host 审批,这让这个插件上其他所有授权都变成装饰。一个 allow_tools = true 布尔值无法把这种情况和"一个只想遍历目录树、读文件的索引器"区分开。

所以这个授权是一个列表,方向和 url_allowlist、events 一致:

  • 不写或写 [] 拒绝一切。 错误信息会点名这个授权和该在哪里设它。
  • 列表里有 bash 会单独触发一条升级过的启动告警——它不需要搭档就已经危险。只读的 ["find", "read"] 不告警,否则告警就成了你学会跳过去的噪音。
  • 不是内置工具的名字是该插件的加载错误,并列出所有内置工具名。拼错的授权是被拒绝,而不是静默地什么都不授。

内置工具就是这七个:bash、edit、find、grep、ls、read、write。

只能调内置工具

目标是另一个扩展提供的工具会被拒绝,并点名那个扩展。两个互相被授予对方工具的插件会死锁——工具这条路径是故意用阻塞锁的,因为调用方要的就是结果——所以这个环是在构造上被去掉的,而不是运行时去检测。

返回形态

情形 返回
工具跑了 {"content":"…","is_error":false}
工具跑了但失败 {"content":"tool error: …","is_error":true}
被你的某个 hook 挡了 error: blocked by hook: <reason>
args-json 不是合法 JSON error: args-json is not valid JSON: …
没有可派发的目标 error: tool calls are not available right now
不在 allow_tools 里 error: tool "bash" is not in this plugin's allow_tools (empty, so every tool is denied) — add it to allow_tools on this plugin's [[extensions]] entry

跑过了的调用用 JSON 帧,没跑的用裸 error: 约定——因为一次拒绝不是一个工具结果。图片附件会被丢掉:这里没有承载它们的通道。

你的 hook 仍然大于插件

tool_execution_start / tool_execution_end hook 在这些调用上照样触发,和模型发起的调用完全一样,包括参数改写。被 hook 挡下会返回 error: blocked by hook: <reason>,工具不执行。插件不能大过用户的策略。 见 Hooks-zh。

30 秒上限

超过就是 error: tool call exceeded the 30s plugin deadline。注意工具启动的子进程可能活得比它久。这个上限高于 host-http-get 的 10 秒,因为一次正当的 find 需要更多时间;又远低于模型路径上那个实际上无界的等待,因为插件的这次调用是在屏幕上什么都不显示的情况下跑的。

它不是对话的一部分

这次调用不写任何东西进会话 transcript,也不发出 agent 事件——transcript 是和模型的那场对话。你是通过宿主的一行披露知道它发生过的。


allow_send_message — host-send-user-message

[[extensions]]
path = "/abs/path/autofix.wasm"
events = ["tool_execution_end"]
allow_send_message = true
import host-send-user-message: func(text: string) -> string

成功返回 "",否则 error: <reason>。永不 trap。

这是唯一一个花你钱的能力。 每次调用都会导致一轮向服务商计费的对话。nanopi 在启动时对这个授权单独告警,不需要搭档——和 allow_context 不同,它自己就足够尖锐。

没有安静的路径

文本一定会原样回显给你,署上插件的名字,并且走宿主自己的 notify 额度而不是插件的。插件永远不可能以为自己发了一句你没看见的话。

路由

如果有一轮正在跑,这段文本引导它——在下一个迭代边界作为一条新的用户消息到达,走的是和你在流式输出中途敲进去的一行完全相同的路径。如果没有一轮在跑,或者刚跑的那一轮正好结束了,这段文本开启下一轮。它永远不会被丢掉。

而且它永远不会插到你已经排好队的输入前面:人的待发消息先被读取,插件的在后。

循环护栏,三条规则,全部带内拒绝

error: this plugin cannot send during a turn its own message started
error: a message from this plugin is already pending
error: this plugin has started too many turns in this session (20)

按这个顺序检查,因为"你正在你自己开启的那一轮里面"是更具体的诊断;当真正的问题是在打转时被告知"你已经有一条待发消息",会把作者引向错误的方向。

  • 不能自己回自己。 是按插件算,不是全局:另一个插件在那一轮里仍然可以说话,这正是"审计插件 + 规则插件"这种搭配还能工作的原因。

  • 每个插件只能有一条未被消费的消息。 它是在文本真正进入一轮时才清掉的,不是调用返回时。返回时就清会让这条规则变成装饰。

  • 每会话上限 20 轮。 第三条规则存在,是因为前两条并不能拦住那个最显然的循环:A 发消息 → 一轮跑起来 → A 的 turn_end 订阅者又发 → 无穷。规则 2 每次都满足,因为每一轮新的来源都是一个新的 turn;规则 1 也满足,因为每条消息在下一条提交之前都已被消费。这一对读起来像循环护栏,其实不是。

    20 这个数是对着它要保护的东西定的——你的钱。一个有正当理由去驱动 agent 的插件,一个会话里做个把次;跑到第五十轮的那个是在打转。是按会话而不是按小时,因为一个墙钟时间窗口会让一个有耐心的循环永远跑下去。也是按插件算,这样一个打转的插件不能花掉另一个插件的额度。

headless 模式下不可用

nanopi -p 没有可以送达的对话循环,所以每次调用都返回

error: sending a message is not available right now

拒绝,绝不静默丢弃。插件的工具在那里仍然能用。


无门:host-notify 和 host-log

两者都不需要授权,因为输出不算访问。

host-notify — 在你的 scrollback 里写一行

import host-notify: func(text: string) -> string

整行完整送达时返回 ""。

约束它的是速率上限而不是授权:每插件每轮 10 行。 超出的行被丢弃,并有一行

… <N> more suppressed

报告丢了多少。截断会自己说出来,被压制的那些调用也会返回一个 error: 字符串,所以插件绝不会在没说出话的时候被告知它说了:

error: notify suppressed — this plugin is over its limit of 10 lines per turn

前缀由宿主加,来自插件 .wasm 文件的 stem,并且从不去解析 payload 里有没有前缀。所以插件无法冒充另一个插件:名为 memory 的插件发出文本 [other] rm -rf /,渲染出来是

[memory] [other] rm -rf /

payload 里的换行会被压成空格,理由相同——前缀只覆盖第一行,否则第二行就能装成另一个插件的通告。一次调用就是一行。

payload 上限 64 KiB(和一个命令 action 的 payload 同一个上限,按字符计)。超过时这一行仍然被截断后送达,返回值会这么说而不是返回 "":

error: notify text was truncated to 65536 chars — the line was delivered but not in full

host-notify 和 host-log 的区别,这个区别很要紧

去哪里 能不能活过一次重绘
host-log 裸 stderr,落在 ratatui 管理的区域内 不能——下一帧就被擦掉
host-notify scrollback,走 insert_before 能

host-log 用来调试没问题,用来告诉用户什么事情则毫无用处。想让人看见,用 host-notify。


没有做的事

没有 SSRF 专门防护——不拦 loopback、RFC1918、169.254.169.254。

这是明确接受的风险,理由是白名单本身就是那道控制,且默认拒绝:要访问到云元数据端点,用户得自己把它写进 url_allowlist。如果你的部署环境里这个假设不成立(比如白名单由不可信来源生成),需要在配置层面额外把关。


插件侧怎么处理拒绝

透传,不要吞:

let body = unsafe { host_http_get(url) };
if body.starts_with("error: ") {
    return err(body);   // 让模型看到是权限问题
}

宿主的错误消息会点名具体该改哪个配置项(allow_network 还是 url_allowlist、allow_store、allow_context、allow_tools、allow_send_message),所以原样透传对使用者最有用。

注意这里有一个值得正确处理的约定分叉:拒绝一定是裸 error: 字符串,而一次真的跑起来了的 host-call-tool 返回的是 JSON 对象——包括工具自己失败的情况。所以 starts_with("error: ") 区分的是"没跑"和"跑了",JSON 里的 is_error 区分的是"跑了但失败"和"跑了且成功"。是三态,不是两态。


/tools 会告诉你什么

v0.12 给 /tools 加了每插件一行的授权行,放在 Plugin grants 下:

Plugin grants (2 plugins)
  memory              allow_store, allow_context, events(input, message_end)
  formatter           no grants

授权按固定顺序列出(文件系统、网络、store、context、工具、发消息、事件),这样两行可以一眼对比;关着的授权不贡献任何内容——一个在找 bash 的运维不该为了找到它而先读过每个插件四个 no。

有两个细节是承重的:

  • 什么都没被授予的插件仍然有一行,写着 no grants。 去掉这一行会让"没装"和"装了但什么都干不了"看起来一样,而"这个插件什么都干不了"恰恰是有人来 /tools 想得到的答案。
  • token 自带自己的作用范围,因为一个裸标志会说谎。allow_network 配空列表渲染成 allow_network(no allowlist, reaches nothing);配星号渲染成 allow_network(*)。这两者对一个在扫风险的人不能长得一样。同理 allow_send_message 会把自己拼出来,成为 allow_send_message(can start billed turns),allow_tools 会点名具体哪些工具。

这些行是在加载时烘好的,不是在 TUI 里推导的,所以一行描述的是正在运行的那个插件持有什么,而不是磁盘上当前的配置。只有加载成功的插件才有行——一个加载失败的插件的行会声称一份没人持有的授权。

/tools 还有一个 Watching events 段落显示订阅;见 插件事件订阅。


怎么自己验证这些

不需要 LLM,见 Debugging-Plugins-zh:

# 门关着
PLUGIN=... TOOL=fetch_head ARGS='{"url":"http://127.0.0.1:8899/x"}' \
  cargo test --features wasm --test manual_plugin_probe -- --ignored --nocapture call_tool
# → error: network access denied (set allow_network = true ...)

# 门开但白名单空
... ALLOW_NET=1 ...
# → error: url_allowlist does not permit ... (an empty allowlist denies everything)

# 门开且 host 在白名单
... ALLOW_NET=1 ALLOWLIST=127.0.0.1 ...
# → 正常返回响应体

用 loopback(127.0.0.1)做这类验证还有一个好处:它不经过代理,也不受受限网络影响,所以测出来的失败一定是门的问题,不是网络的问题。


相关

Clone this wiki locally