Skip to content

Plugin Capabilities

Dev edited this page Sep 7, 2026 · 2 revisions

English · 中文

Plugin capability gates

Tracks v0.12.0 (3a762b1). Implemented in src/wasm/loader.rs, with the gate bodies split into src/wasm/store.rs, src/wasm/notify.rs, src/plugin_context.rs, src/plugin_tools.rs and src/plugin_send.rs; the semantics are pinned by tests/wasm_plugin_integration.rs and the unit tests in each of those modules. Re-verify with: cargo test --features wasm -- --test-threads=1


Principles

Everything is off by default. Install a plugin and, without explicitly opening a gate, it can read no files and reach no network.

Refusals are in-band, not traps. Every refusal and failure comes back to the guest as a string prefixed error: rather than killing it. A plugin can treat "no permission" as an ordinary failure and relay the reason to the model.

Capabilities are opt-in twice over. Network especially: allow_network is only the first gate; the second is a host allowlist, and an empty allowlist denies everything. Turning the switch on by itself reaches nothing.

Output is not access. Two imports are ungated — host-log and host-notify — because they reach nothing, read nothing, and leave nothing behind. What they can do is flood your attention, so they are bounded by a rate limit rather than a grant. Everything that reaches, reads, persists, or spends is gated.

A grant you asked for and did not get is loud. A misspelled key in [[extensions]] is a config load error, and a name in allow_tools that is not a built-in is a plugin load error. Silently granting nothing is the failure mode this rules out — see Configuration.

The whole surface, v0.12

Import Gate Default
host-log — always available
host-notify — always available, rate-limited
host-fs-read allow_fs off
host-http-get allow_network + url_allowlist off, and empty list denies
host-store-get / host-store-set allow_store off
host-set-context allow_context off
host-call-tool allow_tools, per tool [] — denies every tool
host-send-user-message allow_send_message off

Event subscriptions are a separate grant with its own page: Plugin-Events.


allow_fs — host-fs-read

[[extensions]]
path = "/abs/path/plugin.component.wasm"
allow_fs = true
Default false
Access read-only; there is no write capability
Scope must resolve inside the session's working directory

Paths are canonicalized before the check, so ../ sequences and symlinks pointing outward are both refused. This matters: a plain starts_with prefix comparison on an uncanonicalized path is not enough — <cwd>/../../etc/passwd is literally prefixed by 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"]

Gate order

The order is deliberate, so the error is useful:

  1. allow_network is false → error: network access denied (set allow_network = true ...), no request is made
  2. host not in url_allowlist → error: url_allowlist does not permit <url> ..., no request is made
  3. only then is the request actually sent

Checking the allowlist first would tell a plugin author their allowlist is wrong when the real problem is that the capability is off entirely.

An empty allowlist denies everything

allow_network = true
url_allowlist = []        # reaches nothing

This is the documented contract, not an oversight. allow_network on its own is equivalent to off.

Matching is on the host, never a substring

This is the easiest thing to get wrong. Do not use url.contains(entry) — the URL is plugin-supplied and therefore fully attacker-controlled. All three of these pass a contains("api.github.com") check while pointing elsewhere:

URL Actually reaches Verdict
https://evil.com/?x=api.github.com evil.com refused (it's in the query)
https://api.github.com@evil.com/ evil.com refused (userinfo segment)
https://api.github.com.evil.com/ attacker's subdomain refused (entry is a prefix, not a suffix)

The actual parse:

  1. Empty allowlist → refuse immediately
  2. Scheme must be http:// or https:// (case-insensitive). No scheme is also refused. This is what blocks file:// — otherwise the network capability becomes a file read, sidestepping the separate allow_fs gate
  3. Authority = everything after the scheme up to the first /, ?, or #
  4. Host = the authority with userinfo stripped (up to and including the last @) and the :port suffix removed. An authority starting with [ is an IPv6 literal, so the host runs to the matching ] and the port strip looks after the bracket rather than from the left
  5. Both sides lowercased as ASCII, then compared. Match when host == entry or host.ends_with(".{entry}")

That leading dot in rule 5 is the whole trick:

Allowlist entry Host Result
github.com github.com allowed (equal)
github.com api.github.com allowed (.github.com suffix)
github.com evilgithub.com refused (no dot boundary)
github.com github.com.evil.com refused (entry is a prefix)

Ports don't participate (already stripped from the host), so a bare 127.0.0.1 entry matches any port — which is exactly what lets a local test allowlist loopback without knowing the ephemeral port up front.

Config entries are normalized the same way a host is, so someone who writes https://api.github.com/ where a hostname was wanted still gets what they meant.

Three spellings, and only three

