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-emptyA 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) meansdiscover_and_loadreturnsPluginLoadOutcome::Disabledwithout ever touching the filesystem (no directory read, no manifest parse, no subprocess) — byte-identical to before this module existed.is_trustedis the SEPARATE workspace-trust gate ([capabilities.trust]): even withplugins_enabled = true, a workspace whoseTrustDecisionisn’tTrustDecision::AlwaysgetsPluginLoadOutcome::BlockedPendingTrust— loud (a caller-visible, non-silent outcome; seeregister_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 ofTrustDecision::Askexists yet, matchingcrate::mcp::HeadlessElicitationHandler’s own “deny-default, pending a real interactive handler” precedent) — soask(pi’s own own default) andneverboth cleanly refuse to load in this build; only an operator explicitly settingdefault = "always"in their trusted user/global config unlocks plugin loading. This narrows what pi’s owndefaultProjectTrust = "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 withpluginson andtrustoff at all — this module’s ownis_trustedcheck is a SECOND, finer-grained gate on top (trust enabled is necessary but not sufficient; it must also have decidedalways). [capabilities.plugins](the whole table:enabled,dirs, any future contribution key) is wholesale PROJECT-FORBIDDEN — stripped by bothcrate::configfile::sanitize_for_projectandcrates/cli/src/userconfig.rs’s own copy, exactly likehooks/mcp.servers/server(config-borne code execution). A hostile.supercode.tomlcannot 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’spluginsmodule 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 noplugin 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 posturelsp/formattersalready 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 onLoadedPlugins::hooks— but no lifecycle site incrates/cliconsults them yet (the same “registerable now, emission deferred” shapecrates/cli/src/hooks.rsalready ships and documents forsubagent_start/subagent_stop/pre_compact/post_compact, P5-7).register_intoprints 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 atTrustDecision::Alwaystrusts every manifest under its scanned directories, including one edited after the fact. Theenabled/default/dirsknobs this module DOES expose are all real and wired; this is a scope gap on top of them, not a dead key.
Structs§
- Loaded
Plugins - Every trusted, loaded plugin’s contributions —
discover_and_load’s success case. - Plugin
Hook Spec - One
[[hooks]]entry from aplugin.tomlmanifest — parsed and trust-gated, but not yet wired to firing (see the module doc comment’s “Honest, deliberate gaps” section). - Plugin
Manifest - A parsed, trust-gated-pending
plugin.tomlmanifest — see the module doc comment’s “Manifest schema” section for the ABI contract this mirrors. - Plugin
Tool - 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 contractTool::executebelow implements. - Plugin
Tool Spec - One
[[tools]]entry from aplugin.tomlmanifest — see the module doc comment’s “Manifest schema” section.
Enums§
- Plugin
Load Outcome - The result of one
discover_and_loadcall — see the module doc comment’s “Trust model” section for what drives each variant. - Trust
Decision - §2 module 14
trust’s[capabilities.trust] default = "ask" | "always" | "never"decision (§3.1 schema; every preset that turns trust on setsdefault = "ask", pi’s owndefaultProjectTrustdefault). See the module doc comment’s “Trust model” section for why, absent an interactive upgrade path in this build, onlyTrustDecision::Alwaysactually unlocks plugin loading —Ask/Neverboth 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(mirrorscrate::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
dirsfor<plugin-name>/plugin.tomlmanifests — each entry ofdirsis expected to be a directory whose immediate subdirectories are plugin roots (the same shapedefault_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 precedentcrates/cli/src/main.rs::attach_mcp’s “same-named entries here WIN” documents forcapabilities.mcp.serversovermcp.json). Adirsentry 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.falsewhenevercrate::Config::trust_enabledisfalse(the master gate — matches every OTHER module’s “disabled means the setting underneath is never consulted” contract) ORcrate::Config::trust_defaultisn’t exactlyTrustDecision::Always. - parse_
manifest - Read and parse
path(aplugin.tomlfile) — seeparse_manifest_str.fallback_nameis the containing directory’s name. - parse_
manifest_ str - Parse
text(aplugin.toml’s contents) into aPluginManifest, usingfallback_name(the plugin’s directory name) when the manifest itself doesn’t declarename. A malformed TOML document is a cleanErr, never a panic; a malformed INDIVIDUAL[[tools]]/[[hooks]]entry (emptyname/command/event) is silently skipped rather than failing the whole manifest (seePluginManifest::tools’s doc comment). - register_
into - Register every trusted, loaded plugin tool into
registry— the single production choke pointcrate::agent::Agent::with_partscalls (so everyAgentconstruction path gets plugin tools “for free”, the same waycrate::lsp/crate::formatters/crate::checkpoint’s observers are wired unconditionally fromcrate::agent::build_tool_context). A no-op, with nothing printed, whencrate::Config::plugins_enabledisfalse(default-off byte-identity). When enabled but not yet trusted, prints ONE line to stderr (never silent — seePluginLoadOutcome::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.