Skip to main content

Module plugins

Module plugins 

Source
Expand description

P5-12 (COMPOSABLE-HARNESS-DESIGN.md §2 module 18 plugins, D7 “in-process extension API, packaging/marketplaces, custom tools from files, provider injection, extension UI, plugin/package installation”; §2.1 D-10: “config-borne code execution without a trust gate is an injection hole”).

§The ABI decision: out-of-process, trust-gated, manifest-declared

A plugin is not an in-process dynamically-linked library or FFI — that would be memory-unsafe in Rust, a versioning nightmare across plugin/host builds, and would bypass the trust/sandbox boundary this module exists to enforce. Instead, a plugin is a directory containing a manifest (plugin.toml, RawManifest) that DECLARES what it contributes — the manifest is DATA; the plugin’s own code runs ONLY as a subprocess this crate spawns, never linked into supercode’s address space. This keeps a plugin memory-safe to load (a malformed/hostile manifest can’t corrupt this process, only fail to parse), language- agnostic (a plugin can be any executable), sandboxable via crate::sandbox/crate::tools::build_sandboxed_sh exactly like bash, and cleanly trust-gated (below) for D-10.

§The manifest schema (the ABI contract)

name = "my-plugin"      # optional — the plugin's directory name is the
version = "0.1.0"       # fallback/authoritative namespace either way

[[tools]]
name = "greet"                 # required, non-empty
command = "python3"            # required, non-empty — the executable
args = ["greet.py"]            # optional, fixed argv (config-borne, trusted)
description = "Say hello"      # optional
params = { type = "object", properties = { name = { type = "string" } } }
# ^ optional JSON Schema for the tool's input; defaults to
#   {"type": "object"} (an MCP-style server would be the natural growth
#   path for a richer tool surface — see "Honest gaps" below).

[[hooks]]
event = "post_tool"            # a lifecycle event name (cli::hooks::HookEvent)
command = "notify.sh"          # required, non-empty

A plugin registers its [[tools]] entries into the model-visible crate::tools::ToolRegistry (namespaced plugin__<plugin>__<tool>, mirroring crate::mcp::McpServerHandle’s mcp__<server>__<tool> convention) via register_into. [[hooks]] entries are PARSED, VALIDATED, and carried on LoadedPlugins::hooks — see “Honest gaps” below for why their lifecycle EMISSION is not yet wired in this build, the exact same “registerable now, emission deferred” shape crates/cli/src/hooks.rs’s own subagent_start/pre_compact events already use (that module’s doc comment, P5-7).

§Discovery

discover_manifests scans a list of directories, each expected to contain <plugin-name>/plugin.toml subdirectories — the ALWAYS-scanned $SUPERCODE_HOME/plugins (mirroring crate::agent::global_instructions_dir, the same “trusted user/global tier” location every other user-level resource in this crate lives under) plus any extra [capabilities.plugins] dirs = [...] entries. Since [capabilities.plugins] (dirs included) is wholesale project-forbidden (see “Trust model” below), dirs can only ever be user/global-layer or preset data — never attacker-controlled project config.

§Subprocess execution model

A registered PluginTool::execute spawns the manifest’s fixed command/args (never the model’s own arguments — see below) through crate::tools::build_sandboxed_sh, the SAME sandboxed-spawn builder crate::tools::BashTool/crate::agent::Agent::background_exec use — so a plugin tool’s subprocess gets the identical P5-10 OS sandbox (Landlock/seatbelt)/env-policy/network-policy posture a bash call would, not a second, weaker path. Like background_exec (crate::agent’s own P5-6 precedent), the child is placed in its own process group (Command::process_group(0), unix) and unconditionally group-killed after the call completes (success, error, OR timeout) — see kill_group — so a plugin that spawns a persistent worker grandchild (the exact P5-11 LSP-review class this mirrors) never orphans one.

The model’s own tool-call arguments are never shell-spliced. They are serialized to JSON and written to the child’s STDIN — never appended to the (fixed, manifest-sourced) command string build_sandboxed_sh wraps in sh -c. Since the model-controlled content never touches that string at all, there is nothing for it to break out of.

Output (stdout/stderr, captured separately) is bounded at PLUGIN_TOOL_MAX_OUTPUT_BYTES each — reading never stops at the cap (so a flooding child can’t wedge on a full OS pipe), only what’s RETAINED is bounded, the same “reading never stops, retention does” contract crate::background::CapturedOutput documents for itself. The whole call is bounded by DEFAULT_PLUGIN_TOOL_TIMEOUT_SECS; on timeout the process group is killed and a clear timeout error is returned — never a hang.