Entry Covers
example.com the host and its subdomains, any port
*.example.com the subdomains only
* any http/https host

* is for plugins whose host set genuinely is not knowable up front, and it turns the second gate off — allow_network becomes the only check, link-local metadata endpoints included. nanopi warns at startup when it sees it, and /tools renders the grant as allow_network(*) so the row does not read like a narrow one.

A star anywhere else (api.*.com) is refused rather than widened. Half-understood wildcards are how allowlists quietly become no allowlist at all.

Redirects are not followed

redirect::Policy::none(). A 3xx comes back as error: HTTP 30x rather than being followed.

Why: the allowlist is checked once, before the request, against the URL the plugin supplied. Nothing re-checks the Location header. So if redirects were followed, any allowlisted host could 302 the fetch anywhere it liked — cloud metadata endpoints, RFC1918, loopback. Not following the hop IS the control.

This is pinned by http_get_does_not_follow_redirect_off_the_allowlist, which was mutation-checked: swapping the policy for Policy::limited(10) fails it in 0.72s.

Other limits

Limit Value Why
Timeout 10s a plugin must not hang a turn
Body cap 1 MiB sized to the guest-side arena
Schemes http / https only blocks file:// bypassing allow_fs
Non-2xx error: HTTP <status> —

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 returns the stored value, or "" for an absent key. set returns "" on success. Either can return an error: string.

Why this is gated at all. State in the instance's memory dies with the instance. State that survives a restart is a different capability: combined with allow_network it is a durable profile of the user, which is why nanopi warns at startup when it sees both grants on one plugin.

Keys, not paths

The plugin never names a filesystem location. It supplies a map key; the host decides which file that lands in — one JSON object per plugin at ~/.nanopi/extensions/<stem>/store.json, where <stem> is the .wasm file stem.

So the entire class of problem host-fs-read has to defend against — ../, symlinks pointing outward, non-regular files — does not exist here. There is nothing to traverse.

One plugin cannot read another's keys, because the directory is per stem. And two plugins whose .wasm files share a stem, where either sets allow_store, is a hard load error for both — not "the later one loses". With last-one-wins, which plugin owns the store would depend on config order, so reordering your config would silently hand one plugin's memory to another. The error tells you to rename a file. Two stem-sharing plugins that both have the grant off touch no store and load normally.

set returning "" means the bytes reached the disk

Not a buffer. The host writes a temp file and renames it over store.json in the same directory, and returns only after the rename. A same-directory rename is atomic, so a crash mid-set leaves either the whole old file or the whole new one, never a torn one.

There is no fsync: this survives a process crash, not a power cut. Worth knowing which one you are relying on.

A value also survives a guest trap — the store is host-side precisely because a trap resets the instance and takes guest memory with it.

Bounds, all checked before anything is written

Bound Value On refusal
Total serialized store 1 MiB error: store quota exceeded (1 MiB)
Number of keys 1000 refused, naming the cap
Key length 128 chars (inclusive — 128 is fine) refused, naming the cap

A refusal stores nothing. A plugin is never told it failed while the value went in anyway. And replacing a key that already exists does not count against the key cap, so a plugin sitting at 1000 keys can still update what it holds.

The one ambiguity, documented rather than papered over

An absent key reads as "", which is unambiguous if you store JSON — "" is not valid JSON. A plugin that stores a literal string beginning error: will not be able to tell that read from a refusal. That is inherited from this ABI's in-band error convention. Store JSON.


allow_context — host-set-context

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

This is the one import that changes what the agent believes, rather than what the plugin knows. The text is read by the model as instruction. That is why it is gated, and why combining it with allow_network = true gets the sharpest of the startup warnings: a remote source the plugin controls could then shape how the agent behaves, without ever touching a tool.

It REPLACES; it never appends

Each call replaces this plugin's previous contribution. It is state, not a log. Passing "" clears it entirely.

That is what makes it safe under a dropped delivery: the value is idempotent, so a call the host never made simply means the previous contribution stands for one more turn. An append would grow without bound and would make a late arrival observable.

It lands at the next turn's assembly

The host folds contributions into the system prompt once per turn, before the request is built. A call made while a turn is already in flight lands on the following one. There is no partially-applied contribution — same idempotent-state framing as above.

Bounded at 4 KiB per plugin, in bytes

Small on purpose: this text enters every request for the rest of the session, so an unbounded contribution silently multiplies the cost of every turn. Each plugin gets its own 4 KiB.

Measured in bytes rather than characters because bytes are what the request pays for. An over-bound call returns

error: context contribution exceeds 4 KiB (<n> bytes, limit 4096) ...

