Repository navigation
Plugin Capabilities zh
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。
| 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 |
关 |
事件订阅是另一套独立的授权,有自己的页面:插件事件订阅。
[[extensions]]
path = "/abs/path/plugin.component.wasm"
allow_fs = true| 默认 | false |
| 作用范围 | 只读;没有写入能力 |
| 路径限制 | 必须落在会话工作目录内 |
路径先 canonicalize 再检查,所以 ../ 和指向外部的符号链接都会被拒。这一点很关键:直接对未 canonicalize 的路径做 starts_with 前缀比较是不够的——<cwd>/../../etc/passwd 字面上确实以 cwd 开头。
[[extensions]]
path = "/abs/path/plugin.component.wasm"
allow_network = true
url_allowlist = ["api.github.com", "127.0.0.1"]顺序是刻意的,为了给出有用的错误:
-
allow_network为 false →error: network access denied (set allow_network = true ...),不发任何请求 -
host 不在
url_allowlist→error: url_allowlist does not permit <url> ...,不发任何请求 - 以上都过了才真正发请求
先查白名单会在能力本身就是关着的时候,告诉插件作者"你白名单写错了"——而真正的问题是开关没开。
allow_network = true
url_allowlist = [] # ← 什么都访问不到这是文档承诺的契约,不是疏漏。allow_network 单独打开等于没打开。
这是最容易写错的地方。不能用 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 是前缀不是后缀) |
实际的解析步骤:
- 空白名单 → 直接拒绝
- scheme 必须是
http://或https://(大小写不敏感)。没有 scheme 也拒绝。这一条挡的是file://——否则网络能力就变成了文件读取,绕过独立的allow_fs门 -
authority = scheme 之后到第一个
/、?或#为止 -
host = authority 剥掉 userinfo(到最后一个
@为止)和:port后缀。以[开头的是 IPv6 字面量,host 取到匹配的],端口从]之后找 - 两边都转 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,写了上游的名字并不会让它匹配镜像。
[[extensions]]
path = "/abs/path/memory.wasm"
allow_store = trueimport host-store-get: func(key: string) -> string
import host-store-set: func(key: string, value: string) -> stringget 返回存的值,key 不存在则返回 ""。set 成功返回 ""。两者都可能返回 error: 字符串。
为什么这件事需要一道门。 存在实例内存里的状态,随实例一起消失。能跨重启存活的状态是另一种能力:它和 allow_network 合在一起就是一份可持久积累的用户画像,所以 nanopi 在同一个插件上看到这两个授权时会在启动时告警。
插件从来不指定文件系统位置。它给一个 map key,由宿主决定这条记录落在哪个文件里——每个插件一个 JSON 对象,位于 ~/.nanopi/extensions/<stem>/store.json,<stem> 是 .wasm 文件的主干名。
所以 host-fs-read 必须去防的那一整类问题——../、指向外部的符号链接、非普通文件——在这里根本不存在。没有可以穿越的东西。
一个插件读不到另一个插件的 key,因为目录是按 stem 分的。而两个 .wasm 文件 stem 相同、且其中任一开了 allow_store,是两个都硬加载失败——不是"后来的覆盖前面的"。如果是后来者胜,哪个插件拥有这个 store 就取决于配置里的先后顺序,那么调整一下配置顺序就会把一个插件的记忆悄悄交给另一个。错误信息会告诉你去重命名一个文件。两个 stem 相同但都没开这个授权的插件,谁都不碰 store,正常加载。
不是落在缓冲区。宿主先写一个临时文件,然后在同一目录里 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。
[[extensions]]
path = "/abs/path/memory.wasm"
allow_context = trueimport host-set-context: func(text: string) -> string这是唯一一个改变 agent 所"相信"的东西的 import,而不是改变插件所知道的东西。这段文本会被模型当作指令读。这就是它需要一道门的原因,也是它和 allow_network = true 组合起来会拿到三条组合告警里最尖锐的那一条的原因:一个由插件控制的远端来源,从此可以塑造 agent 的行为,而完全不必碰任何工具。
每次调用都替换这个插件上一次的贡献。它是状态,不是日志。传 "" 就整段清掉。
这正是它在一次投递被丢掉的情况下依然安全的原因:这个值是幂等的,所以一次宿主没做成的调用只是意味着上一次的贡献多留一轮。追加语义则会无界增长,而且会让一次迟到的投递变得可观测。
宿主每轮一次地把各插件的贡献折进 system prompt,在请求构造之前。一轮已经在飞的时候发生的调用,落在下一轮。不存在"部分生效"的贡献——还是上面那个幂等状态的框架。
小是故意的:这段文本会进入本会话此后的每一个请求,所以一段无界的贡献会悄悄地把每一轮的成本都乘上去。每个插件有自己独立的 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:它是宿主侧状态。
[[extensions]]
path = "/abs/path/indexer.wasm"
allow_tools = ["find", "read"] # 可以遍历和读;不能写、不能执行import host-call-tool: func(name: string, args-json: string) -> stringname 是内置工具名,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: 约定——因为一次拒绝不是一个工具结果。图片附件会被丢掉:这里没有承载它们的通道。
tool_execution_start / tool_execution_end hook 在这些调用上照样触发,和模型发起的调用完全一样,包括参数改写。被 hook 挡下会返回 error: blocked by hook: <reason>,工具不执行。插件不能大过用户的策略。 见 Hooks-zh。
超过就是 error: tool call exceeded the 30s plugin deadline。注意工具启动的子进程可能活得比它久。这个上限高于 host-http-get 的 10 秒,因为一次正当的 find 需要更多时间;又远低于模型路径上那个实际上无界的等待,因为插件的这次调用是在屏幕上什么都不显示的情况下跑的。
这次调用不写任何东西进会话 transcript,也不发出 agent 事件——transcript 是和模型的那场对话。你是通过宿主的一行披露知道它发生过的。
[[extensions]]
path = "/abs/path/autofix.wasm"
events = ["tool_execution_end"]
allow_send_message = trueimport 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 的插件,一个会话里做个把次;跑到第五十轮的那个是在打转。是按会话而不是按小时,因为一个墙钟时间窗口会让一个有耐心的循环永远跑下去。也是按插件算,这样一个打转的插件不能花掉另一个插件的额度。
nanopi -p 没有可以送达的对话循环,所以每次调用都返回
error: sending a message is not available right now
拒绝,绝不静默丢弃。插件的工具在那里仍然能用。
两者都不需要授权,因为输出不算访问。
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-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 区分的是"跑了但失败"和"跑了且成功"。是三态,不是两态。
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)做这类验证还有一个好处:它不经过代理,也不受受限网络影响,所以测出来的失败一定是门的问题,不是网络的问题。
- 每个授权真实的字段名和默认值 → Configuration-zh
- 在授权下观察生命周期事件 → 插件事件订阅
- 写这些 import 的 guest 侧 → Plugin-Development-zh
- 不用 LLM 驱动插件 → Debugging-Plugins-zh
English
- Home
- Features
- Configuration
- Hooks
- Plugin Development
- Example Plugins
- Plugin Capabilities
- Plugin Events
- Debugging Plugins
中文
v0.12.0 · source of truth:
wit/nanopi-extension.wit