1use 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
16const CONFIG_SAFELIST: &[&str] = &[
19 "tools.blocked_commands",
20 "tools.allowed_commands",
21 "skills.disambiguation_threshold",
22];
23
24#[derive(Debug)]
26pub struct AddResult {
27 pub name: String,
29 pub plugin_root: PathBuf,
35 pub installed_skills: Vec<String>,
37 pub mcp_server_ids: Vec<String>,
39 pub warnings: Vec<String>,
48}
49
50#[derive(Debug, Default)]
52pub struct RemoveResult {
53 pub removed_skills: Vec<String>,
55 pub removed_mcp_ids: Vec<String>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct InstalledPlugin {
62 pub name: String,
64 pub version: String,
66 pub description: String,
68 pub path: PathBuf,
70}
71
72pub struct PluginManager {
77 plugins_dir: PathBuf,
79 managed_skills_dir: PathBuf,
81 mcp_allowed_commands: Vec<String>,
83 base_allowed_commands: Vec<String>,
87 integrity_registry_path: PathBuf,
89}
90
91impl PluginManager {
92 #[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 #[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 #[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 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(&manifest.plugin.name)?;
167
168 for entry in &manifest.skills {
170 let skill_path = source_path.join(&entry.path);
171 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 if !skill_path.join("SKILL.md").is_file() {
187 return Err(PluginError::SkillEntryMissing { path: skill_path });
188 }
189 }
190
191 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_commands(&manifest.mcp.servers, &self.mcp_allowed_commands)?;
206
207 let skill_names = collect_skill_names(&source_path, &manifest);
209
210 self.check_skill_conflicts(&skill_names, &manifest.plugin.name)?;
212
213 let dest = self.plugins_dir.join(&manifest.plugin.name);
214
215 copy_dir_all(&source_path, &dest)?;
217
218 strip_bundled_markers(&dest);
220
221 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 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 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 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 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 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 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 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 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
455pub(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
481fn 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
516pub(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
537fn 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 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
558fn collect_skill_names(root: &Path, manifest: &PluginManifest) -> Vec<String> {
564 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 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
593fn 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
637fn 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 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 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 let bundled = bundled_skill_names();
879 if bundled.is_empty() {
880 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 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 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 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 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 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 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 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 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}