Skip to main content

zeph_plugins/
manager.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Plugin lifecycle management: add, remove, list.
5
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9use walkdir::WalkDir;
10use zeph_skills::bundled::bundled_skill_names;
11use zeph_skills::registry::SkillRegistry;
12
13use crate::PluginError;
14use crate::manifest::{PluginManifest, PluginMcpServer};
15
16/// The tighten-only config overlay safelist. Any key outside this list causes
17/// [`PluginError::UnsafeOverlay`] at install time.
18const CONFIG_SAFELIST: &[&str] = &[
19    "tools.blocked_commands",
20    "tools.allowed_commands",
21    "skills.disambiguation_threshold",
22];
23
24/// Result of a successful `plugin add` operation.
25#[derive(Debug)]
26pub struct AddResult {
27    /// Installed plugin name.
28    pub name: String,
29    /// Absolute path to the installed plugin root.
30    ///
31    /// Callers should pass each entry in `installed_skill_dirs` to
32    /// [`zeph_skills::registry::SkillRegistry::register_hub_dir`] so the registry treats plugin
33    /// subtrees as non-bundled regardless of any residual `.bundled` markers (S2 defense).
34    pub plugin_root: PathBuf,
35    /// Skill names registered from this plugin.
36    pub installed_skills: Vec<String>,
37    /// MCP server IDs declared by this plugin (require agent restart).
38    pub mcp_server_ids: Vec<String>,
39    /// Non-fatal warnings produced at install time.
40    ///
41    /// Currently populated when a plugin's `allowed_commands` overlay will
42    /// have no effect because the host's base `tools.shell.allowed_commands`
43    /// is empty (see issue #3149 — tighten-only semantics mean plugins
44    /// cannot widen an empty base allowlist). Callers should surface these
45    /// to the user alongside the success message (`eprintln!` on the CLI,
46    /// appended to the output string on the TUI).
47    pub warnings: Vec<String>,
48}
49
50/// Result of a successful `plugin remove` operation.
51#[derive(Debug, Default)]
52pub struct RemoveResult {
53    /// Skill names unregistered.
54    pub removed_skills: Vec<String>,
55    /// MCP server IDs that were declared (require agent restart).
56    pub removed_mcp_ids: Vec<String>,
57}
58
59/// Installed plugin metadata as returned by `plugin list`.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct InstalledPlugin {
62    /// Plugin name.
63    pub name: String,
64    /// Plugin version.
65    pub version: String,
66    /// Plugin description.
67    pub description: String,
68    /// Absolute path to the installed plugin root.
69    pub path: PathBuf,
70}
71
72/// Manages plugin lifecycle: install, remove, list.
73///
74/// All operations are synchronous. Plugin watchers and agent config overlays are
75/// applied separately by the agent bootstrap layer.
76pub struct PluginManager {
77    /// Root directory where plugins are installed (`~/.local/share/zeph/plugins/`).
78    plugins_dir: PathBuf,
79    /// Directory where managed (user-installed) skills live.
80    managed_skills_dir: PathBuf,
81    /// `mcp.allowed_commands` from the agent config. Used to validate plugin MCP entries.
82    mcp_allowed_commands: Vec<String>,
83    /// Host's base `tools.shell.allowed_commands`. Used to warn when a
84    /// plugin overlay will be silently dropped because the base is empty
85    /// (see issue #3149).
86    base_allowed_commands: Vec<String>,
87    /// Path to the integrity registry file. Injected so tests can use isolated paths.
88    integrity_registry_path: PathBuf,
89}
90
91impl PluginManager {
92    /// Returns the canonical default plugins directory: `~/.local/share/zeph/plugins/`.
93    ///
94    /// Both the CLI and TUI must use this helper so they always point to the same directory.
95    #[must_use]
96    pub fn default_plugins_dir() -> PathBuf {
97        dirs::data_local_dir()
98            .unwrap_or_else(|| PathBuf::from("~/.local/share"))
99            .join("zeph")
100            .join("plugins")
101    }
102
103    /// Create a new manager.
104    ///
105    /// # Parameters
106    ///
107    /// - `plugins_dir` — root installation directory for plugins.
108    /// - `managed_skills_dir` — directory for user-managed skills (conflict detection).
109    /// - `mcp_allowed_commands` — allowlist for MCP server commands from agent config.
110    /// - `base_allowed_commands` — host's `tools.shell.allowed_commands`.
111    ///   Used to emit a non-fatal warning when a plugin overlay would be
112    ///   silently dropped at load time (tighten-only invariant).
113    #[must_use]
114    pub fn new(
115        plugins_dir: PathBuf,
116        managed_skills_dir: PathBuf,
117        mcp_allowed_commands: Vec<String>,
118        base_allowed_commands: Vec<String>,
119    ) -> Self {
120        let integrity_registry_path = crate::integrity::IntegrityRegistry::default_path();
121        Self {
122            plugins_dir,
123            managed_skills_dir,
124            mcp_allowed_commands,
125            base_allowed_commands,
126            integrity_registry_path,
127        }
128    }
129
130    /// Override the integrity registry path. Intended for tests only.
131    #[cfg(test)]
132    #[must_use]
133    pub fn with_integrity_registry_path(mut self, path: PathBuf) -> Self {
134        self.integrity_registry_path = path;
135        self
136    }
137
138    /// Install a plugin from a local directory path.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`PluginError`] if the manifest is invalid, the source cannot be read,
143    /// there are skill name conflicts, MCP commands are not allowlisted, or config
144    /// overlay keys are not in the tighten-only safelist.
145    pub fn add(&self, source: &str) -> Result<AddResult, PluginError> {
146        let source_path = PathBuf::from(source);
147        if !source_path.exists() {
148            return Err(PluginError::InvalidSource {
149                path: source.to_owned(),
150                reason: "path does not exist".to_owned(),
151            });
152        }
153
154        let manifest_path = source_path.join("plugin.toml");
155        let manifest_bytes = std::fs::read(&manifest_path).map_err(|e| PluginError::Io {
156            path: manifest_path.clone(),
157            source: e,
158        })?;
159        let manifest_str = String::from_utf8(manifest_bytes).map_err(|_| {
160            PluginError::InvalidManifest("plugin.toml is not valid UTF-8".to_owned())
161        })?;
162        let manifest: PluginManifest = toml::from_str(&manifest_str)
163            .map_err(|e| PluginError::InvalidManifest(format!("{e}")))?;
164
165        // Validate plugin name.
166        validate_plugin_name(&manifest.plugin.name)?;
167
168        // Validate each [[skills]] entry: path must stay within source root and SKILL.md must exist.
169        for entry in &manifest.skills {
170            let skill_path = source_path.join(&entry.path);
171            // Reject path traversal: resolved path must be inside source_path.
172            let canonical_source = source_path.canonicalize().map_err(|e| PluginError::Io {
173                path: source_path.clone(),
174                source: e,
175            })?;
176            let canonical_skill = skill_path
177                .canonicalize()
178                .unwrap_or_else(|_| skill_path.clone());
179            if !canonical_skill.starts_with(&canonical_source) {
180                return Err(PluginError::InvalidSource {
181                    path: entry.path.clone(),
182                    reason: "skill path escapes plugin source root".to_owned(),
183                });
184            }
185            // Ensure the skill directory contains a SKILL.md file.
186            if !skill_path.join("SKILL.md").is_file() {
187                return Err(PluginError::SkillEntryMissing { path: skill_path });
188            }
189        }
190
191        // Validate config overlay keys.
192        validate_overlay_keys(&manifest.config)?;
193
194        let mut warnings: Vec<String> = Vec::new();
195        if let Some(msg) = check_allowed_commands_overlay_effect(
196            &manifest.config,
197            &self.base_allowed_commands,
198            &manifest.plugin.name,
199        ) {
200            tracing::warn!(plugin = %manifest.plugin.name, "{msg}");
201            warnings.push(msg);
202        }
203
204        // Validate MCP command allowlist.
205        validate_mcp_commands(&manifest.mcp.servers, &self.mcp_allowed_commands)?;
206
207        // Collect skill names from the plugin source.
208        let skill_names = collect_skill_names(&source_path, &manifest);
209
210        // Check for name conflicts.
211        self.check_skill_conflicts(&skill_names, &manifest.plugin.name)?;
212
213        let dest = self.plugins_dir.join(&manifest.plugin.name);
214
215        // Copy source to destination.
216        copy_dir_all(&source_path, &dest)?;
217
218        // Recursively strip all .bundled markers from the installed tree.
219        strip_bundled_markers(&dest);
220
221        // Write manifest copy at plugin root for future reference.
222        let installed_manifest_path = dest.join(".plugin.toml");
223        let manifest_str = toml::to_string(&manifest)?;
224        std::fs::write(&installed_manifest_path, &manifest_str).map_err(|e| PluginError::Io {
225            path: installed_manifest_path.clone(),
226            source: e,
227        })?;
228
229        // Record integrity digest. Crash between the write above and the save here leaves the
230        // plugin with no registry entry — it will load unverified until reinstalled (M4).
231        let mut registry = crate::integrity::IntegrityRegistry::load(&self.integrity_registry_path);
232        if let Err(e) = registry
233            .record(&manifest.plugin.name, &installed_manifest_path)
234            .and_then(|()| registry.save(&self.integrity_registry_path))
235        {
236            tracing::warn!(plugin = %manifest.plugin.name, error = %e, "failed to update integrity registry after install");
237        }
238
239        let mcp_server_ids: Vec<String> =
240            manifest.mcp.servers.iter().map(|s| s.id.clone()).collect();
241
242        tracing::info!(
243            plugin = %manifest.plugin.name,
244            skills = ?skill_names,
245            mcp_servers = ?mcp_server_ids,
246            "plugin installed"
247        );
248
249        Ok(AddResult {
250            name: manifest.plugin.name,
251            plugin_root: dest,
252            installed_skills: skill_names,
253            mcp_server_ids,
254            warnings,
255        })
256    }
257
258    /// Remove an installed plugin by name.
259    ///
260    /// # Errors
261    ///
262    /// Returns [`PluginError::NotFound`] if the plugin is not installed.
263    pub fn remove(&self, name: &str) -> Result<RemoveResult, PluginError> {
264        validate_plugin_name(name)?;
265        let plugin_dir = self.plugins_dir.join(name);
266        if !plugin_dir.exists() {
267            return Err(PluginError::NotFound {
268                name: name.to_owned(),
269            });
270        }
271
272        let manifest_path = plugin_dir.join(".plugin.toml");
273        let (removed_skills, removed_mcp_ids) = if manifest_path.exists() {
274            let bytes = std::fs::read(&manifest_path).map_err(|e| PluginError::Io {
275                path: manifest_path,
276                source: e,
277            })?;
278            let text = String::from_utf8(bytes).map_err(|_| {
279                PluginError::InvalidManifest(".plugin.toml is not valid UTF-8".to_owned())
280            })?;
281            let manifest: PluginManifest =
282                toml::from_str(&text).map_err(|e| PluginError::InvalidManifest(format!("{e}")))?;
283            let skills = collect_skill_names(&plugin_dir, &manifest);
284            let mcp = manifest.mcp.servers.iter().map(|s| s.id.clone()).collect();
285            (skills, mcp)
286        } else {
287            (Vec::new(), Vec::new())
288        };
289
290        std::fs::remove_dir_all(&plugin_dir).map_err(|e| PluginError::Io {
291            path: plugin_dir,
292            source: e,
293        })?;
294
295        // Remove integrity entry; non-fatal if registry cannot be updated.
296        let mut registry = crate::integrity::IntegrityRegistry::load(&self.integrity_registry_path);
297        registry.remove(name);
298        if let Err(e) = registry.save(&self.integrity_registry_path) {
299            tracing::warn!(plugin = %name, error = %e, "failed to update integrity registry after remove");
300        }
301
302        tracing::info!(plugin = %name, "plugin removed");
303
304        Ok(RemoveResult {
305            removed_skills,
306            removed_mcp_ids,
307        })
308    }
309
310    /// List all installed plugins.
311    ///
312    /// # Errors
313    ///
314    /// Returns [`PluginError`] if the plugins directory cannot be read.
315    pub fn list_installed(&self) -> Result<Vec<InstalledPlugin>, PluginError> {
316        if !self.plugins_dir.exists() {
317            return Ok(Vec::new());
318        }
319
320        let mut plugins = Vec::new();
321        let entries = std::fs::read_dir(&self.plugins_dir).map_err(|e| PluginError::Io {
322            path: self.plugins_dir.clone(),
323            source: e,
324        })?;
325
326        for entry in entries.flatten() {
327            let path = entry.path();
328            if !path.is_dir() {
329                continue;
330            }
331            let manifest_path = path.join(".plugin.toml");
332            if !manifest_path.exists() {
333                continue;
334            }
335            let Ok(bytes) = std::fs::read(&manifest_path) else {
336                continue;
337            };
338            let Ok(text) = String::from_utf8(bytes) else {
339                continue;
340            };
341            let Ok(manifest): Result<PluginManifest, _> = toml::from_str(&text) else {
342                continue;
343            };
344            plugins.push(InstalledPlugin {
345                name: manifest.plugin.name,
346                version: manifest.plugin.version,
347                description: manifest.plugin.description,
348                path,
349            });
350        }
351
352        plugins.sort_by(|a, b| a.name.cmp(&b.name));
353        Ok(plugins)
354    }
355
356    /// Returns all skill directory paths from installed plugins.
357    ///
358    /// # Errors
359    ///
360    /// Returns [`PluginError`] if the plugins directory cannot be read.
361    pub fn collect_skill_dirs(&self) -> Result<Vec<PathBuf>, PluginError> {
362        if !self.plugins_dir.exists() {
363            return Ok(Vec::new());
364        }
365
366        let mut dirs = Vec::new();
367        let plugins = self.list_installed()?;
368        for plugin in &plugins {
369            let manifest_path = plugin.path.join(".plugin.toml");
370            if let Ok(bytes) = std::fs::read(&manifest_path)
371                && let Ok(text) = String::from_utf8(bytes)
372                && let Ok(manifest) = toml::from_str::<PluginManifest>(&text)
373            {
374                for entry in &manifest.skills {
375                    let skill_dir = plugin.path.join(&entry.path);
376                    // Reject traversal: dir must stay within the installed plugin root.
377                    let ok = skill_dir
378                        .canonicalize()
379                        .is_ok_and(|c| c.starts_with(&plugin.path));
380                    if ok {
381                        dirs.push(skill_dir);
382                    } else {
383                        tracing::warn!(
384                            plugin = %plugin.name,
385                            path = %entry.path,
386                            "skipping skill path that escapes plugin root"
387                        );
388                    }
389                }
390            }
391        }
392        Ok(dirs)
393    }
394
395    fn check_skill_conflicts(
396        &self,
397        skill_names: &[String],
398        this_plugin: &str,
399    ) -> Result<(), PluginError> {
400        let bundled = bundled_skill_names();
401
402        // Managed skills: any name in the managed skills dir.
403        let managed_registry = {
404            let dirs: Vec<PathBuf> = if self.managed_skills_dir.exists() {
405                vec![self.managed_skills_dir.clone()]
406            } else {
407                vec![]
408            };
409            SkillRegistry::load(&dirs)
410        };
411        let managed_names: std::collections::HashSet<String> = managed_registry
412            .all_meta()
413            .iter()
414            .map(|m| m.name.clone())
415            .collect();
416
417        // Other installed plugins' skill names.
418        let installed = self.list_installed().unwrap_or_default();
419        let mut other_plugin_skills: std::collections::HashMap<String, String> =
420            std::collections::HashMap::new();
421        for plugin in &installed {
422            if plugin.name == this_plugin {
423                continue;
424            }
425            let manifest_path = plugin.path.join(".plugin.toml");
426            if let Ok(bytes) = std::fs::read(&manifest_path)
427                && let Ok(text) = String::from_utf8(bytes)
428                && let Ok(manifest) = toml::from_str::<PluginManifest>(&text)
429            {
430                let names = collect_skill_names(&plugin.path, &manifest);
431                for name in names {
432                    other_plugin_skills.insert(name, plugin.name.clone());
433                }
434            }
435        }
436
437        for name in skill_names {
438            if bundled.contains(name) {
439                return Err(PluginError::SkillNameConflictWithBundled { name: name.clone() });
440            }
441            if managed_names.contains(name) {
442                return Err(PluginError::SkillNameConflictWithManaged { name: name.clone() });
443            }
444            if let Some(other) = other_plugin_skills.get(name) {
445                return Err(PluginError::SkillNameConflictWithPlugin {
446                    name: name.clone(),
447                    plugin: other.clone(),
448                });
449            }
450        }
451        Ok(())
452    }
453}
454
455/// Validate that a plugin name is a safe identifier: `[a-z0-9][a-z0-9-]*`.
456pub(crate) fn validate_plugin_name(name: &str) -> Result<(), PluginError> {
457    if name.is_empty() {
458        return Err(PluginError::InvalidName {
459            name: name.to_owned(),
460            reason: "name must not be empty".to_owned(),
461        });
462    }
463    if name.contains('/') || name.contains('\\') || name.contains('.') {
464        return Err(PluginError::InvalidName {
465            name: name.to_owned(),
466            reason: "name must not contain path separators or dots".to_owned(),
467        });
468    }
469    if !name
470        .chars()
471        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
472    {
473        return Err(PluginError::InvalidName {
474            name: name.to_owned(),
475            reason: "name must match [a-z0-9][a-z0-9-]*".to_owned(),
476        });
477    }
478    Ok(())
479}
480
481/// Returns a warning message if the plugin's `allowed_commands` overlay
482/// will be silently dropped because the host's base allowlist is empty.
483///
484/// Returns `None` when the overlay is absent or empty, or when the base
485/// allowlist is non-empty (in which case the overlay will narrow it and
486/// the existing `tracing::info!` in `apply_resolved` already signals the
487/// transition at load time).
488fn check_allowed_commands_overlay_effect(
489    config: &toml::Value,
490    base_allowed: &[String],
491    plugin_name: &str,
492) -> Option<String> {
493    let overlay_has_entries = config
494        .as_table()
495        .and_then(|t| t.get("tools"))
496        .and_then(toml::Value::as_table)
497        .and_then(|t| t.get("allowed_commands"))
498        .and_then(toml::Value::as_array)
499        .is_some_and(|arr| arr.iter().any(toml::Value::is_str));
500
501    if !overlay_has_entries {
502        return None;
503    }
504    if !base_allowed.is_empty() {
505        return None;
506    }
507    Some(format!(
508        "plugin {plugin_name:?} declares allowed_commands overlay but the host \
509         has no tools.shell.allowed_commands configured; overlay will have no effect \
510         at load time (tighten-only: plugins cannot widen an empty base allowlist). \
511         Install proceeds. To use this overlay, set tools.shell.allowed_commands \
512         in your base config."
513    ))
514}
515
516/// Validate all keys in the `[config]` overlay are in the tighten-only safelist.
517pub(crate) fn validate_overlay_keys(config: &toml::Value) -> Result<(), PluginError> {
518    let table = match config.as_table() {
519        Some(t) if !t.is_empty() => t,
520        _ => return Ok(()),
521    };
522
523    for (section, inner) in table {
524        let inner_table = inner.as_table().ok_or_else(|| PluginError::UnsafeOverlay {
525            key: section.clone(),
526        })?;
527        for key in inner_table.keys() {
528            let dotted = format!("{section}.{key}");
529            if !CONFIG_SAFELIST.contains(&dotted.as_str()) {
530                return Err(PluginError::UnsafeOverlay { key: dotted });
531            }
532        }
533    }
534    Ok(())
535}
536
537/// Validate that all plugin MCP servers declare commands that are in the allowlist.
538fn validate_mcp_commands(
539    servers: &[PluginMcpServer],
540    allowed: &[String],
541) -> Result<(), PluginError> {
542    for server in servers {
543        if let Some(cmd) = &server.command {
544            // Compare the full command string verbatim — no file_name() fallback.
545            // Basename matching would allow `/tmp/evil/npx` when allowlist contains `npx`.
546            let ok = allowed.iter().any(|a| a == cmd);
547            if !ok {
548                return Err(PluginError::DisallowedMcpCommand {
549                    id: server.id.clone(),
550                    command: cmd.clone(),
551                });
552            }
553        }
554    }
555    Ok(())
556}
557
558/// Collect skill names from a plugin source tree according to the manifest's `[[skills]]` entries.
559///
560/// Each `[[skills]] path` entry points to a single skill directory that directly contains
561/// `SKILL.md`. `SkillRegistry::load` expects *parent* directories, so we pass each entry's
562/// parent and collect only the skills whose directory matches the declared path.
563fn collect_skill_names(root: &Path, manifest: &PluginManifest) -> Vec<String> {
564    // Collect unique parent directories so we can batch-load.
565    let mut parent_dirs: Vec<PathBuf> = manifest
566        .skills
567        .iter()
568        .filter_map(|e| {
569            let p = root.join(&e.path);
570            p.parent().map(Path::to_path_buf)
571        })
572        .collect();
573    parent_dirs.sort();
574    parent_dirs.dedup();
575
576    if parent_dirs.is_empty() {
577        return Vec::new();
578    }
579
580    // Allowed skill directories (resolved absolute paths).
581    let allowed: std::collections::HashSet<PathBuf> =
582        manifest.skills.iter().map(|e| root.join(&e.path)).collect();
583
584    let registry = SkillRegistry::load(&parent_dirs);
585    registry
586        .all_meta()
587        .iter()
588        .filter(|m| allowed.contains(&m.skill_dir))
589        .map(|m| m.name.clone())
590        .collect()
591}
592
593/// Recursively copy `src` directory to `dst`, creating `dst` if needed.
594fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), PluginError> {
595    if dst.exists() {
596        std::fs::remove_dir_all(dst).map_err(|e| PluginError::Io {
597            path: dst.to_path_buf(),
598            source: e,
599        })?;
600    }
601    std::fs::create_dir_all(dst).map_err(|e| PluginError::Io {
602        path: dst.to_path_buf(),
603        source: e,
604    })?;
605
606    for entry in WalkDir::new(src).min_depth(1) {
607        let entry = entry.map_err(|e| PluginError::Io {
608            path: src.to_path_buf(),
609            source: std::io::Error::other(e.to_string()),
610        })?;
611        let rel = entry
612            .path()
613            .strip_prefix(src)
614            .expect("walkdir yields paths under src");
615        let target = dst.join(rel);
616        if entry.file_type().is_dir() {
617            std::fs::create_dir_all(&target).map_err(|e| PluginError::Io {
618                path: target,
619                source: e,
620            })?;
621        } else {
622            if let Some(parent) = target.parent() {
623                std::fs::create_dir_all(parent).map_err(|e| PluginError::Io {
624                    path: parent.to_path_buf(),
625                    source: e,
626                })?;
627            }
628            std::fs::copy(entry.path(), &target).map_err(|e| PluginError::Io {
629                path: target,
630                source: e,
631            })?;
632        }
633    }
634    Ok(())
635}
636
637/// Walk the plugin tree and delete every `.bundled` marker file.
638///
639/// Plugin skills are third-party and must never be treated as bundled by the scanner.
640fn strip_bundled_markers(root: &Path) {
641    for entry in WalkDir::new(root).into_iter().flatten() {
642        if entry.file_type().is_file() && entry.file_name().to_str() == Some(".bundled") {
643            let _ = std::fs::remove_file(entry.path());
644        }
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    fn write_plugin(dir: &Path, name: &str, manifest_toml: &str, skills: &[(&str, &str)]) {
653        std::fs::create_dir_all(dir).unwrap();
654        std::fs::write(dir.join("plugin.toml"), manifest_toml).unwrap();
655        for (skill_name, body) in skills {
656            let skill_dir = dir.join("skills").join(skill_name);
657            std::fs::create_dir_all(&skill_dir).unwrap();
658            std::fs::write(
659                skill_dir.join("SKILL.md"),
660                format!("---\nname: {skill_name}\ndescription: test\n---\n{body}"),
661            )
662            .unwrap();
663            // Write a .bundled marker to test stripping.
664            std::fs::write(skill_dir.join(".bundled"), "").unwrap();
665        }
666        let _ = name;
667    }
668
669    fn simple_manifest(name: &str, skill: &str) -> String {
670        format!(
671            r#"[plugin]
672name = "{name}"
673version = "0.1.0"
674description = "test plugin"
675
676[[skills]]
677path = "skills/{skill}"
678"#
679        )
680    }
681
682    #[test]
683    fn add_and_list_plugin() {
684        let tmp = tempfile::tempdir().unwrap();
685        let source = tmp.path().join("source");
686        write_plugin(
687            &source,
688            "test-plugin",
689            &simple_manifest("test-plugin", "my-skill"),
690            &[("my-skill", "Do stuff")],
691        );
692
693        let plugins_dir = tmp.path().join("plugins");
694        let managed_dir = tmp.path().join("managed");
695        let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
696
697        let result = mgr.add(source.to_str().unwrap()).unwrap();
698        assert_eq!(result.name, "test-plugin");
699        assert!(result.installed_skills.contains(&"my-skill".to_owned()));
700
701        let installed = mgr.list_installed().unwrap();
702        assert_eq!(installed.len(), 1);
703        assert_eq!(installed[0].name, "test-plugin");
704    }
705
706    #[test]
707    fn bundled_markers_stripped_on_install() {
708        let tmp = tempfile::tempdir().unwrap();
709        let source = tmp.path().join("source");
710        write_plugin(
711            &source,
712            "strip-test",
713            &simple_manifest("strip-test", "my-skill"),
714            &[("my-skill", "Body")],
715        );
716
717        let plugins_dir = tmp.path().join("plugins");
718        let managed_dir = tmp.path().join("managed");
719        let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
720        mgr.add(source.to_str().unwrap()).unwrap();
721
722        // .bundled markers must not exist in the installed tree.
723        let has_bundled = WalkDir::new(&plugins_dir)
724            .into_iter()
725            .flatten()
726            .any(|e| e.file_name().to_str() == Some(".bundled"));
727        assert!(!has_bundled, ".bundled markers were not stripped");
728    }
729
730    #[test]
731    fn mcp_disallowed_command_fails_install() {
732        let tmp = tempfile::tempdir().unwrap();
733        let source = tmp.path().join("source");
734        let manifest = r#"[plugin]
735name = "mcp-test"
736version = "0.1.0"
737description = "test"
738
739[[mcp.servers]]
740id = "bad-server"
741command = "dangerous-binary"
742"#;
743        write_plugin(&source, "mcp-test", manifest, &[]);
744
745        let plugins_dir = tmp.path().join("plugins");
746        let managed_dir = tmp.path().join("managed");
747        let mgr = PluginManager::new(plugins_dir, managed_dir, vec!["npx".to_owned()], vec![]);
748
749        let err = mgr.add(source.to_str().unwrap()).unwrap_err();
750        assert!(matches!(err, PluginError::DisallowedMcpCommand { .. }));
751    }
752
753    #[test]
754    fn unsafe_config_overlay_fails_install() {
755        let tmp = tempfile::tempdir().unwrap();
756        let source = tmp.path().join("source");
757        let manifest = r#"[plugin]
758name = "overlay-test"
759version = "0.1.0"
760description = "test"
761
762[config.llm]
763model = "evil"
764"#;
765        write_plugin(&source, "overlay-test", manifest, &[]);
766
767        let plugins_dir = tmp.path().join("plugins");
768        let managed_dir = tmp.path().join("managed");
769        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
770
771        let err = mgr.add(source.to_str().unwrap()).unwrap_err();
772        assert!(matches!(err, PluginError::UnsafeOverlay { .. }));
773    }
774
775    #[test]
776    fn max_active_skills_overlay_is_rejected() {
777        let tmp = tempfile::tempdir().unwrap();
778        let source = tmp.path().join("source");
779        let manifest = r#"[plugin]
780name = "max-skills-test"
781version = "0.1.0"
782description = "test"
783
784[config.skills]
785max_active_skills = 10
786"#;
787        write_plugin(&source, "max-skills-test", manifest, &[]);
788
789        let plugins_dir = tmp.path().join("plugins");
790        let managed_dir = tmp.path().join("managed");
791        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
792
793        let err = mgr.add(source.to_str().unwrap()).unwrap_err();
794        assert!(matches!(err, PluginError::UnsafeOverlay { .. }));
795    }
796
797    #[test]
798    fn safe_config_overlay_is_accepted() {
799        let tmp = tempfile::tempdir().unwrap();
800        let source = tmp.path().join("source");
801        let manifest = r#"[plugin]
802name = "safe-overlay"
803version = "0.1.0"
804description = "test"
805
806[config.skills]
807disambiguation_threshold = 0.05
808
809[config.tools]
810blocked_commands = ["rm -rf"]
811"#;
812        write_plugin(&source, "safe-overlay", manifest, &[]);
813
814        let plugins_dir = tmp.path().join("plugins");
815        let managed_dir = tmp.path().join("managed");
816        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
817        let result = mgr.add(source.to_str().unwrap()).unwrap();
818        assert_eq!(result.name, "safe-overlay");
819    }
820
821    #[test]
822    fn remove_plugin() {
823        let tmp = tempfile::tempdir().unwrap();
824        let source = tmp.path().join("source");
825        write_plugin(
826            &source,
827            "removable",
828            &simple_manifest("removable", "my-skill"),
829            &[("my-skill", "Body")],
830        );
831
832        let plugins_dir = tmp.path().join("plugins");
833        let managed_dir = tmp.path().join("managed");
834        let mgr = PluginManager::new(plugins_dir.clone(), managed_dir, vec![], vec![]);
835        mgr.add(source.to_str().unwrap()).unwrap();
836
837        let result = mgr.remove("removable").unwrap();
838        assert!(result.removed_skills.contains(&"my-skill".to_owned()));
839
840        let installed = mgr.list_installed().unwrap();
841        assert!(installed.is_empty());
842    }
843
844    #[test]
845    fn remove_nonexistent_plugin_returns_not_found() {
846        let tmp = tempfile::tempdir().unwrap();
847        let plugins_dir = tmp.path().join("plugins");
848        let mgr = PluginManager::new(plugins_dir, tmp.path().to_path_buf(), vec![], vec![]);
849        let err = mgr.remove("no-such-plugin").unwrap_err();
850        assert!(matches!(err, PluginError::NotFound { .. }));
851    }
852
853    #[test]
854    fn invalid_plugin_name_with_slash_rejected() {
855        let err = validate_plugin_name("foo/bar").unwrap_err();
856        assert!(matches!(err, PluginError::InvalidName { .. }));
857    }
858
859    #[test]
860    fn plugin_name_with_uppercase_rejected() {
861        let err = validate_plugin_name("FooBar").unwrap_err();
862        assert!(matches!(err, PluginError::InvalidName { .. }));
863    }
864
865    #[test]
866    fn valid_plugin_names_accepted() {
867        assert!(validate_plugin_name("foo").is_ok());
868        assert!(validate_plugin_name("foo-bar").is_ok());
869        assert!(validate_plugin_name("foo123").is_ok());
870    }
871
872    #[test]
873    fn bundled_skill_conflict_detected() {
874        let tmp = tempfile::tempdir().unwrap();
875        let source = tmp.path().join("source");
876
877        // Find a real bundled skill name to trigger conflict.
878        let bundled = bundled_skill_names();
879        if bundled.is_empty() {
880            // No bundled skills compiled in; skip.
881            return;
882        }
883        let conflict_name = &bundled[0];
884
885        let manifest = format!(
886            r#"[plugin]
887name = "conflict-test"
888version = "0.1.0"
889description = "test"
890
891[[skills]]
892path = "skills/{conflict_name}"
893"#
894        );
895        write_plugin(
896            &source,
897            "conflict-test",
898            &manifest,
899            &[(conflict_name, "body")],
900        );
901
902        let plugins_dir = tmp.path().join("plugins");
903        let managed_dir = tmp.path().join("managed");
904        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
905
906        let err = mgr.add(source.to_str().unwrap()).unwrap_err();
907        assert!(matches!(
908            err,
909            PluginError::SkillNameConflictWithBundled { .. }
910        ));
911    }
912
913    #[test]
914    fn path_traversal_in_skill_path_rejected() {
915        let tmp = tempfile::tempdir().unwrap();
916        let source = tmp.path().join("source");
917        let manifest = r#"[plugin]
918name = "traversal-test"
919version = "0.1.0"
920description = "test"
921
922[[skills]]
923path = "../../../etc/passwd"
924"#;
925        std::fs::create_dir_all(&source).unwrap();
926        std::fs::write(source.join("plugin.toml"), manifest).unwrap();
927
928        let plugins_dir = tmp.path().join("plugins");
929        let managed_dir = tmp.path().join("managed");
930        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
931
932        let err = mgr.add(source.to_str().unwrap()).unwrap_err();
933        assert!(
934            matches!(err, PluginError::InvalidSource { .. }),
935            "expected InvalidSource for path traversal, got {err:?}"
936        );
937    }
938
939    #[test]
940    fn mcp_basename_bypass_rejected() {
941        let tmp = tempfile::tempdir().unwrap();
942        let source = tmp.path().join("source");
943        // allowed_commands = ["npx"] but plugin declares full path "/tmp/evil/npx".
944        // Verbatim match must reject this; the old file_name() fallback would have passed it.
945        let manifest = r#"[plugin]
946name = "basename-bypass"
947version = "0.1.0"
948description = "test"
949
950[[mcp.servers]]
951id = "evil"
952command = "/tmp/evil/npx"
953"#;
954        write_plugin(&source, "basename-bypass", manifest, &[]);
955
956        let plugins_dir = tmp.path().join("plugins");
957        let managed_dir = tmp.path().join("managed");
958        let mgr = PluginManager::new(plugins_dir, managed_dir, vec!["npx".to_owned()], vec![]);
959
960        let err = mgr.add(source.to_str().unwrap()).unwrap_err();
961        assert!(
962            matches!(err, PluginError::DisallowedMcpCommand { .. }),
963            "expected DisallowedMcpCommand for basename bypass, got {err:?}"
964        );
965    }
966
967    #[test]
968    fn managed_skill_conflict_detected() {
969        let tmp = tempfile::tempdir().unwrap();
970        let managed_dir = tmp.path().join("managed");
971
972        // Create a managed skill named "my-skill".
973        let managed_skill = managed_dir.join("my-skill");
974        std::fs::create_dir_all(&managed_skill).unwrap();
975        std::fs::write(
976            managed_skill.join("SKILL.md"),
977            "---\nname: my-skill\ndescription: managed\n---\nbody",
978        )
979        .unwrap();
980
981        // Plugin tries to install a skill with the same name.
982        let source = tmp.path().join("source");
983        write_plugin(
984            &source,
985            "conflict-managed",
986            &simple_manifest("conflict-managed", "my-skill"),
987            &[("my-skill", "body")],
988        );
989
990        let plugins_dir = tmp.path().join("plugins");
991        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
992
993        let err = mgr.add(source.to_str().unwrap()).unwrap_err();
994        assert!(
995            matches!(err, PluginError::SkillNameConflictWithManaged { .. }),
996            "expected SkillNameConflictWithManaged, got {err:?}"
997        );
998    }
999
1000    #[test]
1001    fn cross_plugin_skill_conflict_detected() {
1002        let tmp = tempfile::tempdir().unwrap();
1003        let plugins_dir = tmp.path().join("plugins");
1004        let managed_dir = tmp.path().join("managed");
1005        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
1006
1007        // Install first plugin with "shared-skill".
1008        let source_a = tmp.path().join("source_a");
1009        write_plugin(
1010            &source_a,
1011            "plugin-a",
1012            &simple_manifest("plugin-a", "shared-skill"),
1013            &[("shared-skill", "body")],
1014        );
1015        mgr.add(source_a.to_str().unwrap()).unwrap();
1016
1017        // Install second plugin with the same skill name — must conflict.
1018        let source_b = tmp.path().join("source_b");
1019        write_plugin(
1020            &source_b,
1021            "plugin-b",
1022            &simple_manifest("plugin-b", "shared-skill"),
1023            &[("shared-skill", "body")],
1024        );
1025        let err = mgr.add(source_b.to_str().unwrap()).unwrap_err();
1026        assert!(
1027            matches!(err, PluginError::SkillNameConflictWithPlugin { .. }),
1028            "expected SkillNameConflictWithPlugin, got {err:?}"
1029        );
1030    }
1031
1032    #[test]
1033    fn allowed_commands_overlay_with_empty_base_warns() {
1034        let tmp = tempfile::tempdir().unwrap();
1035        let source = tmp.path().join("source");
1036        let manifest = r#"[plugin]
1037name = "warn-test"
1038version = "0.1.0"
1039description = "test"
1040
1041[config.tools]
1042allowed_commands = ["curl", "git"]
1043"#;
1044        write_plugin(&source, "warn-test", manifest, &[]);
1045
1046        let plugins_dir = tmp.path().join("plugins");
1047        let managed_dir = tmp.path().join("managed");
1048        // base_allowed_commands is empty — overlay will have no effect
1049        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
1050
1051        let result = mgr.add(source.to_str().unwrap()).unwrap();
1052        assert_eq!(result.warnings.len(), 1);
1053        let msg = &result.warnings[0];
1054        assert!(
1055            msg.contains("warn-test"),
1056            "warning must contain plugin name"
1057        );
1058        assert!(
1059            msg.contains("allowed_commands"),
1060            "warning must mention allowed_commands"
1061        );
1062        assert!(msg.is_ascii(), "warning message must be ASCII-only");
1063    }
1064
1065    #[test]
1066    fn allowed_commands_overlay_with_non_empty_base_no_warn() {
1067        let tmp = tempfile::tempdir().unwrap();
1068        let source = tmp.path().join("source");
1069        let manifest = r#"[plugin]
1070name = "no-warn-test"
1071version = "0.1.0"
1072description = "test"
1073
1074[config.tools]
1075allowed_commands = ["curl"]
1076"#;
1077        write_plugin(&source, "no-warn-test", manifest, &[]);
1078
1079        let plugins_dir = tmp.path().join("plugins");
1080        let managed_dir = tmp.path().join("managed");
1081        // base_allowed_commands is non-empty — overlay narrows correctly, no warning
1082        let mgr = PluginManager::new(
1083            plugins_dir,
1084            managed_dir,
1085            vec![],
1086            vec!["curl".to_owned(), "git".to_owned()],
1087        );
1088
1089        let result = mgr.add(source.to_str().unwrap()).unwrap();
1090        assert!(result.warnings.is_empty());
1091    }
1092
1093    #[test]
1094    fn empty_allowed_commands_array_no_warn() {
1095        let tmp = tempfile::tempdir().unwrap();
1096        let source = tmp.path().join("source");
1097        let manifest = r#"[plugin]
1098name = "empty-overlay"
1099version = "0.1.0"
1100description = "test"
1101
1102[config.tools]
1103allowed_commands = []
1104"#;
1105        write_plugin(&source, "empty-overlay", manifest, &[]);
1106
1107        let plugins_dir = tmp.path().join("plugins");
1108        let managed_dir = tmp.path().join("managed");
1109        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
1110
1111        let result = mgr.add(source.to_str().unwrap()).unwrap();
1112        assert!(result.warnings.is_empty());
1113    }
1114
1115    #[test]
1116    fn list_installed_ignores_non_directory_entries() {
1117        let tmp = tempfile::tempdir().unwrap();
1118        let plugins_dir = tmp.path().to_path_buf();
1119
1120        // Stray files that must not be treated as installed plugins.
1121        std::fs::write(plugins_dir.join(".plugin-integrity.toml"), b"plugins = {}").unwrap();
1122        std::fs::write(plugins_dir.join("README.txt"), b"docs").unwrap();
1123
1124        let managed_dir = tmp.path().join("managed");
1125        let mgr = PluginManager::new(plugins_dir, managed_dir, vec![], vec![]);
1126        assert!(
1127            mgr.list_installed().unwrap().is_empty(),
1128            "non-directory entries inside plugins_dir must not be surfaced as installed plugins"
1129        );
1130    }
1131}