and the previous contribution still stands. Under replace-semantics, a refusal that also cleared the existing value would turn being told "no" into losing what you had.

The host applies the attribution, and announces every change

The text is rendered under a header the host builds from the plugin's .wasm file stem:

[context contributed by extension "memory"]

A body containing a header-shaped line cannot open a block attributed to another plugin — the host neutralizes the sequence. Unlike host-notify, multi-line text is normal here and is kept as-is.

And every change to the contribution is announced in your scrollback. A contribution is invisible by nature; you never see the system prompt. A plugin silently rewriting the agent's instructions is exactly what nanopi refuses to allow. The announcement is per change — re-declaring the same text says nothing — and it is emitted on the host's own budget, so a plugin cannot bury it by exhausting its own notify quota.

Like the store, a contribution survives a guest trap: it is host-side state.


allow_tools — host-call-tool

[[extensions]]
path = "/abs/path/indexer.wasm"
allow_tools = ["find", "read"]     # may walk and read; may not write or exec
import host-call-tool: func(name: string, args-json: string) -> string

name is a built-in tool name and args-json is that tool's ordinary JSON argument object.

Per tool, not per plugin — and that is the whole point

allow_tools = ["bash"] is arbitrary command execution. It walks straight past allow_fs's cwd confinement and past url_allowlist's per-host approval, which makes every other grant on that plugin decorative. A single allow_tools = true boolean could not distinguish that from an indexer that wants to walk the tree and read files.

So the grant is a list, in the same direction url_allowlist and events already take:

  • Absent or [] refuses everything. The error names the grant and where to set it.
  • bash in the list gets an escalated startup warning, on its own — it does not need a partner to be dangerous. A read-only ["find", "read"] does not warn, or the warning becomes noise you learn to skip past.
  • A name that is not a built-in is a load error for that plugin, listing the built-ins. A misspelled grant is refused rather than silently granting nothing.

The built-ins are exactly: bash, edit, find, grep, ls, read, write.

Built-in tools only

A target supplied by another extension is refused, naming the extension. Two plugins each granted the other's tool would deadlock — the tool path takes a blocking lock on purpose, because the caller wants the result — so the cycle is removed by construction rather than detected at runtime.

Return shapes

Situation Returned
The tool ran {"content":"…","is_error":false}
The tool ran and failed {"content":"tool error: …","is_error":true}
Blocked by one of your hooks error: blocked by hook: <reason>
args-json was not valid JSON error: args-json is not valid JSON: …
Nothing to dispatch to error: tool calls are not available right now
Not in allow_tools error: tool "bash" is not in this plugin's allow_tools (…) — add it to allow_tools on this plugin's [[extensions]] entry

The JSON frame wins for a call that ran; the bare error: convention wins for one that did not, because a refusal is not a tool result. Image attachments are dropped — there is no channel here for them.

Your hooks still outrank the plugin

tool_execution_start / tool_execution_end hooks fire on these calls exactly as they do on the model's, including transforms. A hook block comes back as error: blocked by hook: <reason> with the tool not run. A plugin does not outrank the user's policy. See Hooks.

Bounded at 30 seconds

Past that: error: tool call exceeded the 30s plugin deadline. Note that a process the tool spawned may outlive it. The deadline sits above host-http-get's 10s because a legitimate find needs more, and far below the model path's effectively-unbounded wait because a plugin's call runs with nothing on screen.

It is not part of the conversation

The call writes nothing to the session transcript and emits no agent event — the transcript is the conversation with the model. You are told about it by a one-line host disclosure instead.


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

"" on success, error: <reason> otherwise. Never traps.

This is the only capability that spends your money. Every call causes a billed turn against your provider. nanopi warns about this grant at startup, on its own, unpaired — unlike allow_context, it does not need a partner to be sharp.

There is no quiet path

The text is always echoed to you verbatim, attributed to the plugin, and disclosed on the host's own notify budget rather than the plugin's. A plugin can never believe it sent something you did not see.

Routing

If a turn is running, the text steers it — arriving as a fresh user message at the next iteration boundary, the same path a mid-stream typed line takes. If no turn is running, or the one that was running has just ended, the text starts the next turn. It is never dropped.

And it never outranks a line you have already queued: a human's pending message is read before a plugin's.

The loop guard, three rules, all refusals in band

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)

