Skip to main content

eval_magic/adapters/
plugin_shadow.rs

1//! Plugin-shadow detector (Claude Code).
2//!
3//! The runner stages eval skills into the
4//! project-local `.claude/skills/` dir, but eval subagents are dispatched via the
5//! Task tool and run in-process — so they ALSO inherit whatever skills the
6//! orchestrator session loaded from installed plugins and the global skills dir.
7//! When a staged skill name collides with one of those, both copies are
8//! discoverable and the with/without comparison is contaminated. The runner
9//! cannot unload a plugin from a running session, so this module only *detects
10//! and reports* the overlap, reading declared settings as a best-effort proxy.
11
12use serde::de::DeserializeOwned;
13use serde::{Deserialize, Serialize};
14use std::collections::{HashMap, HashSet};
15use std::fs;
16use std::path::{Path, PathBuf};
17
18const ISOLATION_DOC: &str = "docs/harness-claude-code.md → \"Isolating from installed plugins\"";
19
20/// A staged skill that is also discoverable from the live environment.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22#[serde(tag = "kind", rename_all = "kebab-case")]
23pub enum ShadowSource {
24    Plugin {
25        plugin: String,
26        skill_name: String,
27        path: String,
28    },
29    GlobalSkill {
30        skill_name: String,
31        path: String,
32    },
33}
34
35impl ShadowSource {
36    fn skill_name(&self) -> &str {
37        match self {
38            ShadowSource::Plugin { skill_name, .. } => skill_name,
39            ShadowSource::GlobalSkill { skill_name, .. } => skill_name,
40        }
41    }
42
43    fn source_label(&self) -> String {
44        match self {
45            ShadowSource::Plugin { plugin, .. } => format!("enabled plugin '{plugin}'"),
46            ShadowSource::GlobalSkill { .. } => "the global skills dir".to_string(),
47        }
48    }
49}
50
51/// The detector's findings for a run.
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53pub struct PluginShadowReport {
54    pub config_dir: String,
55    pub shadowed: Vec<ShadowSource>,
56}
57
58/// The Claude Code config dir: a non-empty `CLAUDE_CONFIG_DIR` override (passed
59/// in), else `~/.claude`.
60pub fn resolve_config_dir(config_dir_override: Option<&str>) -> PathBuf {
61    match config_dir_override {
62        Some(o) if !o.trim().is_empty() => PathBuf::from(o),
63        _ => std::env::home_dir().unwrap_or_default().join(".claude"),
64    }
65}
66
67/// The Claude Code config dir, reading the `CLAUDE_CONFIG_DIR` override from the
68/// environment (else `~/.claude`). Thin convenience over [`resolve_config_dir`]
69/// for the call sites that should honor the env var — the override logic itself
70/// is covered by `resolve_config_dir`'s tests.
71pub fn config_dir_from_env() -> PathBuf {
72    resolve_config_dir(std::env::var("CLAUDE_CONFIG_DIR").ok().as_deref())
73}
74
75fn read_json_safe<T: DeserializeOwned>(path: &Path) -> Option<T> {
76    let raw = fs::read_to_string(path).ok()?;
77    serde_json::from_str(&raw).ok()
78}
79
80#[derive(Debug, Deserialize)]
81struct Settings {
82    #[serde(rename = "enabledPlugins")]
83    enabled_plugins: Option<HashMap<String, bool>>,
84}
85
86/// Effective `enabledPlugins` map, honoring Claude Code's settings precedence
87/// (local > project > user). Later sources override earlier keys, so a
88/// project-scope `false` correctly masks a user-scope `true`.
89fn resolve_enabled_plugins(config_dir: &Path, cwd: &Path) -> HashMap<String, bool> {
90    let sources = [
91        config_dir.join("settings.json"),
92        cwd.join(".claude").join("settings.json"),
93        cwd.join(".claude").join("settings.local.json"),
94    ];
95    let mut merged = HashMap::new();
96    for path in sources {
97        if let Some(s) = read_json_safe::<Settings>(&path)
98            && let Some(ep) = s.enabled_plugins
99        {
100            merged.extend(ep);
101        }
102    }
103    merged
104}
105
106/// Names of skill folders (those holding a `SKILL.md`) directly under `dir`.
107fn skill_folder_names(dir: &Path) -> Vec<(String, PathBuf)> {
108    let mut out = Vec::new();
109    let Ok(entries) = fs::read_dir(dir) else {
110        return out;
111    };
112    for entry in entries.flatten() {
113        let path = entry.path();
114        if !path.is_dir() {
115            continue;
116        }
117        if path.join("SKILL.md").exists() {
118            out.push((entry.file_name().to_string_lossy().into_owned(), path));
119        }
120    }
121    out
122}
123
124#[derive(Debug, Deserialize)]
125struct Install {
126    #[serde(rename = "installPath")]
127    install_path: Option<String>,
128}
129
130#[derive(Debug, Deserialize)]
131struct InstalledPlugins {
132    plugins: Option<HashMap<String, Vec<Install>>>,
133}
134
135/// Skills exposed by currently-enabled installed plugins.
136fn enabled_plugin_skills(config_dir: &Path, enabled: &HashMap<String, bool>) -> Vec<ShadowSource> {
137    let mut out = Vec::new();
138    let manifest: Option<InstalledPlugins> =
139        read_json_safe(&config_dir.join("plugins").join("installed_plugins.json"));
140    let Some(plugins) = manifest.and_then(|m| m.plugins) else {
141        return out;
142    };
143    for (key, installs) in plugins {
144        if enabled.get(&key) != Some(&true) {
145            continue; // only enabled plugins shadow
146        }
147        for inst in installs {
148            let Some(install_path) = inst.install_path else {
149                continue;
150            };
151            for (name, path) in skill_folder_names(&Path::new(&install_path).join("skills")) {
152                out.push(ShadowSource::Plugin {
153                    plugin: key.clone(),
154                    skill_name: name,
155                    path: path.to_string_lossy().into_owned(),
156                });
157            }
158        }
159    }
160    out
161}
162
163/// Skills under the global skills dir (`<config_dir>/skills`).
164fn global_skills(config_dir: &Path) -> Vec<ShadowSource> {
165    skill_folder_names(&config_dir.join("skills"))
166        .into_iter()
167        .map(|(name, path)| ShadowSource::GlobalSkill {
168            skill_name: name,
169            path: path.to_string_lossy().into_owned(),
170        })
171        .collect()
172}
173
174/// Which of `staged_skill_names` are also discoverable from enabled plugins or
175/// the global skills dir. Matches on the skill folder name (exact).
176pub fn detect_plugin_shadows(
177    config_dir: &Path,
178    cwd: &Path,
179    staged_skill_names: &[&str],
180) -> PluginShadowReport {
181    let staged: HashSet<&str> = staged_skill_names.iter().copied().collect();
182    let enabled = resolve_enabled_plugins(config_dir, cwd);
183    let mut shadowed = Vec::new();
184
185    for s in enabled_plugin_skills(config_dir, &enabled) {
186        if staged.contains(s.skill_name()) {
187            shadowed.push(s);
188        }
189    }
190    for s in global_skills(config_dir) {
191        if staged.contains(s.skill_name()) {
192            shadowed.push(s);
193        }
194    }
195
196    PluginShadowReport {
197        config_dir: config_dir.to_string_lossy().into_owned(),
198        shadowed,
199    }
200}
201
202/// One `validity_warnings` line per shadowed skill (for benchmark.json).
203pub fn shadow_validity_warnings(report: &PluginShadowReport) -> Vec<String> {
204    report
205        .shadowed
206        .iter()
207        .map(|s| {
208            format!(
209                "staged skill '{}' is also provided by {} — eval subagents could discover both \
210                 copies, so with/without results may be contaminated. Re-run from an isolated \
211                 session (see {}).",
212                s.skill_name(),
213                s.source_label(),
214                ISOLATION_DOC
215            )
216        })
217        .collect()
218}
219
220/// Build-time banner for the runner. Empty string when nothing is shadowed.
221pub fn format_shadow_banner(report: &PluginShadowReport) -> String {
222    if report.shadowed.is_empty() {
223        return String::new();
224    }
225    let mut lines = vec![
226        String::new(),
227        "⚠ Plugin-shadow warning: skills staged for this eval are ALSO discoverable".to_string(),
228        "  from your live environment:".to_string(),
229    ];
230    for s in &report.shadowed {
231        lines.push(format!("  • {} — {}", s.skill_name(), s.source_label()));
232    }
233    lines.push(
234        "  Eval subagents (dispatched via the Task tool) inherit this session's plugins,"
235            .to_string(),
236    );
237    lines.push(
238        "  so both the staged copy and the installed copy are discoverable — the".to_string(),
239    );
240    lines.push(
241        "  with/without comparison may be contaminated and the control arm is not truly"
242            .to_string(),
243    );
244    lines.push(
245        "  skill-absent. The runner cannot unload a plugin from a running session.".to_string(),
246    );
247    lines.push(format!(
248        "  Re-run from an isolated session — see {ISOLATION_DOC}."
249    ));
250    lines.join("\n")
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use serde_json::json;
257    use std::fs;
258    use std::path::{Path, PathBuf};
259    use tempfile::TempDir;
260
261    fn fresh(dir: &TempDir) -> (PathBuf, PathBuf) {
262        let config = dir.path().join("config");
263        let cwd = dir.path().join("cwd");
264        fs::create_dir_all(&config).unwrap();
265        fs::create_dir_all(&cwd).unwrap();
266        (config, cwd)
267    }
268
269    fn write_file(path: &Path, body: &str) {
270        fs::create_dir_all(path.parent().unwrap()).unwrap();
271        fs::write(path, body).unwrap();
272    }
273
274    fn install_plugin(config: &Path, key: &str, skill_names: &[&str]) -> PathBuf {
275        let install = config
276            .join("plugins")
277            .join("cache")
278            .join(key.replace('@', "__"));
279        for name in skill_names {
280            write_file(
281                &install.join("skills").join(name).join("SKILL.md"),
282                &format!("---\nname: {name}\ndescription: x\n---\n"),
283            );
284        }
285        install
286    }
287
288    fn write_installed_manifest(config: &Path, entries: &[(&str, &Path)]) {
289        let mut plugins = serde_json::Map::new();
290        for (key, install) in entries {
291            plugins.insert(
292                (*key).to_string(),
293                json!([{"installPath": install.to_string_lossy()}]),
294            );
295        }
296        write_file(
297            &config.join("plugins").join("installed_plugins.json"),
298            &serde_json::to_string_pretty(&json!({"version": 2, "plugins": plugins})).unwrap(),
299        );
300    }
301
302    fn write_settings(path: &Path, enabled: &[(&str, bool)]) {
303        let mut m = serde_json::Map::new();
304        for (k, v) in enabled {
305            m.insert((*k).to_string(), json!(v));
306        }
307        write_file(
308            path,
309            &serde_json::to_string(&json!({"enabledPlugins": m})).unwrap(),
310        );
311    }
312
313    #[test]
314    fn honors_config_dir_override() {
315        assert_eq!(
316            resolve_config_dir(Some("/custom/cfg")),
317            PathBuf::from("/custom/cfg")
318        );
319    }
320
321    #[test]
322    fn defaults_to_home_claude_when_unset() {
323        let expected = std::env::home_dir().unwrap_or_default().join(".claude");
324        assert_eq!(resolve_config_dir(None), expected);
325        // Whitespace-only override falls back to the default too.
326        assert_eq!(resolve_config_dir(Some("  ")), expected);
327    }
328
329    #[test]
330    fn flags_skill_also_provided_by_enabled_plugin() {
331        let tmp = TempDir::new().unwrap();
332        let (config, cwd) = fresh(&tmp);
333        let ip = install_plugin(
334            &config,
335            "slow-powers@slowdini",
336            &["verification-before-completion", "writing-skills"],
337        );
338        write_installed_manifest(&config, &[("slow-powers@slowdini", &ip)]);
339        write_settings(
340            &config.join("settings.json"),
341            &[("slow-powers@slowdini", true)],
342        );
343
344        let report = detect_plugin_shadows(&config, &cwd, &["verification-before-completion"]);
345        assert_eq!(report.shadowed.len(), 1);
346        match &report.shadowed[0] {
347            ShadowSource::Plugin {
348                plugin, skill_name, ..
349            } => {
350                assert_eq!(plugin, "slow-powers@slowdini");
351                assert_eq!(skill_name, "verification-before-completion");
352            }
353            other => panic!("expected plugin shadow, got {other:?}"),
354        }
355    }
356
357    #[test]
358    fn does_not_flag_disabled_plugin() {
359        let tmp = TempDir::new().unwrap();
360        let (config, cwd) = fresh(&tmp);
361        let ip = install_plugin(
362            &config,
363            "slow-powers@slowdini",
364            &["verification-before-completion"],
365        );
366        write_installed_manifest(&config, &[("slow-powers@slowdini", &ip)]);
367        write_settings(
368            &config.join("settings.json"),
369            &[("slow-powers@slowdini", false)],
370        );
371
372        let report = detect_plugin_shadows(&config, &cwd, &["verification-before-completion"]);
373        assert_eq!(report.shadowed.len(), 0);
374    }
375
376    #[test]
377    fn project_settings_disabling_user_enabled_plugin_suppresses_shadow() {
378        let tmp = TempDir::new().unwrap();
379        let (config, cwd) = fresh(&tmp);
380        let ip = install_plugin(
381            &config,
382            "slow-powers@slowdini",
383            &["verification-before-completion"],
384        );
385        write_installed_manifest(&config, &[("slow-powers@slowdini", &ip)]);
386        write_settings(
387            &config.join("settings.json"),
388            &[("slow-powers@slowdini", true)],
389        );
390        // Project scope (cwd/.claude/settings.json) outranks user scope.
391        write_settings(
392            &cwd.join(".claude").join("settings.json"),
393            &[("slow-powers@slowdini", false)],
394        );
395
396        let report = detect_plugin_shadows(&config, &cwd, &["verification-before-completion"]);
397        assert_eq!(report.shadowed.len(), 0);
398    }
399
400    #[test]
401    fn flags_skill_also_in_global_skills_dir() {
402        let tmp = TempDir::new().unwrap();
403        let (config, cwd) = fresh(&tmp);
404        write_file(
405            &config.join("skills").join("my-skill").join("SKILL.md"),
406            "---\nname: my-skill\n---\n",
407        );
408
409        let report = detect_plugin_shadows(&config, &cwd, &["my-skill"]);
410        assert_eq!(report.shadowed.len(), 1);
411        match &report.shadowed[0] {
412            ShadowSource::GlobalSkill { skill_name, .. } => assert_eq!(skill_name, "my-skill"),
413            other => panic!("expected global-skill shadow, got {other:?}"),
414        }
415    }
416
417    #[test]
418    fn no_shadow_when_staged_names_match_nothing() {
419        let tmp = TempDir::new().unwrap();
420        let (config, cwd) = fresh(&tmp);
421        let ip = install_plugin(&config, "p@m", &["other"]);
422        write_installed_manifest(&config, &[("p@m", &ip)]);
423        write_settings(&config.join("settings.json"), &[("p@m", true)]);
424
425        let report = detect_plugin_shadows(&config, &cwd, &["mine"]);
426        assert_eq!(report.shadowed.len(), 0);
427    }
428
429    #[test]
430    fn graceful_when_config_dir_has_no_plugins_or_skills() {
431        let tmp = TempDir::new().unwrap();
432        let (config, cwd) = fresh(&tmp);
433        let report = detect_plugin_shadows(&config, &cwd, &["x"]);
434        assert_eq!(report.shadowed.len(), 0);
435        assert_eq!(report.config_dir, config.to_string_lossy());
436    }
437
438    fn sample_report() -> PluginShadowReport {
439        PluginShadowReport {
440            config_dir: "/x".into(),
441            shadowed: vec![ShadowSource::Plugin {
442                plugin: "slow-powers@slowdini".into(),
443                skill_name: "verification-before-completion".into(),
444                path: "/p".into(),
445            }],
446        }
447    }
448
449    #[test]
450    fn validity_warnings_name_skill_plugin_and_contamination() {
451        let warnings = shadow_validity_warnings(&sample_report());
452        assert_eq!(warnings.len(), 1);
453        assert!(warnings[0].contains("verification-before-completion"));
454        assert!(warnings[0].contains("slow-powers@slowdini"));
455        assert!(warnings[0].to_lowercase().contains("contaminat"));
456    }
457
458    #[test]
459    fn banner_is_empty_when_nothing_shadowed() {
460        let empty = PluginShadowReport {
461            config_dir: "/x".into(),
462            shadowed: vec![],
463        };
464        assert_eq!(format_shadow_banner(&empty), "");
465    }
466
467    #[test]
468    fn banner_lists_shadowed_skills_and_points_at_isolation_docs() {
469        let banner = format_shadow_banner(&sample_report());
470        assert!(banner.contains("verification-before-completion"));
471        assert!(banner.contains("slow-powers@slowdini"));
472        assert!(banner.to_lowercase().contains("isolat"));
473    }
474}