Skip to main content

eval_magic/adapters/claude_code/
plugin_shadow.rs

1//! Plugin-shadow detector (Claude Code).
2//!
3//! The runner stages eval skills into each dispatch's project-local
4//! `.claude/skills/` dir, but every `claude -p` dispatch ALSO loads the user/global
5//! plugins and the global skills dir from its Claude config. When a staged skill
6//! name collides with one of those, both copies are discoverable and the
7//! with/without comparison is contaminated. The runner cannot strip an installed
8//! plugin from a dispatch, so this module only *detects and reports* the overlap,
9//! reading declared settings as a best-effort proxy. The report/banner shapes are
10//! harness-neutral and live in [`skill_shadow`](crate::adapters::skill_shadow).
11
12use serde::Deserialize;
13use serde::de::DeserializeOwned;
14use std::collections::{HashMap, HashSet};
15use std::fs;
16use std::path::{Path, PathBuf};
17
18use crate::adapters::skill_shadow::{PluginShadowReport, ShadowSource};
19
20/// The Claude Code config dir: a non-empty `CLAUDE_CONFIG_DIR` override (passed
21/// in), else `~/.claude`.
22pub fn resolve_config_dir(config_dir_override: Option<&str>) -> PathBuf {
23    match config_dir_override {
24        Some(o) if !o.trim().is_empty() => PathBuf::from(o),
25        _ => std::env::home_dir().unwrap_or_default().join(".claude"),
26    }
27}
28
29/// The Claude Code config dir, reading the `CLAUDE_CONFIG_DIR` override from the
30/// environment (else `~/.claude`). Thin convenience over [`resolve_config_dir`]
31/// for the call sites that should honor the env var — the override logic itself
32/// is covered by `resolve_config_dir`'s tests.
33pub fn config_dir_from_env() -> PathBuf {
34    resolve_config_dir(std::env::var("CLAUDE_CONFIG_DIR").ok().as_deref())
35}
36
37fn read_json_safe<T: DeserializeOwned>(path: &Path) -> Option<T> {
38    let raw = fs::read_to_string(path).ok()?;
39    serde_json::from_str(&raw).ok()
40}
41
42#[derive(Debug, Deserialize)]
43struct Settings {
44    #[serde(rename = "enabledPlugins")]
45    enabled_plugins: Option<HashMap<String, bool>>,
46}
47
48/// Effective `enabledPlugins` map, honoring Claude Code's settings precedence
49/// (local > project > user). Later sources override earlier keys, so a
50/// project-scope `false` correctly masks a user-scope `true`.
51fn resolve_enabled_plugins(config_dir: &Path, cwd: &Path) -> HashMap<String, bool> {
52    let sources = [
53        config_dir.join("settings.json"),
54        cwd.join(".claude").join("settings.json"),
55        cwd.join(".claude").join("settings.local.json"),
56    ];
57    let mut merged = HashMap::new();
58    for path in sources {
59        if let Some(s) = read_json_safe::<Settings>(&path)
60            && let Some(ep) = s.enabled_plugins
61        {
62            merged.extend(ep);
63        }
64    }
65    merged
66}
67
68/// Names of skill folders (those holding a `SKILL.md`) directly under `dir`.
69fn skill_folder_names(dir: &Path) -> Vec<(String, PathBuf)> {
70    let mut out = Vec::new();
71    let Ok(entries) = fs::read_dir(dir) else {
72        return out;
73    };
74    for entry in entries.flatten() {
75        let path = entry.path();
76        if !path.is_dir() {
77            continue;
78        }
79        if path.join("SKILL.md").exists() {
80            out.push((entry.file_name().to_string_lossy().into_owned(), path));
81        }
82    }
83    out
84}
85
86#[derive(Debug, Deserialize)]
87struct Install {
88    #[serde(rename = "installPath")]
89    install_path: Option<String>,
90}
91
92#[derive(Debug, Deserialize)]
93struct InstalledPlugins {
94    plugins: Option<HashMap<String, Vec<Install>>>,
95}
96
97/// Skills exposed by currently-enabled installed plugins.
98fn enabled_plugin_skills(config_dir: &Path, enabled: &HashMap<String, bool>) -> Vec<ShadowSource> {
99    let mut out = Vec::new();
100    let manifest: Option<InstalledPlugins> =
101        read_json_safe(&config_dir.join("plugins").join("installed_plugins.json"));
102    let Some(plugins) = manifest.and_then(|m| m.plugins) else {
103        return out;
104    };
105    for (key, installs) in plugins {
106        if enabled.get(&key) != Some(&true) {
107            continue; // only enabled plugins shadow
108        }
109        for inst in installs {
110            let Some(install_path) = inst.install_path else {
111                continue;
112            };
113            for (name, path) in skill_folder_names(&Path::new(&install_path).join("skills")) {
114                out.push(ShadowSource::Plugin {
115                    plugin: key.clone(),
116                    skill_name: name,
117                    path: path.to_string_lossy().into_owned(),
118                });
119            }
120        }
121    }
122    out
123}
124
125/// Skills under the global skills dir (`<config_dir>/skills`).
126fn global_skills(config_dir: &Path) -> Vec<ShadowSource> {
127    skill_folder_names(&config_dir.join("skills"))
128        .into_iter()
129        .map(|(name, path)| ShadowSource::GlobalSkill {
130            skill_name: name,
131            path: path.to_string_lossy().into_owned(),
132        })
133        .collect()
134}
135
136/// Which of `staged_skill_names` are also discoverable from enabled plugins or
137/// the global skills dir. Matches on the skill folder name (exact).
138pub fn detect_plugin_shadows(
139    config_dir: &Path,
140    cwd: &Path,
141    staged_skill_names: &[&str],
142) -> PluginShadowReport {
143    let staged: HashSet<&str> = staged_skill_names.iter().copied().collect();
144    let enabled = resolve_enabled_plugins(config_dir, cwd);
145    let mut shadowed = Vec::new();
146
147    for s in enabled_plugin_skills(config_dir, &enabled) {
148        if staged.contains(s.skill_name()) {
149            shadowed.push(s);
150        }
151    }
152    for s in global_skills(config_dir) {
153        if staged.contains(s.skill_name()) {
154            shadowed.push(s);
155        }
156    }
157
158    PluginShadowReport {
159        config_dir: config_dir.to_string_lossy().into_owned(),
160        shadowed,
161    }
162}
163
164/// The build-time preflight: `Some(report)` only when a staged name is
165/// shadowed. `config_dir` is passed in so the env-reading caller (the adapter's
166/// `detect_shadowed_skills`) stays a thin wrapper.
167pub fn shadow_preflight(
168    config_dir: &Path,
169    cwd: &Path,
170    staged_skill_names: &[&str],
171) -> Option<PluginShadowReport> {
172    let report = detect_plugin_shadows(config_dir, cwd, staged_skill_names);
173    (!report.shadowed.is_empty()).then_some(report)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use serde_json::json;
180    use std::fs;
181    use std::path::{Path, PathBuf};
182    use tempfile::TempDir;
183
184    fn fresh(dir: &TempDir) -> (PathBuf, PathBuf) {
185        let config = dir.path().join("config");
186        let cwd = dir.path().join("cwd");
187        fs::create_dir_all(&config).unwrap();
188        fs::create_dir_all(&cwd).unwrap();
189        (config, cwd)
190    }
191
192    fn write_file(path: &Path, body: &str) {
193        fs::create_dir_all(path.parent().unwrap()).unwrap();
194        fs::write(path, body).unwrap();
195    }
196
197    fn install_plugin(config: &Path, key: &str, skill_names: &[&str]) -> PathBuf {
198        let install = config
199            .join("plugins")
200            .join("cache")
201            .join(key.replace('@', "__"));
202        for name in skill_names {
203            write_file(
204                &install.join("skills").join(name).join("SKILL.md"),
205                &format!("---\nname: {name}\ndescription: x\n---\n"),
206            );
207        }
208        install
209    }
210
211    fn write_installed_manifest(config: &Path, entries: &[(&str, &Path)]) {
212        let mut plugins = serde_json::Map::new();
213        for (key, install) in entries {
214            plugins.insert(
215                (*key).to_string(),
216                json!([{"installPath": install.to_string_lossy()}]),
217            );
218        }
219        write_file(
220            &config.join("plugins").join("installed_plugins.json"),
221            &serde_json::to_string_pretty(&json!({"version": 2, "plugins": plugins})).unwrap(),
222        );
223    }
224
225    fn write_settings(path: &Path, enabled: &[(&str, bool)]) {
226        let mut m = serde_json::Map::new();
227        for (k, v) in enabled {
228            m.insert((*k).to_string(), json!(v));
229        }
230        write_file(
231            path,
232            &serde_json::to_string(&json!({"enabledPlugins": m})).unwrap(),
233        );
234    }
235
236    #[test]
237    fn honors_config_dir_override() {
238        assert_eq!(
239            resolve_config_dir(Some("/custom/cfg")),
240            PathBuf::from("/custom/cfg")
241        );
242    }
243
244    #[test]
245    fn defaults_to_home_claude_when_unset() {
246        let expected = std::env::home_dir().unwrap_or_default().join(".claude");
247        assert_eq!(resolve_config_dir(None), expected);
248        // Whitespace-only override falls back to the default too.
249        assert_eq!(resolve_config_dir(Some("  ")), expected);
250    }
251
252    #[test]
253    fn flags_skill_also_provided_by_enabled_plugin() {
254        let tmp = TempDir::new().unwrap();
255        let (config, cwd) = fresh(&tmp);
256        let ip = install_plugin(
257            &config,
258            "slow-powers@slowdini",
259            &["verification-before-completion", "writing-skills"],
260        );
261        write_installed_manifest(&config, &[("slow-powers@slowdini", &ip)]);
262        write_settings(
263            &config.join("settings.json"),
264            &[("slow-powers@slowdini", true)],
265        );
266
267        let report = detect_plugin_shadows(&config, &cwd, &["verification-before-completion"]);
268        assert_eq!(report.shadowed.len(), 1);
269        match &report.shadowed[0] {
270            ShadowSource::Plugin {
271                plugin, skill_name, ..
272            } => {
273                assert_eq!(plugin, "slow-powers@slowdini");
274                assert_eq!(skill_name, "verification-before-completion");
275            }
276            other => panic!("expected plugin shadow, got {other:?}"),
277        }
278    }
279
280    #[test]
281    fn does_not_flag_disabled_plugin() {
282        let tmp = TempDir::new().unwrap();
283        let (config, cwd) = fresh(&tmp);
284        let ip = install_plugin(
285            &config,
286            "slow-powers@slowdini",
287            &["verification-before-completion"],
288        );
289        write_installed_manifest(&config, &[("slow-powers@slowdini", &ip)]);
290        write_settings(
291            &config.join("settings.json"),
292            &[("slow-powers@slowdini", false)],
293        );
294
295        let report = detect_plugin_shadows(&config, &cwd, &["verification-before-completion"]);
296        assert_eq!(report.shadowed.len(), 0);
297    }
298
299    #[test]
300    fn project_settings_disabling_user_enabled_plugin_suppresses_shadow() {
301        let tmp = TempDir::new().unwrap();
302        let (config, cwd) = fresh(&tmp);
303        let ip = install_plugin(
304            &config,
305            "slow-powers@slowdini",
306            &["verification-before-completion"],
307        );
308        write_installed_manifest(&config, &[("slow-powers@slowdini", &ip)]);
309        write_settings(
310            &config.join("settings.json"),
311            &[("slow-powers@slowdini", true)],
312        );
313        // Project scope (cwd/.claude/settings.json) outranks user scope.
314        write_settings(
315            &cwd.join(".claude").join("settings.json"),
316            &[("slow-powers@slowdini", false)],
317        );
318
319        let report = detect_plugin_shadows(&config, &cwd, &["verification-before-completion"]);
320        assert_eq!(report.shadowed.len(), 0);
321    }
322
323    #[test]
324    fn flags_skill_also_in_global_skills_dir() {
325        let tmp = TempDir::new().unwrap();
326        let (config, cwd) = fresh(&tmp);
327        write_file(
328            &config.join("skills").join("my-skill").join("SKILL.md"),
329            "---\nname: my-skill\n---\n",
330        );
331
332        let report = detect_plugin_shadows(&config, &cwd, &["my-skill"]);
333        assert_eq!(report.shadowed.len(), 1);
334        match &report.shadowed[0] {
335            ShadowSource::GlobalSkill { skill_name, .. } => assert_eq!(skill_name, "my-skill"),
336            other => panic!("expected global-skill shadow, got {other:?}"),
337        }
338    }
339
340    #[test]
341    fn no_shadow_when_staged_names_match_nothing() {
342        let tmp = TempDir::new().unwrap();
343        let (config, cwd) = fresh(&tmp);
344        let ip = install_plugin(&config, "p@m", &["other"]);
345        write_installed_manifest(&config, &[("p@m", &ip)]);
346        write_settings(&config.join("settings.json"), &[("p@m", true)]);
347
348        let report = detect_plugin_shadows(&config, &cwd, &["mine"]);
349        assert_eq!(report.shadowed.len(), 0);
350    }
351
352    #[test]
353    fn graceful_when_config_dir_has_no_plugins_or_skills() {
354        let tmp = TempDir::new().unwrap();
355        let (config, cwd) = fresh(&tmp);
356        let report = detect_plugin_shadows(&config, &cwd, &["x"]);
357        assert_eq!(report.shadowed.len(), 0);
358        assert_eq!(report.config_dir, config.to_string_lossy());
359    }
360
361    #[test]
362    fn preflight_is_none_when_nothing_is_shadowed() {
363        let tmp = TempDir::new().unwrap();
364        let (config, cwd) = fresh(&tmp);
365        assert_eq!(shadow_preflight(&config, &cwd, &["mine"]), None);
366    }
367
368    #[test]
369    fn preflight_returns_the_report_when_a_skill_is_shadowed() {
370        let tmp = TempDir::new().unwrap();
371        let (config, cwd) = fresh(&tmp);
372        write_file(
373            &config.join("skills").join("my-skill").join("SKILL.md"),
374            "---\nname: my-skill\n---\n",
375        );
376        let report = shadow_preflight(&config, &cwd, &["my-skill"]).expect("skill is shadowed");
377        assert_eq!(report.shadowed.len(), 1);
378    }
379}