§Trust model (D-10 — the cardinal requirement)

A plugin is arbitrary code execution, so nothing here ever loads OR RUNS one without an affirmative trust decision:

  • crate::Config::plugins_enabled ([capabilities.plugins] enabled) is the feature’s own master gate — false (the default) means discover_and_load returns PluginLoadOutcome::Disabled without ever touching the filesystem (no directory read, no manifest parse, no subprocess) — byte-identical to before this module existed.
  • is_trusted is the SEPARATE workspace-trust gate ([capabilities.trust]): even with plugins_enabled = true, a workspace whose TrustDecision isn’t TrustDecision::Always gets PluginLoadOutcome::BlockedPendingTrust — loud (a caller-visible, non-silent outcome; see register_into’s one-line stderr notice), never a silent partial load. Honest gap: this build has no interactive “trust this workspace?” prompt UI wired up anywhere (no consumer of TrustDecision::Ask exists yet, matching crate::mcp::HeadlessElicitationHandler’s own “deny-default, pending a real interactive handler” precedent) — so ask (pi’s own own default) and never both cleanly refuse to load in this build; only an operator explicitly setting default = "always" in their trusted user/global config unlocks plugin loading. This narrows what pi’s own defaultProjectTrust = "ask" would otherwise interactively allow, in the safe direction (quarantine-by-default), never the unsafe one.
  • The RESOLVER’s own hard dependency (configfile::validate_modules’s pre-existing D-10 check, plugins → trust) refuses to resolve a config with plugins on and trust off at all — this module’s own is_trusted check is a SECOND, finer-grained gate on top (trust enabled is necessary but not sufficient; it must also have decided always).
  • [capabilities.plugins] (the whole table: enabled, dirs, any future contribution key) is wholesale PROJECT-FORBIDDEN — stripped by both crate::configfile::sanitize_for_project and crates/cli/src/userconfig.rs’s own copy, exactly like hooks/ mcp.servers/server (config-borne code execution). A hostile .supercode.toml cannot enable plugins, add a plugin directory, or loosen the trust decision at all — only the user/global layer (or a preset extended from it) can.

§Honest, deliberate gaps (build brief: “no declared-but-dead key”)

  • No pi-TS-extension compatibility. pi’s in-process TypeScript ExtensionAPI (jiti-loaded modules, ~40 events, registerProvider/ setEditorComponent/overlay UI) cannot and does not run under this ABI — supercode’s plugins module has its OWN ABI by design (COMPOSABLE-HARNESS-DESIGN.md line 1080-1081, an already-accepted recorded deviation), not an emulation of pi’s. An existing pi extension simply does not run here.
  • No marketplace / package installation / npm install. Plugins are discovered from a local, trusted directory only — there is no plugin install <name> command, no registry client, no network fetch anywhere in this module. Fetching/installing a plugin (from a marketplace, npm, or otherwise) is the OPERATOR’S job today (place a directory under $SUPERCODE_HOME/plugins), same posture lsp/ formatters already take for THEIR external tools (§2 module 28’s own “no auto-spawn/auto-download fleet” gap).
  • No extension UI / provider injection. registerProvider, setEditorComponent, overlay UI, and any other in-process extension-surface hook are impossible by construction under an out-of-process ABI (a subprocess cannot reach into this process’s UI/provider registry) — not a partially-wired knob, simply not offered.
  • Hook FIRING is deferred; hook REGISTRATION is not. A manifest’s [[hooks]] entries are parsed, validated, trust-gated exactly like [[tools]], and carried on LoadedPlugins::hooks — but no lifecycle site in crates/cli consults them yet (the same “registerable now, emission deferred” shape crates/cli/src/hooks.rs already ships and documents for subagent_start/subagent_stop/pre_compact/ post_compact, P5-7). register_into prints a one-time-per-call warning when a loaded, trusted plugin declares a hook, so this is a visible, honest gap — never a silent no-op.
  • No hash-trust / manifest-change re-prompt (cx§7 “quarantine + hash-trust”). The weakest form re-evaluates is_trusted (a workspace-level decision) on every load, but does not fingerprint an individual manifest’s content to force a re-decision when it changes — tracked, not hidden: a workspace already at TrustDecision::Always trusts every manifest under its scanned directories, including one edited after the fact. The enabled/default/dirs knobs this module DOES expose are all real and wired; this is a scope gap on top of them, not a dead key.

Structs§

