1use std::collections::BTreeSet;
19use std::path::Path;
20
21use zeph_config::Config;
22
23use crate::PluginError;
24use crate::manager::{validate_overlay_keys, validate_plugin_name};
25use crate::manifest::PluginManifest;
26
27#[derive(Debug, Clone, Default)]
32pub struct ResolvedOverlay {
33 pub blocked_commands_add: Vec<String>,
35
36 pub allowed_commands_intersect_accum: Option<BTreeSet<String>>,
40
41 pub disambiguation_threshold_max: Option<f32>,
44
45 pub source_plugins: Vec<String>,
48
49 pub skipped_plugins: Vec<String>,
51}
52
53pub fn apply_plugin_config_overlays(
68 config: &mut Config,
69 plugins_dir: &Path,
70) -> Result<ResolvedOverlay, PluginError> {
71 let integrity_registry_path = crate::integrity::IntegrityRegistry::default_path();
72 let resolved = resolve_overlays(plugins_dir, &integrity_registry_path)?;
73 apply_resolved(config, &resolved);
74 Ok(resolved)
75}
76
77#[cfg(test)]
81pub(crate) fn apply_plugin_config_overlays_with_registry(
82 config: &mut Config,
83 plugins_dir: &Path,
84 integrity_registry_path: &Path,
85) -> Result<ResolvedOverlay, PluginError> {
86 let resolved = resolve_overlays(plugins_dir, integrity_registry_path)?;
87 apply_resolved(config, &resolved);
88 Ok(resolved)
89}
90
91fn resolve_overlays(
92 plugins_dir: &Path,
93 integrity_registry_path: &Path,
94) -> Result<ResolvedOverlay, PluginError> {
95 let mut out = ResolvedOverlay::default();
96
97 if !plugins_dir.exists() {
98 return Ok(out);
99 }
100
101 let registry = crate::integrity::IntegrityRegistry::load(integrity_registry_path);
102
103 let mut entries: Vec<std::fs::DirEntry> = std::fs::read_dir(plugins_dir)
106 .map_err(|e| PluginError::Io {
107 path: plugins_dir.to_path_buf(),
108 source: e,
109 })?
110 .flatten()
111 .collect();
112 entries.sort_by_key(std::fs::DirEntry::file_name);
113
114 let mut blocked_set: BTreeSet<String> = BTreeSet::new();
115 let mut allowed_accum: Option<BTreeSet<String>> = None;
116 let mut threshold: Option<f32> = None;
117
118 for entry in entries {
119 process_plugin_entry(
120 &entry.path(),
121 ®istry,
122 &mut out,
123 &mut blocked_set,
124 &mut allowed_accum,
125 &mut threshold,
126 );
127 }
128
129 out.blocked_commands_add = blocked_set.into_iter().collect();
130 out.allowed_commands_intersect_accum = allowed_accum;
131 out.disambiguation_threshold_max = threshold;
132 Ok(out)
133}
134
135fn process_plugin_entry(
136 path: &std::path::Path,
137 registry: &crate::integrity::IntegrityRegistry,
138 out: &mut ResolvedOverlay,
139 blocked_set: &mut BTreeSet<String>,
140 allowed_accum: &mut Option<BTreeSet<String>>,
141 threshold: &mut Option<f32>,
142) {
143 let md = match std::fs::symlink_metadata(path) {
146 Ok(m) => m,
147 Err(e) => {
148 tracing::debug!(path = %path.display(), error = %e, "stat failed; skipping");
149 return;
150 }
151 };
152 if !md.is_dir() || md.file_type().is_symlink() {
153 return;
154 }
155
156 let manifest_path = path.join(".plugin.toml");
157 let bytes = match std::fs::read(&manifest_path) {
158 Ok(b) => b,
159 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
160 Err(e) => {
161 tracing::warn!(path = %manifest_path.display(), kind = ?e.kind(), "cannot read .plugin.toml; skipping");
162 return;
163 }
164 };
165
166 let Ok(text) = String::from_utf8(bytes) else {
167 let name = path
168 .file_name()
169 .and_then(|s| s.to_str())
170 .unwrap_or("?")
171 .to_owned();
172 out.skipped_plugins
173 .push(format!("{name}: .plugin.toml is not valid UTF-8"));
174 return;
175 };
176 let manifest: PluginManifest = {
177 let name = path
178 .file_name()
179 .and_then(|s| s.to_str())
180 .unwrap_or("?")
181 .to_owned();
182 match toml::from_str(&text) {
183 Ok(m) => m,
184 Err(e) => {
185 out.skipped_plugins
186 .push(format!("{name}: malformed .plugin.toml ({e})"));
187 return;
188 }
189 }
190 };
191
192 let plugin_dir_name = path
194 .file_name()
195 .and_then(|s| s.to_str())
196 .unwrap_or("?")
197 .to_owned();
198 match registry.verify(&plugin_dir_name, &manifest_path) {
199 Ok(crate::integrity::VerifyResult::Match | crate::integrity::VerifyResult::Missing) => {}
200 Ok(crate::integrity::VerifyResult::Mismatch { expected, actual }) => {
201 out.skipped_plugins.push(format!(
202 "{plugin_dir_name}: integrity mismatch (expected {expected}, got {actual})"
203 ));
204 return;
205 }
206 Err(e) => {
207 out.skipped_plugins
208 .push(format!("{plugin_dir_name}: integrity check failed: {e}"));
209 return;
210 }
211 }
212
213 if let Err(e) = validate_plugin_name(&manifest.plugin.name) {
216 tracing::warn!(
217 path = %path.display(),
218 "plugin overlay skipped: invalid plugin name ({e})"
219 );
220 return;
221 }
222
223 if let Err(e) = validate_overlay_keys(&manifest.config) {
225 out.skipped_plugins.push(format!(
226 "{}: overlay rejected by safelist ({e})",
227 manifest.plugin.name
228 ));
229 return;
230 }
231
232 let contributed = merge_manifest_overlay(&manifest, out, blocked_set, allowed_accum, threshold);
233 if contributed {
234 out.source_plugins.push(manifest.plugin.name);
235 }
236}
237
238fn merge_manifest_overlay(
239 manifest: &PluginManifest,
240 out: &mut ResolvedOverlay,
241 blocked_set: &mut BTreeSet<String>,
242 allowed_accum: &mut Option<BTreeSet<String>>,
243 threshold: &mut Option<f32>,
244) -> bool {
245 let Some(cfg_table) = manifest.config.as_table() else {
246 return false;
247 };
248 let mut contributed = false;
249
250 if let Some(tools) = cfg_table.get("tools").and_then(toml::Value::as_table) {
251 if let Some(arr) = tools
252 .get("blocked_commands")
253 .and_then(toml::Value::as_array)
254 {
255 for v in arr {
256 if let Some(s) = v.as_str() {
257 blocked_set.insert(s.to_owned());
258 contributed = true;
259 }
260 }
261 }
262 if let Some(arr) = tools
263 .get("allowed_commands")
264 .and_then(toml::Value::as_array)
265 {
266 let plugin_allowed: BTreeSet<String> = arr
267 .iter()
268 .filter_map(|v| v.as_str().map(str::to_owned))
269 .collect();
270 *allowed_accum = Some(match allowed_accum.take() {
271 None => plugin_allowed,
272 Some(prev) => prev.intersection(&plugin_allowed).cloned().collect(),
273 });
274 contributed = true;
275 }
276 }
277
278 if let Some(skills) = cfg_table.get("skills").and_then(toml::Value::as_table)
279 && let Some(v) = skills.get("disambiguation_threshold")
280 {
281 #[allow(clippy::cast_precision_loss)]
283 let raw = v.as_float().or_else(|| v.as_integer().map(|i| i as f64));
284 match raw {
285 Some(f) if (0.0_f64..=1.0_f64).contains(&f) => {
286 #[allow(clippy::cast_possible_truncation)]
287 let f32_val = f as f32;
288 *threshold = Some(threshold.map_or(f32_val, |cur: f32| cur.max(f32_val)));
289 contributed = true;
290 }
291 Some(f) => {
292 out.skipped_plugins.push(format!(
293 "{}: disambiguation_threshold={f} out of [0,1]; ignored",
294 manifest.plugin.name
295 ));
296 }
297 None => {
298 out.skipped_plugins.push(format!(
299 "{}: disambiguation_threshold has non-numeric value; ignored",
300 manifest.plugin.name
301 ));
302 }
303 }
304 }
305
306 contributed
307}
308
309fn apply_resolved(config: &mut Config, r: &ResolvedOverlay) {
310 let mut seen: BTreeSet<String> = config
312 .tools
313 .shell
314 .blocked_commands
315 .iter()
316 .cloned()
317 .collect();
318 for cmd in &r.blocked_commands_add {
319 if seen.insert(cmd.clone()) {
320 config.tools.shell.blocked_commands.push(cmd.clone());
321 }
322 }
323
324 if let Some(ref plugin_allowed) = r.allowed_commands_intersect_accum {
331 if config.tools.shell.allowed_commands.is_empty() {
332 tracing::debug!(
333 "plugin overlay supplied allowed_commands but base is empty; \
334 ignoring (tighten-only — plugins cannot widen the allowlist)"
335 );
336 } else {
337 let base: BTreeSet<String> = config
338 .tools
339 .shell
340 .allowed_commands
341 .iter()
342 .cloned()
343 .collect();
344 let narrowed: Vec<String> = base.intersection(plugin_allowed).cloned().collect();
345 let narrowed_count = narrowed.len();
346 let prev_count = config.tools.shell.allowed_commands.len();
347 config.tools.shell.allowed_commands = narrowed;
348 if narrowed_count < prev_count {
349 tracing::info!(
350 from = prev_count,
351 to = narrowed_count,
352 "plugin overlay narrowed tools.shell.allowed_commands"
353 );
354 }
355 }
356 }
357
358 if let Some(t) = r.disambiguation_threshold_max
360 && t > config.skills.disambiguation_threshold
361 {
362 tracing::info!(
363 from = config.skills.disambiguation_threshold,
364 to = t,
365 "plugin overlay raised skills.disambiguation_threshold"
366 );
367 config.skills.disambiguation_threshold = t;
368 }
369
370 if !r.source_plugins.is_empty() {
371 tracing::info!(
372 plugins = ?r.source_plugins,
373 blocked_added = r.blocked_commands_add.len(),
374 threshold = ?r.disambiguation_threshold_max,
375 "applied plugin config overlays"
376 );
377 }
378 for s in &r.skipped_plugins {
379 tracing::warn!("plugin overlay skipped: {s}");
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use std::fs;
387 use tempfile::TempDir;
388 use zeph_config::Config;
389
390 fn write_plugin_overlay(plugins_dir: &Path, name: &str, overlay_toml: &str) {
391 let entry_dir = plugins_dir.join(name);
392 fs::create_dir_all(&entry_dir).unwrap();
393 fs::write(
394 entry_dir.join(".plugin.toml"),
395 format!("[plugin]\nname = \"{name}\"\nversion = \"0.1.0\"\n\n{overlay_toml}"),
396 )
397 .unwrap();
398 }
399
400 fn base_config() -> Config {
401 Config::default()
402 }
403
404 #[test]
406 fn empty_plugins_dir_is_noop() {
407 let dir = TempDir::new().unwrap();
408 let absent = dir.path().join("no-such-dir");
409 let mut cfg = base_config();
410 let overlay = apply_plugin_config_overlays(&mut cfg, &absent).unwrap();
411 assert!(overlay.source_plugins.is_empty());
412 assert!(overlay.skipped_plugins.is_empty());
413 assert!(cfg.tools.shell.blocked_commands.is_empty());
414 }
415
416 #[test]
418 fn plugins_dir_without_manifests_is_noop() {
419 let dir = TempDir::new().unwrap();
420 fs::create_dir(dir.path().join("myplugin")).unwrap();
421 let mut cfg = base_config();
422 let overlay = apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
423 assert!(overlay.source_plugins.is_empty());
424 assert!(cfg.tools.shell.blocked_commands.is_empty());
425 }
426
427 #[test]
429 fn single_plugin_blocked_commands_union() {
430 let dir = TempDir::new().unwrap();
431 write_plugin_overlay(
432 dir.path(),
433 "hardening",
434 "[config.tools]\nblocked_commands = [\"sudo\"]",
435 );
436 let mut cfg = base_config();
437 cfg.tools.shell.blocked_commands = vec!["rm -rf".to_owned()];
438 apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
439 assert!(
440 cfg.tools
441 .shell
442 .blocked_commands
443 .contains(&"rm -rf".to_owned())
444 );
445 assert!(
446 cfg.tools
447 .shell
448 .blocked_commands
449 .contains(&"sudo".to_owned())
450 );
451 }
452
453 #[test]
455 fn multi_plugin_blocked_commands_dedup() {
456 let dir = TempDir::new().unwrap();
457 write_plugin_overlay(
458 dir.path(),
459 "p1",
460 "[config.tools]\nblocked_commands = [\"sudo\"]",
461 );
462 write_plugin_overlay(
463 dir.path(),
464 "p2",
465 "[config.tools]\nblocked_commands = [\"sudo\"]",
466 );
467 let mut cfg = base_config();
468 apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
469 let count = cfg
470 .tools
471 .shell
472 .blocked_commands
473 .iter()
474 .filter(|c| c.as_str() == "sudo")
475 .count();
476 assert_eq!(count, 1);
477 }
478
479 #[test]
481 fn non_empty_base_allowed_commands_narrowed() {
482 let dir = TempDir::new().unwrap();
483 write_plugin_overlay(
484 dir.path(),
485 "narrow",
486 "[config.tools]\nallowed_commands = [\"a\", \"b\"]",
487 );
488 let mut cfg = base_config();
489 cfg.tools.shell.allowed_commands = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
490 apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
491 let mut result = cfg.tools.shell.allowed_commands.clone();
492 result.sort();
493 assert_eq!(result, vec!["a".to_owned(), "b".to_owned()]);
494 }
495
496 #[test]
498 fn multi_plugin_allowed_commands_intersection() {
499 let dir = TempDir::new().unwrap();
500 write_plugin_overlay(
501 dir.path(),
502 "p1",
503 "[config.tools]\nallowed_commands = [\"a\", \"b\"]",
504 );
505 write_plugin_overlay(
506 dir.path(),
507 "p2",
508 "[config.tools]\nallowed_commands = [\"b\", \"c\"]",
509 );
510 let mut cfg = base_config();
511 cfg.tools.shell.allowed_commands = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
512 apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
513 assert_eq!(cfg.tools.shell.allowed_commands, vec!["b".to_owned()]);
514 }
515
516 #[test]
518 fn empty_base_allowed_commands_overlay_ignored() {
519 let dir = TempDir::new().unwrap();
520 write_plugin_overlay(
521 dir.path(),
522 "widener",
523 "[config.tools]\nallowed_commands = [\"curl\"]",
524 );
525 let mut cfg = base_config();
526 assert!(cfg.tools.shell.allowed_commands.is_empty());
527 apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
528 assert!(cfg.tools.shell.allowed_commands.is_empty());
530 }
531
532 #[test]
534 fn disambiguation_threshold_max_wins() {
535 let dir = TempDir::new().unwrap();
536 write_plugin_overlay(
537 dir.path(),
538 "strict",
539 "[config.skills]\ndisambiguation_threshold = 0.25",
540 );
541 let mut cfg = base_config();
542 cfg.skills.disambiguation_threshold = 0.20;
543 apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
544 assert!((cfg.skills.disambiguation_threshold - 0.25_f32).abs() < 1e-5);
545 }
546
547 #[test]
549 fn disambiguation_threshold_lower_ignored() {
550 let dir = TempDir::new().unwrap();
551 write_plugin_overlay(
552 dir.path(),
553 "loose",
554 "[config.skills]\ndisambiguation_threshold = 0.20",
555 );
556 let mut cfg = base_config();
557 cfg.skills.disambiguation_threshold = 0.30;
558 apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
559 assert!((cfg.skills.disambiguation_threshold - 0.30_f32).abs() < 1e-5);
560 }
561
562 #[test]
564 fn threshold_out_of_range_skipped_with_warning() {
565 let dir = TempDir::new().unwrap();
566 write_plugin_overlay(
567 dir.path(),
568 "bad",
569 "[config.skills]\ndisambiguation_threshold = 1.5",
570 );
571 let mut cfg = base_config();
572 let orig = cfg.skills.disambiguation_threshold;
573 let overlay = apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
574 assert!((cfg.skills.disambiguation_threshold - orig).abs() < 1e-5);
575 assert!(
576 overlay
577 .skipped_plugins
578 .iter()
579 .any(|s| s.contains("bad") && s.contains("1.5"))
580 );
581 }
582
583 #[test]
585 fn threshold_boundary_one_accepted() {
586 let dir = TempDir::new().unwrap();
587 write_plugin_overlay(
588 dir.path(),
589 "max-strict",
590 "[config.skills]\ndisambiguation_threshold = 1.0",
591 );
592 let mut cfg = base_config();
593 cfg.skills.disambiguation_threshold = 0.5;
594 apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
595 assert!((cfg.skills.disambiguation_threshold - 1.0_f32).abs() < 1e-5);
596 }
597
598 #[test]
600 fn threshold_integer_literal_accepted() {
601 let dir = TempDir::new().unwrap();
602 write_plugin_overlay(
603 dir.path(),
604 "int-thresh",
605 "[config.skills]\ndisambiguation_threshold = 0",
606 );
607 let mut cfg = base_config();
608 cfg.skills.disambiguation_threshold = 0.5;
609 let overlay = apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
610 assert!(
612 overlay.skipped_plugins.is_empty(),
613 "unexpected skips: {:?}",
614 overlay.skipped_plugins
615 );
616 }
617
618 #[test]
620 fn malformed_manifest_skipped() {
621 let dir = TempDir::new().unwrap();
622 let plugin_dir = dir.path().join("broken");
623 fs::create_dir(&plugin_dir).unwrap();
624 fs::write(plugin_dir.join(".plugin.toml"), b"not valid toml ][[[").unwrap();
625 write_plugin_overlay(
626 dir.path(),
627 "good",
628 "[config.tools]\nblocked_commands = [\"sudo\"]",
629 );
630 let mut cfg = base_config();
631 let overlay = apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
632 assert!(overlay.skipped_plugins.iter().any(|s| s.contains("broken")));
633 assert!(
634 cfg.tools
635 .shell
636 .blocked_commands
637 .contains(&"sudo".to_owned())
638 );
639 }
640
641 #[test]
643 fn unsafelisted_overlay_key_skipped() {
644 let dir = TempDir::new().unwrap();
645 write_plugin_overlay(dir.path(), "tampered", "[config.llm]\nmodel = \"evil\"");
646 write_plugin_overlay(
647 dir.path(),
648 "good",
649 "[config.tools]\nblocked_commands = [\"sudo\"]",
650 );
651 let mut cfg = base_config();
652 let overlay = apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
653 assert!(
654 overlay
655 .skipped_plugins
656 .iter()
657 .any(|s| s.contains("tampered"))
658 );
659 assert!(
660 cfg.tools
661 .shell
662 .blocked_commands
663 .contains(&"sudo".to_owned())
664 );
665 }
666
667 #[cfg(unix)]
669 #[test]
670 fn symlinked_plugin_dir_ignored() {
671 let dir = TempDir::new().unwrap();
672 let real_dir = TempDir::new().unwrap();
673 let plugin_in_real = real_dir.path().join("evil");
674 fs::create_dir(&plugin_in_real).unwrap();
675 fs::write(
676 plugin_in_real.join(".plugin.toml"),
677 "[plugin]\nname = \"evil\"\nversion = \"0.1.0\"\n[config.tools]\nblocked_commands = [\"curl\"]",
678 )
679 .unwrap();
680 std::os::unix::fs::symlink(&plugin_in_real, dir.path().join("evil")).unwrap();
682 let mut cfg = base_config();
683 let overlay = apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
684 assert!(overlay.source_plugins.is_empty());
685 assert!(cfg.tools.shell.blocked_commands.is_empty());
686 }
687
688 #[test]
690 fn idempotent_merge() {
691 let dir = TempDir::new().unwrap();
692 write_plugin_overlay(
693 dir.path(),
694 "idem",
695 "[config.tools]\nblocked_commands = [\"sudo\"]",
696 );
697 let mut cfg = base_config();
698 apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
699 let snap1 = cfg.tools.shell.blocked_commands.clone();
700 apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
701 let snap2 = cfg.tools.shell.blocked_commands.clone();
702 assert_eq!(snap1, snap2);
703 }
704
705 #[test]
707 fn iteration_order_deterministic() {
708 let dir = TempDir::new().unwrap();
709 write_plugin_overlay(
711 dir.path(),
712 "z-plugin",
713 "[config.tools]\nblocked_commands = [\"z\"]",
714 );
715 write_plugin_overlay(
716 dir.path(),
717 "a-plugin",
718 "[config.tools]\nblocked_commands = [\"a\"]",
719 );
720 let mut cfg = base_config();
721 let overlay = apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
722 assert_eq!(overlay.source_plugins, vec!["a-plugin", "z-plugin"]);
723 }
724
725 #[test]
727 fn plugin_blocked_wins_over_base_allowed() {
728 let dir = TempDir::new().unwrap();
729 write_plugin_overlay(
730 dir.path(),
731 "hardening",
732 "[config.tools]\nblocked_commands = [\"curl\"]",
733 );
734 let mut cfg = base_config();
735 cfg.tools.shell.allowed_commands = vec!["curl".to_owned()];
736 apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
737 assert!(
738 cfg.tools
739 .shell
740 .blocked_commands
741 .contains(&"curl".to_owned())
742 );
743 }
744
745 #[test]
747 fn tampered_overlay_skipped_but_good_plugin_still_loaded() {
748 let dir = TempDir::new().unwrap();
749 write_plugin_overlay(dir.path(), "evil", "[config.llm]\nmodel = \"x\"");
750 write_plugin_overlay(
751 dir.path(),
752 "good",
753 "[config.skills]\ndisambiguation_threshold = 0.5",
754 );
755 let mut cfg = base_config();
756 cfg.skills.disambiguation_threshold = 0.1;
757 let overlay = apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
758 assert!(overlay.source_plugins.contains(&"good".to_owned()));
759 assert!(!overlay.source_plugins.contains(&"evil".to_owned()));
760 assert!((cfg.skills.disambiguation_threshold - 0.5_f32).abs() < 1e-5);
761 }
762
763 #[test]
769 fn reload_warns_on_shell_overlay_divergence() {
770 let dir = TempDir::new().unwrap();
771
772 let mut startup_cfg = base_config();
774 apply_plugin_config_overlays(&mut startup_cfg, dir.path()).unwrap();
775 let mut startup_blocked = startup_cfg.tools.shell.blocked_commands.clone();
776 startup_blocked.sort();
777 let mut startup_allowed = startup_cfg.tools.shell.allowed_commands.clone();
778 startup_allowed.sort();
779
780 write_plugin_overlay(
782 dir.path(),
783 "hardening",
784 "[config.tools]\nblocked_commands = [\"curl\"]",
785 );
786
787 let mut reload_cfg = base_config();
789 apply_plugin_config_overlays(&mut reload_cfg, dir.path()).unwrap();
790 let mut reload_blocked = reload_cfg.tools.shell.blocked_commands.clone();
791 reload_blocked.sort();
792 let mut reload_allowed = reload_cfg.tools.shell.allowed_commands.clone();
793 reload_allowed.sort();
794
795 assert_ne!(
797 startup_blocked, reload_blocked,
798 "reload should produce a different blocked_commands set after plugin install"
799 );
800 assert!(
801 reload_blocked.contains(&"curl".to_owned()),
802 "reload config must contain plugin-added blocked command"
803 );
804 assert_eq!(startup_allowed, reload_allowed);
806 }
807
808 #[test]
810 fn invalid_plugin_name_in_manifest_skipped() {
811 let dir = TempDir::new().unwrap();
812 let plugin_dir = dir.path().join("bad-name-dir");
813 fs::create_dir(&plugin_dir).unwrap();
814 fs::write(
816 plugin_dir.join(".plugin.toml"),
817 "[plugin]\nname = \"INVALID\"\nversion = \"0.1.0\"\n[config.tools]\nblocked_commands = [\"evil\"]",
818 )
819 .unwrap();
820 let mut cfg = base_config();
821 let overlay = apply_plugin_config_overlays(&mut cfg, dir.path()).unwrap();
822 assert!(cfg.tools.shell.blocked_commands.is_empty());
824 assert!(overlay.source_plugins.is_empty());
826 }
827
828 #[test]
831 fn overlay_ignores_integrity_registry_file() {
832 let dir = TempDir::new().unwrap();
833 let plugins_dir = dir.path();
834 fs::write(plugins_dir.join(".plugin-integrity.toml"), b"").unwrap();
836 let mut cfg = base_config();
837 let overlay = apply_plugin_config_overlays(&mut cfg, plugins_dir).unwrap();
838 assert!(
839 overlay.source_plugins.is_empty(),
840 "registry file must not be treated as a plugin"
841 );
842 assert!(
843 overlay.skipped_plugins.is_empty(),
844 "no errors expected for a non-dir entry"
845 );
846 }
847
848 #[test]
850 fn tampered_manifest_skipped_with_integrity_reason() {
851 let dir = TempDir::new().unwrap();
852 let plugins_dir = dir.path();
853 let registry_path = dir.path().join("registry.toml");
854
855 write_plugin_overlay(
856 plugins_dir,
857 "myplugin",
858 "[config.tools]\nblocked_commands = [\"curl\"]",
859 );
860 let toml_path = plugins_dir.join("myplugin").join(".plugin.toml");
861
862 let mut registry = crate::integrity::IntegrityRegistry::load(®istry_path);
864 registry.record("myplugin", &toml_path).unwrap();
865 registry.save(®istry_path).unwrap();
866
867 fs::write(&toml_path, "[plugin]\nname = \"myplugin\"\nversion = \"0.1.0\"\n[config.tools]\nblocked_commands = [\"evil\"]").unwrap();
869
870 let mut cfg = base_config();
871 let overlay =
872 apply_plugin_config_overlays_with_registry(&mut cfg, plugins_dir, ®istry_path)
873 .unwrap();
874
875 assert!(
876 cfg.tools.shell.blocked_commands.is_empty(),
877 "tampered plugin must not contribute"
878 );
879 let reason = overlay
880 .skipped_plugins
881 .iter()
882 .find(|s| s.contains("integrity mismatch"));
883 assert!(
884 reason.is_some(),
885 "expected integrity mismatch in skipped_plugins; got: {:?}",
886 overlay.skipped_plugins
887 );
888 }
889
890 #[test]
892 fn missing_integrity_record_allowed() {
893 let dir = TempDir::new().unwrap();
894 let plugins_dir = dir.path();
895 let registry_path = dir.path().join("registry.toml");
896
897 write_plugin_overlay(
898 plugins_dir,
899 "oldplugin",
900 "[config.tools]\nblocked_commands = [\"nc\"]",
901 );
902
903 let mut cfg = base_config();
904 let overlay =
905 apply_plugin_config_overlays_with_registry(&mut cfg, plugins_dir, ®istry_path)
906 .unwrap();
907
908 assert!(
909 overlay.skipped_plugins.is_empty(),
910 "pre-integrity plugin must not be skipped"
911 );
912 assert!(cfg.tools.shell.blocked_commands.contains(&"nc".to_owned()));
913 }
914}