Skip to main content

zeph_plugins/
overlay.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Plugin tighten-only config overlay merge.
5//!
6//! Scans every `<plugin>/.plugin.toml` under the plugins directory, resolves the
7//! union / intersection / max of the safelisted overlay keys, and mutates a [`Config`]
8//! in place.
9//!
10//! # Invariants
11//!
12//! - `tools.shell.blocked_commands` grows monotonically (union across all plugins).
13//! - `tools.shell.allowed_commands` never grows beyond the base — an empty base stays
14//!   empty (plugins cannot re-enable `DEFAULT_BLOCKED` commands). A non-empty base is
15//!   narrowed to the intersection with every plugin's list.
16//! - `skills.disambiguation_threshold` only rises (max across all plugins).
17
18use 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/// Summary of the overlay applied to a [`Config`] by [`apply_plugin_config_overlays`].
28///
29/// Returned so callers (bootstrap, TUI, `zeph plugin list`) can surface which plugins
30/// contributed and which were skipped without re-parsing the manifest files.
31#[derive(Debug, Clone, Default)]
32pub struct ResolvedOverlay {
33    /// Union of all plugin `tools.blocked_commands` lists, sorted and de-duplicated.
34    pub blocked_commands_add: Vec<String>,
35
36    /// Accumulated intersection of `allowed_commands` across plugins that supplied it.
37    /// `None` = no plugin mentioned this key → merge step is a no-op for this field.
38    /// Used internally by `apply_resolved`; also available for diagnostics.
39    pub allowed_commands_intersect_accum: Option<BTreeSet<String>>,
40
41    /// `max` across all plugins that supplied `skills.disambiguation_threshold`.
42    /// `None` means no plugin supplied this key.
43    pub disambiguation_threshold_max: Option<f32>,
44
45    /// Names of plugins whose overlay contributed at least one safelisted value.
46    /// Sorted ascending (deterministic — follows `sort_by_key(file_name)` iteration).
47    pub source_plugins: Vec<String>,
48
49    /// Plugins that were skipped. Each entry: `"<name>: <reason>"`.
50    pub skipped_plugins: Vec<String>,
51}
52
53/// Apply tighten-only config overlays from every installed plugin to `config`.
54///
55/// Reads `<plugins_dir>/<plugin>/.plugin.toml` for each subdirectory, validates the
56/// safelisted keys, and merges: `blocked_commands` (union), `allowed_commands` (intersection,
57/// base-gated), `disambiguation_threshold` (max).
58///
59/// Returns [`ResolvedOverlay`] describing what was applied and what was skipped.
60/// A missing `plugins_dir` is silently treated as an empty directory.
61///
62/// # Errors
63///
64/// Returns [`PluginError::Io`] only when `plugins_dir` exists but cannot be enumerated.
65/// Per-plugin failures are recorded in [`ResolvedOverlay::skipped_plugins`] and do not
66/// abort the merge.
67pub 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/// Like [`apply_plugin_config_overlays`] but with an explicit integrity registry path.
78///
79/// Used by tests to inject an isolated registry path and avoid touching the real data dir.
80#[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    // M1: sort entries deterministically so log ordering and `source_plugins` are
104    // platform-independent (ext4 inode order, APFS insertion order, etc. vary).
105    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            &registry,
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    // E8: reject symlinked subdirectories; only real dirs installed by
144    // PluginManager::add may contribute overlays.
145    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    // Integrity check: verify the manifest has not been modified since install.
193    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    // Security: validate plugin name before using it in logs/data structures to prevent
214    // log injection via a post-install-tampered manifest.
215    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    // M2: re-run install-time safelist check as defence-in-depth against post-install tampering.
224    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        // M3: accept both float literals (`0.5`) and integer literals (`0`, `1`).
282        #[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    // blocked_commands: base ∪ overlay (tighten — more commands blocked).
311    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    // allowed_commands: base ∩ overlay, BUT empty base stays empty.
325    //
326    // B2: `allowed_commands` *subtracts* from DEFAULT_BLOCKED in ShellExecutor::new.
327    // Adopting a non-empty plugin list when the base is empty would loosen
328    // restrictions by re-enabling DEFAULT_BLOCKED commands. Tighten-only requires
329    // that only a non-empty base may be further narrowed.
330    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    // disambiguation_threshold: max(base, overlay).
359    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    // 1. empty_plugins_dir_is_noop
405    #[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    // 2. plugins_dir_without_manifests_is_noop
417    #[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    // 3. single_plugin_blocked_commands_union
428    #[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    // 4. multi_plugin_blocked_commands_dedup
454    #[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    // 5. non_empty_base_allowed_commands_narrowed
480    #[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    // 6. multi_plugin_allowed_commands_intersection
497    #[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    // 7. empty_base_allowed_commands_overlay_ignored (B2 regression)
517    #[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        // Base was empty — plugin must NOT have widened it.
529        assert!(cfg.tools.shell.allowed_commands.is_empty());
530    }
531
532    // 8. disambiguation_threshold_max_wins
533    #[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    // 9. disambiguation_threshold_lower_ignored
548    #[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    // 10. threshold_out_of_range_skipped_with_warning
563    #[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    // 11. threshold_boundary_one_accepted
584    #[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    // 12. threshold_integer_literal_accepted (M3)
599    #[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        // 0 < 0.5 so max keeps 0.5; but the key must parse without error (no skipped_plugins).
611        assert!(
612            overlay.skipped_plugins.is_empty(),
613            "unexpected skips: {:?}",
614            overlay.skipped_plugins
615        );
616    }
617
618    // 13. malformed_manifest_skipped
619    #[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    // 14. unsafelisted_overlay_key_skipped
642    #[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    // 15. symlinked_plugin_dir_ignored (E8)
668    #[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        // Symlink: plugins_dir/evil -> real_dir/evil
681        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    // 16. idempotent_merge
689    #[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    // 17. iteration_order_deterministic (M1)
706    #[test]
707    fn iteration_order_deterministic() {
708        let dir = TempDir::new().unwrap();
709        // Create in reverse alphabetical order; iteration must still be sorted.
710        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    // 18. plugin_blocked_wins_over_base_allowed (E4)
726    #[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    // 19. tampered_overlay_skipped_but_source_plugins_still_has_good
746    #[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    // T22. reload_warns_on_shell_overlay_divergence (divergence detection logic)
764    //
765    // Simulates the startup-vs-reload comparison: a startup snapshot with no plugin-blocked
766    // commands, then a reload that merges a plugin adding "curl" to blocked_commands. The
767    // resulting full blocked set differs from the startup snapshot → divergence detected.
768    #[test]
769    fn reload_warns_on_shell_overlay_divergence() {
770        let dir = TempDir::new().unwrap();
771
772        // --- Startup: no plugins yet ---
773        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        // --- A plugin is installed after startup ---
781        write_plugin_overlay(
782            dir.path(),
783            "hardening",
784            "[config.tools]\nblocked_commands = [\"curl\"]",
785        );
786
787        // --- Reload: re-apply overlays to a fresh config ---
788        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        // The blocked set changed — divergence must be detectable by comparison.
796        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        // allowed_commands unchanged (base was empty, plugin list ignored).
805        assert_eq!(startup_allowed, reload_allowed);
806    }
807
808    // 20. invalid_plugin_name_in_manifest_skipped (Security M-2)
809    #[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        // Plugin name contains uppercase — invalid per [a-z0-9][a-z0-9-]*.
815        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        // Plugin skipped due to invalid name — no commands added.
823        assert!(cfg.tools.shell.blocked_commands.is_empty());
824        // source_plugins must NOT contain the untrusted name.
825        assert!(overlay.source_plugins.is_empty());
826    }
827
828    // H4 regression: a stray `.plugin-integrity.toml` inside plugins_dir must not be
829    // treated as a plugin directory.
830    #[test]
831    fn overlay_ignores_integrity_registry_file() {
832        let dir = TempDir::new().unwrap();
833        let plugins_dir = dir.path();
834        // Place a stray registry file directly inside plugins_dir.
835        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    // Tampered manifest is skipped with "integrity mismatch" reason.
849    #[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        // Record digest of original manifest.
863        let mut registry = crate::integrity::IntegrityRegistry::load(&registry_path);
864        registry.record("myplugin", &toml_path).unwrap();
865        registry.save(&registry_path).unwrap();
866
867        // Tamper the manifest.
868        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, &registry_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    // Plugin with no registry entry (pre-integrity install) loads without errors.
891    #[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, &registry_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}