Skip to main content

supercode_harness/
plugins.rs

1//! P5-12 (COMPOSABLE-HARNESS-DESIGN.md §2 module 18 `plugins`, D7 "in-process
2//! extension API, packaging/marketplaces, custom tools from files, provider
3//! injection, extension UI, plugin/package installation"; §2.1 D-10:
4//! "config-borne code execution without a trust gate is an injection hole").
5//!
6//! # The ABI decision: out-of-process, trust-gated, manifest-declared
7//!
8//! A plugin is **not** an in-process dynamically-linked library or FFI —
9//! that would be memory-unsafe in Rust, a versioning nightmare across
10//! plugin/host builds, and would bypass the trust/sandbox boundary this
11//! module exists to enforce. Instead, a plugin is a **directory** containing
12//! a **manifest** (`plugin.toml`, `RawManifest`) that DECLARES what it
13//! contributes — the manifest is DATA; the plugin's own code runs ONLY as a
14//! subprocess this crate spawns, never linked into supercode's address
15//! space. This keeps a plugin memory-safe to load (a malformed/hostile
16//! manifest can't corrupt this process, only fail to parse), language-
17//! agnostic (a plugin can be any executable), sandboxable via
18//! [`crate::sandbox`]/`crate::tools::build_sandboxed_sh` exactly like
19//! `bash`, and cleanly trust-gated (below) for D-10.
20//!
21//! ## The manifest schema (the ABI contract)
22//! ```toml
23//! name = "my-plugin"      # optional — the plugin's directory name is the
24//! version = "0.1.0"       # fallback/authoritative namespace either way
25//!
26//! [[tools]]
27//! name = "greet"                 # required, non-empty
28//! command = "python3"            # required, non-empty — the executable
29//! args = ["greet.py"]            # optional, fixed argv (config-borne, trusted)
30//! description = "Say hello"      # optional
31//! params = { type = "object", properties = { name = { type = "string" } } }
32//! # ^ optional JSON Schema for the tool's input; defaults to
33//! #   {"type": "object"} (an MCP-style server would be the natural growth
34//! #   path for a richer tool surface — see "Honest gaps" below).
35//!
36//! [[hooks]]
37//! event = "post_tool"            # a lifecycle event name (cli::hooks::HookEvent)
38//! command = "notify.sh"          # required, non-empty
39//! ```
40//! A plugin registers its `[[tools]]` entries into the model-visible
41//! [`crate::tools::ToolRegistry`] (namespaced `plugin__<plugin>__<tool>`,
42//! mirroring [`crate::mcp::McpServerHandle`]'s `mcp__<server>__<tool>`
43//! convention) via [`register_into`]. `[[hooks]]` entries are PARSED,
44//! VALIDATED, and carried on [`LoadedPlugins::hooks`] — see "Honest gaps"
45//! below for why their lifecycle EMISSION is not yet wired in this build,
46//! the exact same "registerable now, emission deferred" shape
47//! `crates/cli/src/hooks.rs`'s own `subagent_start`/`pre_compact` events
48//! already use (that module's doc comment, P5-7).
49//!
50//! ## Discovery
51//! [`discover_manifests`] scans a list of directories, each expected to
52//! contain `<plugin-name>/plugin.toml` subdirectories — the ALWAYS-scanned
53//! `$SUPERCODE_HOME/plugins` (mirroring `crate::agent::global_instructions_dir`,
54//! the same "trusted user/global tier" location every other user-level
55//! resource in this crate lives under) plus any extra
56//! `[capabilities.plugins] dirs = [...]` entries. Since `[capabilities.plugins]`
57//! (`dirs` included) is wholesale project-forbidden (see "Trust model"
58//! below), `dirs` can only ever be user/global-layer or preset data — never
59//! attacker-controlled project config.
60//!
61//! ## Subprocess execution model
62//! A registered [`PluginTool::execute`] spawns the manifest's fixed
63//! `command`/`args` (never the model's own arguments — see below) through
64//! `crate::tools::build_sandboxed_sh`, the SAME sandboxed-spawn builder
65//! `crate::tools::BashTool`/`crate::agent::Agent::background_exec` use — so
66//! a plugin tool's subprocess gets the identical P5-10 OS sandbox
67//! (Landlock/seatbelt)/env-policy/network-policy posture a `bash` call
68//! would, not a second, weaker path. Like `background_exec`
69//! (`crate::agent`'s own P5-6 precedent), the child is placed in its own
70//! process group (`Command::process_group(0)`, unix) and unconditionally
71//! group-killed after the call completes (success, error, OR timeout) —
72//! see `kill_group` — so a plugin that spawns a persistent worker
73//! grandchild (the exact P5-11 LSP-review class this mirrors) never
74//! orphans one.
75//!
76//! **The model's own tool-call arguments are never shell-spliced.** They
77//! are serialized to JSON and written to the child's STDIN — never appended
78//! to the (fixed, manifest-sourced) command string `build_sandboxed_sh`
79//! wraps in `sh -c`. Since the model-controlled content never touches that
80//! string at all, there is nothing for it to break out of.
81//!
82//! Output (stdout/stderr, captured separately) is bounded at
83//! [`PLUGIN_TOOL_MAX_OUTPUT_BYTES`] each — reading never stops at the cap
84//! (so a flooding child can't wedge on a full OS pipe), only what's
85//! RETAINED is bounded, the same "reading never stops, retention does"
86//! contract `supercode_runtime::background::CapturedOutput` documents for itself. The
87//! whole call is bounded by [`DEFAULT_PLUGIN_TOOL_TIMEOUT_SECS`]; on
88//! timeout the process group is killed and a clear timeout error is
89//! returned — never a hang.
90//!
91//! ## Trust model (D-10 — the cardinal requirement)
92//! A plugin is arbitrary code execution, so nothing here ever loads OR RUNS
93//! one without an affirmative trust decision:
94//! - [`crate::Config::plugins_enabled`] (`[capabilities.plugins] enabled`)
95//!   is the feature's own master gate — `false` (the default) means
96//!   [`discover_and_load`] returns [`PluginLoadOutcome::Disabled`] without
97//!   ever touching the filesystem (no directory read, no manifest parse, no
98//!   subprocess) — byte-identical to before this module existed.
99//! - [`is_trusted`] is the SEPARATE workspace-trust gate
100//!   (`[capabilities.trust]`): even with `plugins_enabled = true`, a
101//!   workspace whose [`TrustDecision`] isn't [`TrustDecision::Always`] gets
102//!   [`PluginLoadOutcome::BlockedPendingTrust`] — loud (a caller-visible,
103//!   non-silent outcome; see [`register_into`]'s one-line stderr notice),
104//!   never a silent partial load. BP-10: `ask` now has a real consumer —
105//!   [`crate::trust`] puts the "trust this workspace?" question to the
106//!   `Config::trust_handler` door (the same
107//!   `crate::permissions::PermissionsApprovalHandler` every other `Ask` in
108//!   this crate uses) and records the answer per project. With NO door
109//!   installed, plugin code is still refused: [`is_trusted`] asks about
110//!   `crate::trust::TrustSurface::Plugins`, whose undecided answer is `false`
111//!   — quarantine-by-default, unchanged, and never the unsafe direction.
112//! - The RESOLVER's own hard dependency (`configfile::validate_modules`'s
113//!   pre-existing D-10 check, `plugins → trust`) refuses to resolve a config
114//!   with `plugins` on and `trust` off at all — this module's own
115//!   [`is_trusted`] check is a SECOND, finer-grained gate on top (trust
116//!   *enabled* is necessary but not sufficient; it must also have decided
117//!   `always`).
118//! - `[capabilities.plugins]` (the whole table: `enabled`, `dirs`, any
119//!   future contribution key) is wholesale PROJECT-FORBIDDEN — stripped by
120//!   both `crate::configfile::sanitize_for_project` and
121//!   `crates/cli/src/userconfig.rs`'s own copy, exactly like `hooks`/
122//!   `mcp.servers`/`server` (config-borne code execution). A hostile
123//!   `.supercode.toml` cannot enable plugins, add a plugin directory, or
124//!   loosen the trust decision at all — only the user/global layer (or a
125//!   preset extended from it) can.
126//!
127//! ## Honest, deliberate gaps (build brief: "no declared-but-dead key")
128//! - **No pi-TS-extension compatibility.** pi's in-process TypeScript
129//!   `ExtensionAPI` (jiti-loaded modules, ~40 events, `registerProvider`/
130//!   `setEditorComponent`/overlay UI) cannot and does not run under this
131//!   ABI — supercode's `plugins` module has its OWN ABI by design
132//!   (COMPOSABLE-HARNESS-DESIGN.md line 1080-1081, an already-accepted
133//!   recorded deviation), not an emulation of pi's. An existing pi
134//!   extension simply does not run here.
135//! - **No marketplace / package installation / `npm install`.** Plugins are
136//!   discovered from a local, trusted directory only — there is no
137//!   `plugin install <name>` command, no registry client, no network fetch
138//!   anywhere in this module. Fetching/installing a plugin (from a
139//!   marketplace, npm, or otherwise) is the OPERATOR'S job today (place a
140//!   directory under `$SUPERCODE_HOME/plugins`), same posture `lsp`/
141//!   `formatters` already take for THEIR external tools (§2 module 28's own
142//!   "no auto-spawn/auto-download fleet" gap).
143//! - **No extension UI / provider injection.** `registerProvider`,
144//!   `setEditorComponent`, overlay UI, and any other in-process
145//!   extension-surface hook are impossible by construction under an
146//!   out-of-process ABI (a subprocess cannot reach into this process's
147//!   UI/provider registry) — not a partially-wired knob, simply not offered.
148//! - **Hook FIRING is deferred; hook REGISTRATION is not.** A manifest's
149//!   `[[hooks]]` entries are parsed, validated, trust-gated exactly like
150//!   `[[tools]]`, and carried on [`LoadedPlugins::hooks`] — but no lifecycle
151//!   site in `crates/cli` consults them yet (the same "registerable now,
152//!   emission deferred" shape `crates/cli/src/hooks.rs` already ships and
153//!   documents for `subagent_start`/`subagent_stop`/`pre_compact`/
154//!   `post_compact`, P5-7). [`register_into`] prints a one-time-per-call
155//!   warning when a loaded, trusted plugin declares a hook, so this is a
156//!   visible, honest gap — never a silent no-op.
157//! - **No hash-trust / manifest-change re-prompt (cx§7 "quarantine +
158//!   hash-trust").** The weakest form re-evaluates [`is_trusted`] (a
159//!   workspace-level decision) on every load, but does not fingerprint an
160//!   individual manifest's content to force a re-decision when it changes —
161//!   tracked, not hidden: a workspace already at `TrustDecision::Always`
162//!   trusts every manifest under its scanned directories, including one
163//!   edited after the fact. The `enabled`/`default`/`dirs` knobs this
164//!   module DOES expose are all real and wired; this is a scope gap on top
165//!   of them, not a dead key.
166
167use std::path::{Path, PathBuf};
168use std::time::Duration;
169
170use async_trait::async_trait;
171use serde_json::Value;
172
173use crate::error::{Error, Result};
174use crate::tools::{Tool, ToolContext};
175
176/// Default wall-clock bound on a single plugin tool invocation — generous
177/// for a real script while bounding how long a hanging/misbehaving plugin
178/// can stall the agent loop (mirrors `crate::mcp::DEFAULT_MCP_TIMEOUT`'s
179/// rationale for the same "config-borne subprocess" trust class).
180pub const DEFAULT_PLUGIN_TOOL_TIMEOUT_SECS: u64 = 30;
181
182/// Hardening cap (mirrors `crate::mcp::MCP_MAX_RESPONSE_BYTES`'s rationale,
183/// scaled down: a plugin tool result is model-context-bound, not a raw
184/// resource fetch): the maximum bytes of stdout (and, separately, stderr)
185/// a plugin tool invocation retains — reading never stops at this cap (see
186/// the module doc comment), only retention does, so a flooding child can't
187/// wedge on a full OS pipe either.
188pub const PLUGIN_TOOL_MAX_OUTPUT_BYTES: usize = 1024 * 1024;
189
190/// §2 module 14 `trust`'s `[capabilities.trust] default = "ask" | "always" |
191/// "never"` decision (§3.1 schema; every preset that turns trust on sets
192/// `default = "ask"`, pi's own `defaultProjectTrust` default). See the
193/// module doc comment's "Trust model" section for why, absent an
194/// interactive upgrade path in this build, only [`TrustDecision::Always`]
195/// actually unlocks plugin loading — `Ask`/`Never` both cleanly refuse
196/// rather than silently granting or hanging on a prompt nothing answers.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
198pub enum TrustDecision {
199    /// Prompt before trusting — pi's own default. No interactive handler is
200    /// wired in this build (honest gap, see the module doc comment), so
201    /// this behaves like [`TrustDecision::Never`] for [`is_trusted`].
202    #[default]
203    Ask,
204    /// Always trusted — the only value [`is_trusted`] accepts today.
205    Always,
206    /// Never trusted, regardless of anything else.
207    Never,
208}
209
210impl TrustDecision {
211    /// Parse the `"ask"` / `"always"` / `"never"` config strings (§3.1).
212    /// Unrecognized text is never silently trusted — the resolver treats an
213    /// unparseable value the same as "not `always`" (see [`is_trusted`]),
214    /// so an operator typo fails closed, not open.
215    pub fn parse(s: &str) -> Option<TrustDecision> {
216        match s {
217            "ask" => Some(TrustDecision::Ask),
218            "always" => Some(TrustDecision::Always),
219            "never" => Some(TrustDecision::Never),
220            _ => None,
221        }
222    }
223}
224
225/// §2 module 14 `trust` + D-10: is this workspace trusted to load/run
226/// config-declared plugin code? See the module doc comment's "Trust model"
227/// section. `false` whenever [`crate::Config::trust_enabled`] is `false`
228/// (the master gate — matches every OTHER module's "disabled means the
229/// setting underneath is never consulted" contract) OR
230/// [`crate::Config::trust_default`] isn't exactly [`TrustDecision::Always`].
231pub fn is_trusted(config: &crate::Config) -> bool {
232    // BP-10 (catalog row "Project/workspace trust gate"): `ask` is no
233    // longer a synonym for `never` — `crate::trust` is the consumer of
234    // `TrustDecision::Ask` this module's doc comment named as the honest
235    // gap. Plugin code is `TrustSurface::Code`, so with NO trust door
236    // installed the answer is still `false`, byte-identical to the
237    // pre-BP-10 behavior this function had.
238    crate::trust::is_trusted(config, crate::trust::TrustSurface::Plugins)
239}
240
241/// One `[[tools]]` entry from a `plugin.toml` manifest — see the module doc
242/// comment's "Manifest schema" section.
243#[derive(Debug, Clone, PartialEq)]
244pub struct PluginToolSpec {
245    /// The executable to spawn (searched on `PATH`, like any `Command::new`)
246    /// — fixed, manifest-sourced data; never the model's own input.
247    pub command: String,
248    /// Fixed extra arguments to `command` — same trust class as `command`.
249    pub args: Vec<String>,
250    /// Human description surfaced to the model as the tool's description.
251    pub description: String,
252    /// JSON Schema for the tool's input object; `{"type": "object"}` when
253    /// the manifest doesn't declare one (same default
254    /// [`crate::mcp::McpToolDef::input_schema`] uses).
255    pub params: Value,
256}
257
258/// One `[[hooks]]` entry from a `plugin.toml` manifest — parsed and
259/// trust-gated, but not yet wired to firing (see the module doc comment's
260/// "Honest, deliberate gaps" section).
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct PluginHookSpec {
263    /// The lifecycle event name (e.g. `"post_tool"`,
264    /// `"session_start"` — `crates/cli/src/hooks.rs::HookEvent::as_str`'s
265    /// string form).
266    pub event: String,
267    /// The command to run — same trust class as a tool's `command`
268    /// (config-borne, from an already trust-gated manifest).
269    pub command: String,
270}
271
272/// A parsed, trust-gated-pending `plugin.toml` manifest — see the module
273/// doc comment's "Manifest schema" section for the ABI contract this
274/// mirrors.
275#[derive(Debug, Clone, PartialEq)]
276pub struct PluginManifest {
277    /// The plugin's own declared name, or its directory name when the
278    /// manifest omits `name` (see [`parse_manifest_str`]).
279    pub name: String,
280    /// Free-form version string (`"0.0.0"` when omitted) — descriptive
281    /// only; this module does not interpret or compare versions.
282    pub version: String,
283    /// `(tool short name, spec)` pairs from `[[tools]]`, in manifest order.
284    /// An entry with an empty `name` or `command` is skipped (malformed,
285    /// not a crash — same "skip the bad entry" precedent
286    /// `crate::configfile::lsp_servers_from_settings` documents for
287    /// itself).
288    pub tools: Vec<(String, PluginToolSpec)>,
289    /// `[[hooks]]` entries, in manifest order. An entry with an empty
290    /// `event` or `command` is skipped, same precedent as `tools`.
291    pub hooks: Vec<PluginHookSpec>,
292}
293
294#[derive(Debug, Default, serde::Deserialize)]
295struct RawManifest {
296    name: Option<String>,
297    #[serde(default)]
298    version: String,
299    #[serde(default)]
300    tools: Vec<RawTool>,
301    #[serde(default)]
302    hooks: Vec<RawHook>,
303}
304
305#[derive(Debug, Default, serde::Deserialize)]
306struct RawTool {
307    #[serde(default)]
308    name: String,
309    #[serde(default)]
310    command: String,
311    #[serde(default)]
312    args: Vec<String>,
313    #[serde(default)]
314    description: String,
315    params: Option<Value>,
316}
317
318#[derive(Debug, Default, serde::Deserialize)]
319struct RawHook {
320    #[serde(default)]
321    event: String,
322    #[serde(default)]
323    command: String,
324}
325
326/// Parse `text` (a `plugin.toml`'s contents) into a [`PluginManifest`],
327/// using `fallback_name` (the plugin's directory name) when the manifest
328/// itself doesn't declare `name`. A malformed TOML document is a clean
329/// `Err`, never a panic; a malformed INDIVIDUAL `[[tools]]`/`[[hooks]]`
330/// entry (empty `name`/`command`/`event`) is silently skipped rather than
331/// failing the whole manifest (see [`PluginManifest::tools`]'s doc
332/// comment).
333pub fn parse_manifest_str(text: &str, fallback_name: &str) -> Result<PluginManifest> {
334    let raw: RawManifest = toml::from_str(text)
335        .map_err(|e| Error::tool("plugins", format!("parsing manifest: {e}")))?;
336    let name = raw
337        .name
338        .filter(|n| !n.trim().is_empty())
339        .unwrap_or_else(|| fallback_name.to_string());
340    let version = if raw.version.trim().is_empty() {
341        "0.0.0".to_string()
342    } else {
343        raw.version
344    };
345    let tools = raw
346        .tools
347        .into_iter()
348        .filter(|t| !t.name.trim().is_empty() && !t.command.trim().is_empty())
349        .map(|t| {
350            (
351                t.name,
352                PluginToolSpec {
353                    command: t.command,
354                    args: t.args,
355                    description: t.description,
356                    params: t
357                        .params
358                        .unwrap_or_else(|| serde_json::json!({"type": "object"})),
359                },
360            )
361        })
362        .collect();
363    let hooks = raw
364        .hooks
365        .into_iter()
366        .filter(|h| !h.event.trim().is_empty() && !h.command.trim().is_empty())
367        .map(|h| PluginHookSpec {
368            event: h.event,
369            command: h.command,
370        })
371        .collect();
372    Ok(PluginManifest {
373        name,
374        version,
375        tools,
376        hooks,
377    })
378}
379
380/// Read and parse `path` (a `plugin.toml` file) — see [`parse_manifest_str`].
381/// `fallback_name` is the containing directory's name.
382pub fn parse_manifest(path: &Path, fallback_name: &str) -> Result<PluginManifest> {
383    let text = std::fs::read_to_string(path)
384        .map_err(|e| Error::tool("plugins", format!("reading {}: {e}", path.display())))?;
385    parse_manifest_str(&text, fallback_name)
386}
387
388/// The always-scanned trusted plugins location:
389/// `$SUPERCODE_HOME/plugins` (mirrors `crate::agent::global_instructions_dir`
390/// — the same user/global tier every other ambient resource in this crate
391/// lives under).
392pub fn default_plugins_dir() -> PathBuf {
393    crate::agent::global_instructions_dir().join("plugins")
394}
395
396/// Scan `dirs` for `<plugin-name>/plugin.toml` manifests — each entry of
397/// `dirs` is expected to be a directory whose immediate subdirectories are
398/// plugin roots (the same shape `default_plugins_dir()` itself has). Returns
399/// `(plugin name, manifest path)` pairs, sorted by name; a name that
400/// appears under more than one scanned directory keeps the LAST directory's
401/// entry (later/more-specific wins — same precedent
402/// `crates/cli/src/main.rs::attach_mcp`'s "same-named entries here WIN"
403/// documents for `capabilities.mcp.servers` over `mcp.json`). A `dirs`
404/// entry that doesn't exist or isn't readable is silently skipped (not
405/// every configured location need exist).
406pub fn discover_manifests(dirs: &[PathBuf]) -> Vec<(String, PathBuf)> {
407    let mut found: std::collections::BTreeMap<String, PathBuf> = std::collections::BTreeMap::new();
408    for dir in dirs {
409        let Ok(entries) = std::fs::read_dir(dir) else {
410            continue;
411        };
412        for entry in entries.flatten() {
413            let path = entry.path();
414            if !path.is_dir() {
415                continue;
416            }
417            let manifest = path.join("plugin.toml");
418            if !manifest.is_file() {
419                continue;
420            }
421            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
422                continue;
423            };
424            found.insert(name.to_string(), manifest);
425        }
426    }
427    found.into_iter().collect()
428}
429
430/// SIGKILL an entire process group — reused verbatim from
431/// `crate::lsp::kill_process_group` (P5-11's grandchild-orphan fix), the
432/// exact same primitive for the exact same reason: a plugin's declared
433/// command commonly spawns its OWN worker subprocess, and plain
434/// `Child::start_kill` only ever signals the one directly-tracked pid.
435#[cfg(unix)]
436fn kill_group(pid: u32) {
437    crate::lsp::kill_process_group(pid);
438}
439
440#[cfg(not(unix))]
441fn kill_group(_pid: u32) {}
442
443/// POSIX single-quote a string for safe inclusion in a `sh -c` command —
444/// used ONLY for the manifest's OWN fixed `command`/`args` (trusted,
445/// config-borne data), never for the model's tool-call arguments, which
446/// travel over stdin instead (see the module doc comment's "Subprocess
447/// execution model" section). Wrapping in single quotes and escaping any
448/// embedded single quote (`'` -> `'\''`) is safe regardless of what
449/// characters the string contains.
450fn shell_quote(s: &str) -> String {
451    format!("'{}'", s.replace('\'', "'\\''"))
452}
453
454/// Bounded-read one child pipe to completion — reading never stops at
455/// `cap` (so the child can't wedge on a full OS pipe by writing past it),
456/// only what's RETAINED does; returns `(text, truncated)`.
457async fn drain_capped<R>(mut reader: R, cap: usize) -> (String, bool)
458where
459    R: tokio::io::AsyncRead + Unpin,
460{
461    use tokio::io::AsyncReadExt;
462    let mut buf: Vec<u8> = Vec::new();
463    let mut truncated = false;
464    let mut chunk = [0u8; 8192];
465    loop {
466        match reader.read(&mut chunk).await {
467            Ok(0) => break,
468            Ok(n) => {
469                if buf.len() < cap {
470                    let room = cap - buf.len();
471                    let take = room.min(n);
472                    buf.extend_from_slice(&chunk[..take]);
473                    if take < n {
474                        truncated = true;
475                    }
476                } else {
477                    truncated = true;
478                }
479            }
480            Err(_) => break,
481        }
482    }
483    (String::from_utf8_lossy(&buf).into_owned(), truncated)
484}
485
486/// A model-callable tool backed by one plugin's declared `[[tools]]` entry
487/// — see the module doc comment's "Subprocess execution model" section for
488/// the full spawn/sandbox/bound/no-orphan contract [`Tool::execute`] below
489/// implements.
490#[derive(Debug, Clone)]
491pub struct PluginTool {
492    name: String,
493    description: String,
494    params: Value,
495    command: String,
496    args: Vec<String>,
497    timeout: Duration,
498}
499
500impl PluginTool {
501    /// Build the namespaced (`plugin__<plugin>__<tool>`) tool for one
502    /// manifest `[[tools]]` entry — mirrors
503    /// `crate::mcp::McpServerHandle::tools`'s `mcp__<server>__<tool>`
504    /// convention. Uses [`DEFAULT_PLUGIN_TOOL_TIMEOUT_SECS`]; see
505    /// `Self::with_timeout` to override (test-only — this module exposes
506    /// no config knob for it, matching weakest-form scope).
507    pub fn new(plugin_name: &str, tool_name: &str, spec: &PluginToolSpec) -> Self {
508        PluginTool {
509            name: format!("plugin__{plugin_name}__{tool_name}"),
510            description: spec.description.clone(),
511            params: spec.params.clone(),
512            command: spec.command.clone(),
513            args: spec.args.clone(),
514            timeout: Duration::from_secs(DEFAULT_PLUGIN_TOOL_TIMEOUT_SECS),
515        }
516    }
517}
518
519#[async_trait]
520impl Tool for PluginTool {
521    fn name(&self) -> &str {
522        &self.name
523    }
524
525    fn description(&self) -> &str {
526        &self.description
527    }
528
529    fn parameters(&self) -> Value {
530        self.params.clone()
531    }
532
533    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
534        let quoted = format!(
535            "{} {}",
536            shell_quote(&self.command),
537            self.args
538                .iter()
539                .map(|a| shell_quote(a))
540                .collect::<Vec<_>>()
541                .join(" ")
542        );
543        let mut cmd = crate::tools::build_sandboxed_sh(&quoted, ctx)?;
544        cmd.current_dir(&ctx.cwd)
545            .stdin(std::process::Stdio::piped())
546            .stdout(std::process::Stdio::piped())
547            .stderr(std::process::Stdio::piped())
548            .kill_on_drop(true);
549        #[cfg(unix)]
550        cmd.process_group(0);
551
552        let mut child = cmd
553            .spawn()
554            .map_err(|e| Error::tool("plugins", format!("spawn `{}`: {e}", self.command)))?;
555        let pid = child.id();
556
557        let args_json = serde_json::to_vec(&args)
558            .map_err(|e| Error::tool("plugins", format!("encoding tool args: {e}")))?;
559        let mut stdin = child
560            .stdin
561            .take()
562            .ok_or_else(|| Error::tool("plugins", "no stdin"))?;
563        let stdout = child
564            .stdout
565            .take()
566            .ok_or_else(|| Error::tool("plugins", "no stdout"))?;
567        let stderr = child
568            .stderr
569            .take()
570            .ok_or_else(|| Error::tool("plugins", "no stderr"))?;
571
572        let run = async {
573            use tokio::io::AsyncWriteExt;
574            // Write the model's tool-call arguments as JSON over stdin —
575            // NEVER appended to the command string above (see the module
576            // doc comment). Best-effort: a plugin that doesn't read stdin
577            // at all must not hang this write forever, so this is inside
578            // the same outer timeout as everything else in `run`.
579            let _ = stdin.write_all(&args_json).await;
580            let _ = stdin.flush().await;
581            drop(stdin); // EOF, so a plugin blocked on read(stdin) unblocks
582
583            let stdout_task = tokio::spawn(drain_capped(stdout, PLUGIN_TOOL_MAX_OUTPUT_BYTES));
584            let stderr_task = tokio::spawn(drain_capped(stderr, PLUGIN_TOOL_MAX_OUTPUT_BYTES));
585            let status = child.wait().await;
586            let (out, out_truncated) = stdout_task.await.unwrap_or_default();
587            let (err, err_truncated) = stderr_task.await.unwrap_or_default();
588            (status, out, out_truncated, err, err_truncated)
589        };
590
591        let outcome = tokio::time::timeout(self.timeout, run).await;
592
593        // Unconditional group-kill, success OR timeout OR error — belt-
594        // and-suspenders against a surviving worker grandchild even on the
595        // clean-exit path (see the module doc comment's no-orphan
596        // paragraph; `killpg` on an already-exited leader's group still
597        // reaches any surviving member, and is a documented no-op — ESRCH
598        // — if the whole group is already gone).
599        if let Some(pid) = pid {
600            kill_group(pid);
601        }
602
603        let (status, out, out_truncated, err, err_truncated) = match outcome {
604            Ok(result) => result,
605            Err(_) => {
606                return Err(Error::tool(
607                    "plugins",
608                    format!(
609                        "plugin tool `{}` timed out after {:?}",
610                        self.name, self.timeout
611                    ),
612                ));
613            }
614        };
615
616        let mut result = out;
617        if out_truncated {
618            result.push_str(&format!(
619                "\n[plugin output truncated at {PLUGIN_TOOL_MAX_OUTPUT_BYTES} bytes]"
620            ));
621        }
622        if !err.trim().is_empty() {
623            result.push_str("\n[stderr]\n");
624            result.push_str(&err);
625            if err_truncated {
626                result.push_str(&format!(
627                    "\n[plugin stderr truncated at {PLUGIN_TOOL_MAX_OUTPUT_BYTES} bytes]"
628                ));
629            }
630        }
631        match status {
632            Ok(s) if !s.success() => {
633                result.push_str(&format!(
634                    "\n[plugin tool `{}` exited {}]",
635                    self.name,
636                    s.code().map(|c| c.to_string()).unwrap_or_default()
637                ));
638            }
639            Err(e) => {
640                return Err(Error::tool(
641                    "plugins",
642                    format!("plugin tool `{}` wait failed: {e}", self.name),
643                ));
644            }
645            _ => {}
646        }
647        Ok(result)
648    }
649}
650
651/// Every trusted, loaded plugin's contributions — [`discover_and_load`]'s
652/// success case.
653#[derive(Debug, Default)]
654pub struct LoadedPlugins {
655    /// Ready-to-register tools, in discovery order.
656    pub tools: Vec<PluginTool>,
657    /// `(plugin name, hook spec)` pairs — see the module doc comment's
658    /// "Honest, deliberate gaps" section for why these are carried but not
659    /// yet fired.
660    pub hooks: Vec<(String, PluginHookSpec)>,
661    /// Names of every plugin whose manifest parsed successfully.
662    pub loaded_plugin_names: Vec<String>,
663    /// Human-readable warnings for manifests that failed to parse — never
664    /// fatal to the OTHER plugins' load (one bad manifest doesn't sink the
665    /// rest), but never silently swallowed either.
666    pub warnings: Vec<String>,
667}
668
669/// The result of one [`discover_and_load`] call — see the module doc
670/// comment's "Trust model" section for what drives each variant.
671#[derive(Debug)]
672pub enum PluginLoadOutcome {
673    /// `[capabilities.plugins] enabled` is `false` (the default) — nothing
674    /// was touched: no directory read, no manifest parsed, no subprocess
675    /// spawned.
676    Disabled,
677    /// `enabled = true`, but [`is_trusted`] said no — nothing was loaded.
678    /// Distinct from [`PluginLoadOutcome::Disabled`] so a caller can report
679    /// this honestly (quarantined pending trust) rather than looking
680    /// identical to the feature being off.
681    BlockedPendingTrust,
682    /// Trusted and enabled — every discovered manifest was at least
683    /// attempted; see [`LoadedPlugins::warnings`] for any that failed.
684    Loaded(LoadedPlugins),
685}
686
687/// The single entry point: resolve `config`'s plugin gates
688/// ([`crate::Config::plugins_enabled`], [`is_trusted`]) and, only if both
689/// pass, discover + parse every manifest under the scanned directories.
690/// See the module doc comment's "Trust model" section for the full D-10
691/// contract this enforces.
692pub fn discover_and_load(config: &crate::Config) -> PluginLoadOutcome {
693    if !config.plugins_enabled {
694        return PluginLoadOutcome::Disabled;
695    }
696    if !is_trusted(config) {
697        return PluginLoadOutcome::BlockedPendingTrust;
698    }
699    let mut dirs = vec![default_plugins_dir()];
700    dirs.extend(config.plugins_dirs.iter().cloned());
701    let manifests = discover_manifests(&dirs);
702
703    let mut loaded = LoadedPlugins::default();
704    for (name, path) in manifests {
705        match parse_manifest(&path, &name) {
706            Ok(manifest) => {
707                for (tool_name, spec) in &manifest.tools {
708                    loaded
709                        .tools
710                        .push(PluginTool::new(&manifest.name, tool_name, spec));
711                }
712                for hook in &manifest.hooks {
713                    loaded.hooks.push((manifest.name.clone(), hook.clone()));
714                }
715                loaded.loaded_plugin_names.push(manifest.name);
716            }
717            Err(e) => {
718                loaded
719                    .warnings
720                    .push(format!("plugin `{name}` ({}): {e}", path.display()));
721            }
722        }
723    }
724    PluginLoadOutcome::Loaded(loaded)
725}
726
727/// Register every trusted, loaded plugin tool into `registry` — the single
728/// production choke point `crate::agent::Agent::with_parts` calls (so every
729/// `Agent` construction path gets plugin tools "for free", the same way
730/// `crate::lsp`/`crate::formatters`/`crate::checkpoint`'s observers are
731/// wired unconditionally from `crate::agent::build_tool_context`). A no-op,
732/// with nothing printed, when [`crate::Config::plugins_enabled`] is `false`
733/// (default-off byte-identity). When enabled but not yet trusted, prints
734/// ONE line to stderr (never silent — see [`PluginLoadOutcome::BlockedPendingTrust`]'s
735/// doc comment) and registers nothing. When loaded, registers every tool
736/// and prints a one-time-per-call warning for any hook a trusted plugin
737/// declared (see the module doc comment's "Honest, deliberate gaps"
738/// section) plus any manifest parse warning.
739pub fn register_into(config: &crate::Config, registry: &mut crate::tools::ToolRegistry) {
740    match discover_and_load(config) {
741        PluginLoadOutcome::Disabled => {}
742        PluginLoadOutcome::BlockedPendingTrust => {
743            eprintln!(
744                "warning: [capabilities.plugins] is enabled but this workspace is not trusted \
745                 ([capabilities.trust] default must be \"always\") — no plugin was loaded"
746            );
747        }
748        PluginLoadOutcome::Loaded(loaded) => {
749            for warning in &loaded.warnings {
750                eprintln!("warning: {warning}");
751            }
752            for (plugin, hook) in &loaded.hooks {
753                eprintln!(
754                    "warning: plugin `{plugin}`'s `{}` hook is registered but is not yet \
755                     emitted in this build (no-op) — see crate::plugins's module doc comment",
756                    hook.event
757                );
758            }
759            for tool in loaded.tools {
760                registry.register(tool);
761            }
762        }
763    }
764}