Skip to content

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:

  1. Receive a PluginInput JSON document on stdin (or via the sandbox's input channel).
  2. Emit a PluginResult JSON 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 as capabilities.integrity and validation fails with TVO-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 with TVO-PLUGIN-001 (missing field read_metadata, …).
  • block_operation = true declares "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 — with metadata_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":…}]} (for pre-land, the list of changed paths and their metadata). On a content-scan event a record may also carry content — the clear text, only for an unprotected path your effective read_clear_paths admits — or skipped, a marker (too-large, binary, payload-cap, non-utf8) saying the host held that path's clear bytes and withheld them. A skipped path 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-level subject object naming that subject (e.g. {"subject":{"revoked":"did:tovio:…"}} for access-revoked, {"subject":{"decision":"approve"}} for review-submitted). subject is 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:

  • status must be one of pass | warning | fail | error | skipped | unsupported. Anything else is treated as error (TVO-PLUGIN-008).
  • Each finding's severity must be one of info | warning | error | critical.
  • Include a short, human-usable code on every finding — CI, agents, and the CLI branch on it.
  • Include remediation on every non-pass finding. "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, or audit_metadata. The signed audit record carries only hashes and capability names (REQ-PLUGIN-060), but your summary and findings are 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 ./license-check --event pre-land --json

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.toml and a sibling plugin.wasm at a URL ending in /; consumers run tovio plugin install https://plugins.example.com/license-check/. Remote installs require the signed form (remote local_dev and 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_ms is 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 raise timeout_ms (and justify it in your README) or move the heavy work to resolve-requested where 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

Last reviewed September 9, 2026

Suggest an improvement to this page Not for security reports — see disclosure