Write a plugin¶
This page walks through building a check plugin end-to-end: manifest, sandboxed runtime, structured
input handling, structured output, local testing, and packaging. The example is a license-check
plugin that inspects Cargo.toml on pre-land and blocks landing when a prohibited license is
introduced.
Runtime implemented
The tovio-core contracts (PluginManifest, Binding, PluginInput, PluginResult,
validate_result, decision(...)) are stable and pinned by conformance tests today. The WASM/WASI
execution runtime, the tovio plugin install/run/test surfaces, publisher-signed packages with a
repository-local trust store, and bounded HTTPS installation are implemented. There is no plugin
registry: a package is distributed as a directory or an HTTPS URL.
The contract you are implementing¶
Every plugin, in every runtime, follows the same two-step contract:
- Receive a
PluginInputJSON document on stdin (or via the sandbox's input channel). - Emit a
PluginResultJSON document on stdout before the deadline in the manifest.
Everything else — capabilities, redaction, audit — is enforced by TOVIO around your code.
The input and output shapes are pinned by tovio-core conformance tests. See
Manifest reference, Lifecycle events, and the
schema_version/api_version fields in both envelopes.
1. Scaffold the manifest¶
Create a package directory holding a manifest.toml next to your runtime artifact. The file name
matters: tovio plugin install, validate, and test look for manifest.toml inside a package
directory.
id = "com.example.license-check"
name = "Example License Check"
version = "1.2.0"
publisher = "Example Corp"
api_version = "v1"
type = "check"
runtime = "wasm-wasi"
events = ["pre-land", "proposal-created"]
timeout_ms = 5000
integrity = "local_dev"
[schemas]
input = "v1"
output = "v1"
[capabilities]
read_metadata = true
read_clear_paths = ["Cargo.toml", "Cargo.lock"]
read_protected_metadata = false
read_protected_plaintext = false
write_working_copy = false
write_plugin_cache = false
network_access = false
emit_audit = true
invoke_tovio_command = false
propose_resolution = false
propose_transform = false
block_operation = true
Notes:
- Only request what you need. Every capability field is denied by default. Asking for more than you need makes reviewers reject your binding.
integrity = "local_dev"marks this as a dev build. This is a bare top-level key and must appear before any[table]header. Placed after[capabilities], TOML reads it ascapabilities.integrityand validation fails withTVO-PLUGIN-001(missing field integrity). For a distributable package, replace it with a[integrity.signed]table (see step 6).[capabilities]is optional — omit it for the deny-by-default set — but when the table is present it has to list every field. The parser has no per-field defaults, so a partial table is refused withTVO-PLUGIN-001(missing field read_metadata, …).block_operation = truedeclares "I may block when bound in enforcing mode." Today the enforcing decision is made from the binding's mode and required flag plus your result status; the flag is recorded in the audited grant set rather than consulted by the gate, so declare it honestly.
Validate as you go:
$ tovio plugin validate ./license-check
✓ manifest for `com.example.license-check` v1.2.0 is valid (check, events: pre-land, proposal-created)
tovio plugin validate takes a package directory or a bare manifest file and runs the pure
tovio-core check — no sandbox, nothing written. It catches malformed IDs, unsupported
types/runtimes/events, missing schemas, a zero or over-cap timeout_ms, contradictory capability
requests, and integrity-metadata mistakes before you try to run anything.
2. Read the input¶
A pre-land invocation delivers something like this to your sandbox:
{
"api_version": "v1",
"schema_version": "v1",
"execution_id": "plgexec_01H000000000000000000000",
"event": "pre-land",
"mode": "enforcing",
"plugin_id": "com.example.license-check",
"actor": { "kind": "human", "id": "did:tovio:alice" },
"repo": { "id": "repo_123", "name": "payments" },
"target": { "change_id": "chg:7P4ABCDEFGHIJKLMNOPQRSTUVW", "branch": "main" },
"capabilities": {
"read_metadata": true,
"read_clear_paths": ["Cargo.toml", "Cargo.lock"],
"read_protected_metadata": false,
"read_protected_plaintext": false,
"write_working_copy": false,
"write_plugin_cache": false,
"network_access": false,
"emit_audit": true,
"invoke_tovio_command": false,
"propose_resolution": false,
"propose_transform": false,
"block_operation": true
},
"redaction": "metadata_only",
"payload": []
}
Key fields for authors:
capabilities— the effective grant handed to you (the intersection of manifest, binding, actor, and agent token). Always check what you actually have; do not assume your manifest was granted in full.redaction— withmetadata_only, a protected path appears in the payload as{"path": …, "protected": true}and nothing more; you get no content and no plaintext.payload— opaque event-specific bytes marshalled across the sandbox boundary. Your runtime glue decodes it for the event's schema. A change-shaped event carries{"changed_paths":[{"path":…,"protected":…}]}(forpre-land, the list of changed paths and their metadata). On a content-scan event a record may also carrycontent— the clear text, only for an unprotected path your effectiveread_clear_pathsadmits — orskipped, a marker (too-large,binary,payload-cap,non-utf8) saying the host held that path's clear bytes and withheld them. Askippedpath went unchecked: treat it as a hole in whatever you verify, not as clean. An event whose subject is not a changed-path set —access-revoked,policy-changed,agent-session-started,post-sync,review-submitted— additionally carries a top-levelsubjectobject naming that subject (e.g.{"subject":{"revoked":"did:tovio:…"}}foraccess-revoked,{"subject":{"decision":"approve"}}forreview-submitted).subjectis always non-secret metadata; it is optional, so ignore it if you do not need it.
3. Return a structured result¶
Emit a single JSON document on stdout:
{
"schema_version": "v1",
"plugin_id": "com.example.license-check",
"version": "1.2.0",
"event": "pre-land",
"status": "fail",
"summary": "One dependency violates repository license policy.",
"findings": [
{
"severity": "error",
"path": "Cargo.toml",
"message": "Dependency xyz uses a prohibited license.",
"code": "LICENSE-001",
"remediation": "Remove xyz or request a policy exception."
}
],
"proposed_actions": [],
"audit_metadata": { "policy": "org-license-policy-v3" }
}
Rules that matter:
statusmust be one ofpass | warning | fail | error | skipped | unsupported. Anything else is treated aserror(TVO-PLUGIN-008).- Each finding's
severitymust be one ofinfo | warning | error | critical. - Include a short, human-usable
codeon every finding — CI, agents, and the CLI branch on it. - Include
remediationon every non-passfinding. "What do I do next?" is what turns a blocker from a wall into a task. - Do not put protected plaintext, key material, or secrets in
summary,findings, logs, oraudit_metadata. The signed audit record carries only hashes and capability names (REQ-PLUGIN-060), but yoursummaryandfindingsare shown to users verbatim — keeping secrets out of them is on you.
4. Build the guest¶
A guest is a WASI command module: it reads stdin, writes stdout, and gets nothing else. The
repository ships tovio-plugin-sdk (crates/tovio-plugin-sdk): the wire types (a conformance-pinned
mirror of the frozen v1 PluginInput/PluginResult schema), a protect-path helper, and a
fail-closed run_stdio harness that turns a parse or serialize failure into a status = "error"
result rather than a silent pass. It depends only on serde and serde_json, so the module stays
small. The crate is one of the three reserved for crates.io (ADR-0321) but nothing is published yet,
so today you depend on it by path or git rather than with cargo add.
use tovio_plugin_sdk as sdk;
fn main() {
sdk::run_stdio("com.example.license-check", "1.2.0", |input| {
// Decode input.content_payload() (fail closed on Err), inspect Cargo.toml, return a verdict.
sdk::PluginResult::pass("com.example.license-check", "1.2.0", &input.event, "all clear")
});
}
Compile for the sandbox target and drop the module into the package directory as plugin.wasm:
$ cargo build --release --target wasm32-wasip1
$ cp target/wasm32-wasip1/release/license_check.wasm ./license-check/plugin.wasm
The import object is a closed wasi_snapshot_preview1 stdio set — proc_exit, fd_read/fd_write
and their fd helpers, args_*, environ_*, clock_time_get, random_get — and nothing else. There
are no sock_* and no path_* functions at all, so a module importing one fails to instantiate
(TVO-PLUGIN-006); environ_* and args_* exist but report empty, and fd_prestat_get reports no
preopens, so there is no directory to open. A package directory holds manifest.toml plus at most one
other file, which is taken as the artifact — put fixtures and a README elsewhere.
5. Test locally¶
tovio plugin test resolves the package like install (a directory with manifest.toml and its
artifact) and runs it in the sandbox under the manifest's own declared capabilities — there is no
binding, so you see the plugin's self-declared ceiling. It runs advisory and never gates, and the event
has to be one the manifest declares. Add --json for the structured result (status, summary,
findings, runtime).
test and run send an empty payload
Both build a schema-valid input envelope with no changed paths and no target. That is a real
smoke test — the module loads, imports nothing forbidden, speaks the protocol, and returns a
well-formed result — but it is not a content check. A guest that scans changed paths sees none;
one that fails closed on an undecodable payload, as run_stdio does, reports
status = "error" with could not decode the content payload. That is your harness working, not
your check failing.
To exercise the plugin over real content, install it, bind it with the grant it needs, and drive the event itself:
$ tovio plugin install ./license-check
$ tovio plugin bind com.example.license-check --event pre-land --mode enforcing --required \
--grant emit_audit --grant block_operation \
--grant-read-path Cargo.toml --grant-read-path Cargo.lock
$ tovio land feature/deps --into main
The --grant-read-path flags matter: the binding grant is deny-by-default and the effective grant is
its intersection with the manifest, so a binding with no --grant-read-path leaves your check
content-blind — it runs, sees no file contents, and passes everything. There is no fixture-replay mode;
assert on --json output from your own CI.
6. Package and publish¶
For distribution, replace the local_dev marker with signed integrity metadata and name your
publisher identity:
publisher_identity = "did:key:<64 hex digits>"
[integrity.signed]
sha256 = "<sha256 of plugin.wasm>"
signature = "ed25519:<128 hex digits>"
sha256 is the hex digest of the artifact bytes; installers refuse a package whose artifact does not
hash to it (TVO-PLUGIN-003). publisher_identity is an Ed25519 did:key carrying the 32-byte
public key as hex, and signature is the Ed25519 signature over the canonical manifest-without-
signature plus the artifact digest — change the manifest, the identity, or the artifact and it no
longer verifies (REQ-PLUGIN-087). Consumers trust your identity once with
tovio plugin trust add <did:key:…>; a signed install then reports publisher-signature+sha256. A
hash-only or local_dev package still installs from a local path, but as a development-policy
install that is never reported as publisher-verified.
Keep the same signing identity. The first install of an id pins it to the publisher_identity that
package presented, and a later package for the same id under a different identity is refused — nobody
else can take over an installed id, but neither can you hand one off without your consumers
uninstalling it first.
Distribute the package directory as-is — there is no archive format, and archive extraction is refused on install:
- Local / offline:
tovio plugin install ./license-check. - HTTPS: serve
manifest.tomland a siblingplugin.wasmat a URL ending in/; consumers runtovio plugin install https://plugins.example.com/license-check/. Remote installs require the signed form (remotelocal_devand hash-only packages are refused), use the system root certificates, and bound the download size.
A plugin registry does not exist and is not required (ADR-0040): a URL, a Git-tagged release, or an internal artifact store is enough.
Design tips¶
- Do one thing. A check that does one narrow, explainable job is easier to bind, audit, and override than a Swiss-army plugin.
- Idempotent by construction. TOVIO may re-invoke the same execution — deterministic inputs must always yield the same result.
- Deterministic under retry. No random IDs in findings; no wall-clock timestamps in your output fields (TOVIO records its own).
- Stay under the timeout.
timeout_msis required; the example uses 5000 and the hard cap is 300000 (5 minutes). The sandbox derives its instruction budget from it too. If you can't finish in that budget, either raisetimeout_ms(and justify it in your README) or move the heavy work toresolve-requestedwhere the user is already waiting. - Small blast radius for
transform/resolver. TOVIO applies your proposal through the normal path — treat your output as a suggestion, not a fait accompli.
Where to go next¶
- Manifest reference — every field, with types and constraints.
- Bindings — how consumers attach your plugin to events.
- Capabilities & security — what you can and can't ask for.
- Lifecycle events — the exact input shape per event.
- Errors — the
TVO-PLUGIN-*codes your users will see.
Last reviewed September 9, 2026
Suggest an improvement to this page Not for security reports — see disclosure