LoadedPlugins
Every trusted, loaded plugin’s contributions — discover_and_load’s success case.
PluginHookSpec
One [[hooks]] entry from a plugin.toml manifest — parsed and trust-gated, but not yet wired to firing (see the module doc comment’s “Honest, deliberate gaps” section).
PluginManifest
A parsed, trust-gated-pending plugin.toml manifest — see the module doc comment’s “Manifest schema” section for the ABI contract this mirrors.
PluginTool
A model-callable tool backed by one plugin’s declared [[tools]] entry — see the module doc comment’s “Subprocess execution model” section for the full spawn/sandbox/bound/no-orphan contract Tool::execute below implements.
PluginToolSpec
One [[tools]] entry from a plugin.toml manifest — see the module doc comment’s “Manifest schema” section.

Enums§

PluginLoadOutcome
The result of one discover_and_load call — see the module doc comment’s “Trust model” section for what drives each variant.
TrustDecision
§2 module 14 trust’s [capabilities.trust] default = "ask" | "always" | "never" decision (§3.1 schema; every preset that turns trust on sets default = "ask", pi’s own defaultProjectTrust default). See the module doc comment’s “Trust model” section for why, absent an interactive upgrade path in this build, only TrustDecision::Always actually unlocks plugin loading — Ask/Never both cleanly refuse rather than silently granting or hanging on a prompt nothing answers.

Constants§

DEFAULT_PLUGIN_TOOL_TIMEOUT_SECS
Default wall-clock bound on a single plugin tool invocation — generous for a real script while bounding how long a hanging/misbehaving plugin can stall the agent loop (mirrors crate::mcp::DEFAULT_MCP_TIMEOUT’s rationale for the same “config-borne subprocess” trust class).
PLUGIN_TOOL_MAX_OUTPUT_BYTES
Hardening cap (mirrors crate::mcp::MCP_MAX_RESPONSE_BYTES’s rationale, scaled down: a plugin tool result is model-context-bound, not a raw resource fetch): the maximum bytes of stdout (and, separately, stderr) a plugin tool invocation retains — reading never stops at this cap (see the module doc comment), only retention does, so a flooding child can’t wedge on a full OS pipe either.

Functions§

default_plugins_dir
The always-scanned trusted plugins location: $SUPERCODE_HOME/plugins (mirrors crate::agent::global_instructions_dir — the same user/global tier every other ambient resource in this crate lives under).
discover_and_load
The single entry point: resolve config’s plugin gates (crate::Config::plugins_enabled, is_trusted) and, only if both pass, discover + parse every manifest under the scanned directories. See the module doc comment’s “Trust model” section for the full D-10 contract this enforces.
discover_manifests
Scan dirs for <plugin-name>/plugin.toml manifests — each entry of dirs is expected to be a directory whose immediate subdirectories are plugin roots (the same shape default_plugins_dir() itself has). Returns (plugin name, manifest path) pairs, sorted by name; a name that appears under more than one scanned directory keeps the LAST directory’s entry (later/more-specific wins — same precedent crates/cli/src/main.rs::attach_mcp’s “same-named entries here WIN” documents for capabilities.mcp.servers over mcp.json). A dirs entry that doesn’t exist or isn’t readable is silently skipped (not every configured location need exist).
is_trusted
§2 module 14 trust + D-10: is this workspace trusted to load/run config-declared plugin code? See the module doc comment’s “Trust model” section. false whenever crate::Config::trust_enabled is false (the master gate — matches every OTHER module’s “disabled means the setting underneath is never consulted” contract) OR crate::Config::trust_default isn’t exactly TrustDecision::Always.
parse_manifest
Read and parse path (a plugin.toml file) — see parse_manifest_str. fallback_name is the containing directory’s name.
parse_manifest_str
Parse text (a plugin.toml’s contents) into a PluginManifest, using fallback_name (the plugin’s directory name) when the manifest itself doesn’t declare name. A malformed TOML document is a clean Err, never a panic; a malformed INDIVIDUAL [[tools]]/[[hooks]] entry (empty name/command/event) is silently skipped rather than failing the whole manifest (see PluginManifest::tools’s doc comment).
register_into
Register every trusted, loaded plugin tool into registry — the single production choke point crate::agent::Agent::with_parts calls (so every Agent construction path gets plugin tools “for free”, the same way crate::lsp/crate::formatters/crate::checkpoint’s observers are wired unconditionally from crate::agent::build_tool_context). A no-op, with nothing printed, when crate::Config::plugins_enabled is false (default-off byte-identity). When enabled but not yet trusted, prints ONE line to stderr (never silent — see PluginLoadOutcome::BlockedPendingTrust’s doc comment) and registers nothing. When loaded, registers every tool and prints a one-time-per-call warning for any hook a trusted plugin declared (see the module doc comment’s “Honest, deliberate gaps” section) plus any manifest parse warning.