Checked in that order, because "you are inside your own turn" is the more specific diagnosis — being told you already have a message pending, when the real problem is that you are looping, sends an author looking in the wrong place.

  • No replying to yourself. Per plugin, not global: another plugin may still speak during that turn, which is what keeps an audit-plus-rules pair of plugins working.

  • One unconsumed message per plugin. It clears when the text actually reaches a turn, not when the call returns. Clearing it on return would make the rule decorative.

  • A per-session cap of 20. This third rule exists because the first two do not bound the obvious loop: A sends → a turn runs → A's turn_end subscriber sends again → forever. Rule 2 is satisfied every time, because each new turn's origin is a new turn; rule 1 is satisfied too, because each message is consumed before the next is submitted. The pair reads like a loop guard and is not one.

    Twenty is set against what the bound is protecting — your money. A plugin with a legitimate reason to drive the agent does it a handful of times per session; one on its fiftieth turn is looping. Per session rather than per hour, because a wall-clock window lets a patient loop run forever. And per plugin, so a shared budget cannot let one looping plugin spend another's.

Unavailable in headless mode

nanopi -p has no turn loop to reach, so every call returns

error: sending a message is not available right now

Refused, never silently discarded. A plugin's tools still work there.


Ungated: host-notify and host-log

Neither takes a grant, because output is not access.

host-notify — one line in your scrollback

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

Returns "" when the line was delivered whole.

Bounded by a rate limit instead of a grant: 10 lines per plugin per turn. Past that, lines are dropped and one

… <N> more suppressed

line reports how many. The truncation announces itself, and the suppressed calls also return an error: string, so a plugin is never told it spoke when it did not:

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

The host applies the prefix, built from the plugin's .wasm file stem, and the payload is never parsed for one. So a plugin cannot impersonate another: text of [other] rm -rf / from a plugin named memory renders as

[memory] [other] rm -rf /

Newlines in the payload are flattened to spaces for the same reason — the prefix covers the first line only, so a second line could otherwise pose as another plugin's notice. One call is one line.

Payload capped at 64 KiB (the same cap a command action's payload has). Over that the line is delivered truncated and the return value says so rather than "".

host-notify vs host-log, and the difference matters

Goes to Survives a redraw
host-log raw stderr, inside ratatui's managed region no — wiped by the next frame
host-notify scrollback, via insert_before yes

host-log is fine for debugging and useless for telling the user something. If you want a human to read it, use host-notify.


What is deliberately not done

There is no dedicated SSRF guard — nothing blocks loopback, RFC1918, or 169.254.169.254.

This is an accepted risk, on the grounds that the allowlist is the control and it is deny-by-default: reaching a cloud metadata endpoint requires the user to have written it into url_allowlist themselves. If that assumption doesn't hold in your deployment — say the allowlist is generated from an untrusted source — you need an additional check at the config layer.


Handling refusals in a plugin

Pass them through, don't swallow:

let body = unsafe { host_http_get(url) };
if body.starts_with("error: ") {
    return err(body);   // let the model see it's a permission problem
}

The host's messages name the specific config knob to change (allow_network vs url_allowlist, allow_store, allow_context, allow_tools, allow_send_message), so relaying them verbatim is the most useful thing for the user.

Note the split convention, which is worth handling correctly: a refusal is always a bare error: string, but a host-call-tool that actually ran returns a JSON object — including when the tool itself failed. So starts_with("error: ") distinguishes "did not run" from "ran", and is_error inside the JSON distinguishes "ran and failed" from "ran and worked". Three states, not two.


What /tools tells you

/tools grew a per-plugin grant row in v0.12, under Plugin grants:

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

Grants are listed in a fixed order (filesystem, network, store, context, tools, send-message, events) so two rows are comparable at a glance, and a grant that is off contributes nothing — an operator scanning for bash should not have to read past four nos per plugin to find it.

Two details are load-bearing:

  • A plugin with nothing granted still gets a row, reading no grants. Dropping it would make "not installed" and "installed, powerless" look identical, and "this plugin can do nothing" is exactly the answer someone came to /tools to get.
  • The tokens carry their own scope, because a bare flag would lie. allow_network with an empty list renders allow_network(no allowlist, reaches nothing); with a star it renders allow_network(*). Those must not look the same to someone scanning for risk. Likewise allow_send_message spells itself out as allow_send_message(can start billed turns), and allow_tools names the tools.

The rows are baked at load time, not derived in the TUI, so a row describes what the running plugin holds rather than what is currently on disk. Only successfully-loaded plugins get one — a row for a plugin that failed to load would claim a grant nothing holds.

/tools also has a Watching events section for subscriptions; see Plugin-Events.


Verifying this yourself

No LLM required — see Debugging-Plugins:

# gate closed
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 ...)

# gate open, allowlist empty
... ALLOW_NET=1 ...
# -> error: url_allowlist does not permit ... (an empty allowlist denies everything)

# gate open, host allowlisted
... ALLOW_NET=1 ALLOWLIST=127.0.0.1 ...
# -> the response body

Related

Clone this wiki locally