Skip to main content

eval_magic/adapters/opencode/
skill_shadow.rs

1//! Live-skill shadow detector and reporting for OpenCode.
2//!
3//! OpenCode discovers skills from more roots than any other built-in harness:
4//! project `.opencode/skills`, `.claude/skills`, and `.agents/skills` (walked
5//! up from the dispatch cwd to the git worktree), and global
6//! `$XDG_CONFIG_HOME/opencode/skills` (default `~/.config/opencode/skills`),
7//! `$OPENCODE_CONFIG_DIR/skills` (additive, not a replacement), the legacy
8//! `~/.opencode/skills`, `~/.claude/skills`, and `~/.agents/skills`. The
9//! `.claude`/`.agents` roots are a cross-harness contamination vector: a skill
10//! installed for Claude Code or Codex is visible to OpenCode sessions by
11//! default. A logical eval skill present in any of these sources contaminates
12//! the control arm even though eval-magic stages its test copy under a
13//! generated slug. Detection is deliberately best-effort: missing directories
14//! and unreadable/malformed skills are ignored while all other roots continue
15//! to be scanned.
16
17use std::collections::HashSet;
18use std::fs;
19use std::path::{Path, PathBuf};
20
21use crate::adapters::skill_shadow::{PluginShadowReport, ShadowSource};
22
23const ISOLATION_DOC: &str = "docs/opencode-notes.md → \"Isolating from live skills\"";
24
25fn env_path(name: &str) -> Option<PathBuf> {
26    std::env::var_os(name)
27        .filter(|value| !value.is_empty())
28        .map(PathBuf::from)
29}
30
31fn user_home() -> PathBuf {
32    env_path("HOME").unwrap_or_else(|| std::env::home_dir().unwrap_or_default())
33}
34
35fn unquote(value: &str) -> &str {
36    let value = value.trim();
37    if value.len() >= 2
38        && ((value.starts_with('"') && value.ends_with('"'))
39            || (value.starts_with('\'') && value.ends_with('\'')))
40    {
41        value[1..value.len() - 1].trim()
42    } else {
43        value
44    }
45}
46
47/// Read the top-level `name:` from a skill's YAML frontmatter. OpenCode keys
48/// discovery by this value, not necessarily by the enclosing folder name.
49fn frontmatter_name(skill_md: &Path) -> Option<String> {
50    let raw = fs::read_to_string(skill_md).ok()?;
51    let mut lines = raw.lines();
52    (lines.next()?.trim() == "---").then_some(())?;
53    let mut found = None;
54    for line in lines {
55        if line.trim() == "---" {
56            return found;
57        }
58        if line.starts_with(' ') || line.starts_with('\t') {
59            continue;
60        }
61        let Some((key, value)) = line.split_once(':') else {
62            continue;
63        };
64        if key.trim() == "name" {
65            let name = unquote(value);
66            found = (!name.is_empty()).then(|| name.to_string());
67        }
68    }
69    None
70}
71
72fn direct_skill_sources(dir: &Path) -> Vec<ShadowSource> {
73    let Ok(entries) = fs::read_dir(dir) else {
74        return Vec::new();
75    };
76    entries
77        .flatten()
78        .filter_map(|entry| {
79            let path = entry.path();
80            if !path.is_dir() {
81                return None;
82            }
83            let skill_name = frontmatter_name(&path.join("SKILL.md"))?;
84            Some(ShadowSource::GlobalSkill {
85                skill_name,
86                path: path.to_string_lossy().into_owned(),
87            })
88        })
89        .collect()
90}
91
92/// The three project skill dirs OpenCode checks at each level of the
93/// up-from-cwd walk.
94const PROJECT_SKILL_DIRS: [&str; 3] = [".opencode/skills", ".claude/skills", ".agents/skills"];
95
96fn repository_skill_dirs(scan_root: &Path) -> Vec<PathBuf> {
97    let Some(repo_root) = scan_root
98        .ancestors()
99        .find(|path| path.join(".git").exists())
100    else {
101        return Vec::new();
102    };
103    let mut dirs = Vec::new();
104    // Start at the parent: scan_root is the staged env itself, whose own
105    // skills dir holds the intentional staged copies.
106    let mut cursor = scan_root.parent();
107    while let Some(path) = cursor {
108        for sub in PROJECT_SKILL_DIRS {
109            dirs.push(path.join(sub));
110        }
111        if path == repo_root {
112            break;
113        }
114        cursor = path.parent();
115    }
116    dirs
117}
118
119/// The global skill roots: the xdg default config dir, `$OPENCODE_CONFIG_DIR`
120/// (additive), the legacy `~/.opencode`, and the two cross-harness dirs.
121fn global_skill_dirs(
122    home: &Path,
123    xdg_config_home: Option<&Path>,
124    opencode_config_dir: Option<&Path>,
125) -> Vec<PathBuf> {
126    let mut dirs = vec![default_config_dir(home, xdg_config_home).join("skills")];
127    if let Some(dir) = opencode_config_dir {
128        dirs.push(dir.join("skills"));
129    }
130    dirs.push(home.join(".opencode/skills"));
131    dirs.push(home.join(".claude/skills"));
132    dirs.push(home.join(".agents/skills"));
133    dirs
134}
135
136fn default_config_dir(home: &Path, xdg_config_home: Option<&Path>) -> PathBuf {
137    xdg_config_home
138        .map(Path::to_path_buf)
139        .unwrap_or_else(|| home.join(".config"))
140        .join("opencode")
141}
142
143fn sort_and_dedup(sources: &mut Vec<ShadowSource>) {
144    sources.sort_by_key(|source| match source {
145        ShadowSource::Plugin {
146            plugin,
147            skill_name,
148            path,
149        } => format!("plugin\0{plugin}\0{skill_name}\0{path}"),
150        ShadowSource::GlobalSkill { skill_name, path } => {
151            format!("skill\0{skill_name}\0{path}")
152        }
153    });
154    sources.dedup();
155}
156
157fn detect_with_sources(
158    scan_root: &Path,
159    staged_skill_names: &[&str],
160    home: &Path,
161    xdg_config_home: Option<&Path>,
162    opencode_config_dir: Option<&Path>,
163) -> PluginShadowReport {
164    let staged: HashSet<&str> = staged_skill_names.iter().copied().collect();
165    let mut dirs = repository_skill_dirs(scan_root);
166    dirs.extend(global_skill_dirs(
167        home,
168        xdg_config_home,
169        opencode_config_dir,
170    ));
171
172    let mut seen_dirs = HashSet::new();
173    let mut shadowed = Vec::new();
174    for dir in dirs {
175        if seen_dirs.insert(dir.clone()) {
176            shadowed.extend(direct_skill_sources(&dir));
177        }
178    }
179    shadowed.retain(|source| staged.contains(source.skill_name()));
180    sort_and_dedup(&mut shadowed);
181
182    PluginShadowReport {
183        config_dir: default_config_dir(home, xdg_config_home)
184            .to_string_lossy()
185            .into_owned(),
186        shadowed,
187    }
188}
189
190/// Detect logical eval skill names that OpenCode can also load from live
191/// sources.
192pub fn shadow_preflight(
193    scan_root: &Path,
194    staged_skill_names: &[&str],
195) -> Option<PluginShadowReport> {
196    let home = user_home();
197    let report = detect_with_sources(
198        scan_root,
199        staged_skill_names,
200        &home,
201        env_path("XDG_CONFIG_HOME").as_deref(),
202        env_path("OPENCODE_CONFIG_DIR").as_deref(),
203    );
204    (!report.shadowed.is_empty()).then_some(report)
205}
206
207fn source_label(source: &ShadowSource) -> String {
208    match source {
209        ShadowSource::Plugin { plugin, .. } => format!("enabled plugin '{plugin}'"),
210        ShadowSource::GlobalSkill { path, .. } => {
211            let parent = Path::new(path).parent().unwrap_or_else(|| Path::new(path));
212            format!("skill directory '{}'", parent.display())
213        }
214    }
215}
216
217/// One OpenCode-specific `validity_warnings` line per shadowed skill.
218pub fn shadow_validity_warnings(report: &PluginShadowReport) -> Vec<String> {
219    report
220        .shadowed
221        .iter()
222        .map(|source| {
223            format!(
224                "staged skill '{}' is also provided by {} — each `opencode run` dispatch could \
225                 discover both copies, so with/without results may be contaminated. Isolate the \
226                 live skill source before dispatch (see {}).",
227                source.skill_name(),
228                source_label(source),
229                ISOLATION_DOC
230            )
231        })
232        .collect()
233}
234
235/// OpenCode-specific build-time banner. Empty when nothing is shadowed.
236pub fn format_shadow_banner(report: &PluginShadowReport) -> String {
237    if report.shadowed.is_empty() {
238        return String::new();
239    }
240    let mut lines = vec![
241        String::new(),
242        "⚠ OpenCode skill-shadow warning: skills staged for this eval are ALSO discoverable"
243            .to_string(),
244        "  from your live environment:".to_string(),
245    ];
246    for source in &report.shadowed {
247        lines.push(format!(
248            "  • {} — {}",
249            source.skill_name(),
250            source_label(source)
251        ));
252    }
253    lines.extend([
254        "  Each `opencode run` dispatch discovers project and global .opencode, .claude,"
255            .to_string(),
256        "  and .agents skill dirs, so it can load both copies — the with/without".to_string(),
257        "  comparison may be contaminated and the control arm may not be skill-absent.".to_string(),
258        "  eval-magic cannot unload a live skill. Before dispatch:".to_string(),
259        "  1. Move or rename the conflicting skill directory.".to_string(),
260        "  2. For a .claude-root collision only: run the dispatch with".to_string(),
261        "     OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1.".to_string(),
262        "  3. For a .claude or .agents collision: OPENCODE_DISABLE_EXTERNAL_SKILLS=1".to_string(),
263        "     hides both cross-harness roots.".to_string(),
264        format!("  Full mechanics and detection limits: {ISOLATION_DOC}."),
265    ]);
266    lines.join("\n")
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::adapters::skill_shadow::ShadowSource;
273    use std::fs;
274    use std::path::{Path, PathBuf};
275    use tempfile::TempDir;
276
277    fn write_skill(path: &Path, name: &str) {
278        fs::create_dir_all(path).unwrap();
279        fs::write(
280            path.join("SKILL.md"),
281            format!("---\nname: '{name}'\ndescription: test\n---\n"),
282        )
283        .unwrap();
284    }
285
286    /// A git worktree (`repo/.git`) with a staged env nested inside it; the
287    /// shadow scan runs against the env root, exactly as `run` invokes it.
288    fn repo_with_env(tmp: &Path) -> (PathBuf, PathBuf) {
289        let repo = tmp.join("repo");
290        let scan_root = repo.join(".eval-magic/skill/iteration-1/env-g1-with_skill");
291        fs::create_dir_all(repo.join(".git")).unwrap();
292        fs::create_dir_all(&scan_root).unwrap();
293        (repo, scan_root)
294    }
295
296    fn detect(scan_root: &Path, home: &Path) -> PluginShadowReport {
297        detect_with_sources(scan_root, &["target-skill"], home, None, None)
298    }
299
300    #[test]
301    fn project_opencode_root_detects_collision() {
302        let tmp = TempDir::new().unwrap();
303        let (repo, scan_root) = repo_with_env(tmp.path());
304        write_skill(&repo.join(".opencode/skills/live-copy"), "target-skill");
305
306        let report = detect(&scan_root, &tmp.path().join("home"));
307
308        assert_eq!(report.shadowed.len(), 1);
309        assert!(
310            matches!(&report.shadowed[0], ShadowSource::GlobalSkill { path, .. } if path.ends_with("live-copy"))
311        );
312    }
313
314    #[test]
315    fn project_claude_root_detects_collision() {
316        let tmp = TempDir::new().unwrap();
317        let (repo, scan_root) = repo_with_env(tmp.path());
318        write_skill(&repo.join(".claude/skills/live-copy"), "target-skill");
319
320        let report = detect(&scan_root, &tmp.path().join("home"));
321
322        assert_eq!(report.shadowed.len(), 1);
323        assert!(
324            matches!(&report.shadowed[0], ShadowSource::GlobalSkill { path, .. } if path.ends_with("live-copy"))
325        );
326    }
327
328    #[test]
329    fn project_agents_root_detects_collision() {
330        let tmp = TempDir::new().unwrap();
331        let (repo, scan_root) = repo_with_env(tmp.path());
332        write_skill(&repo.join(".agents/skills/live-copy"), "target-skill");
333
334        let report = detect(&scan_root, &tmp.path().join("home"));
335
336        assert_eq!(report.shadowed.len(), 1);
337        assert!(
338            matches!(&report.shadowed[0], ShadowSource::GlobalSkill { path, .. } if path.ends_with("live-copy"))
339        );
340    }
341
342    #[test]
343    fn global_opencode_root_detects_collision() {
344        let tmp = TempDir::new().unwrap();
345        let home = tmp.path().join("home");
346        write_skill(
347            &home.join(".config/opencode/skills/live-copy"),
348            "target-skill",
349        );
350
351        let report = detect(tmp.path(), &home);
352
353        assert_eq!(report.shadowed.len(), 1);
354        assert!(
355            matches!(&report.shadowed[0], ShadowSource::GlobalSkill { path, .. } if path.ends_with("live-copy"))
356        );
357    }
358
359    #[test]
360    fn global_claude_root_detects_collision() {
361        let tmp = TempDir::new().unwrap();
362        let home = tmp.path().join("home");
363        write_skill(&home.join(".claude/skills/live-copy"), "target-skill");
364
365        let report = detect(tmp.path(), &home);
366
367        assert_eq!(report.shadowed.len(), 1);
368        assert!(
369            matches!(&report.shadowed[0], ShadowSource::GlobalSkill { path, .. } if path.ends_with("live-copy"))
370        );
371    }
372
373    #[test]
374    fn global_agents_root_detects_collision() {
375        let tmp = TempDir::new().unwrap();
376        let home = tmp.path().join("home");
377        write_skill(&home.join(".agents/skills/live-copy"), "target-skill");
378
379        let report = detect(tmp.path(), &home);
380
381        assert_eq!(report.shadowed.len(), 1);
382        assert!(
383            matches!(&report.shadowed[0], ShadowSource::GlobalSkill { path, .. } if path.ends_with("live-copy"))
384        );
385    }
386
387    #[test]
388    fn legacy_home_opencode_root_detects_collision() {
389        let tmp = TempDir::new().unwrap();
390        let home = tmp.path().join("home");
391        write_skill(&home.join(".opencode/skills/live-copy"), "target-skill");
392
393        let report = detect(tmp.path(), &home);
394
395        assert_eq!(report.shadowed.len(), 1);
396        assert!(
397            matches!(&report.shadowed[0], ShadowSource::GlobalSkill { path, .. } if path.ends_with("live-copy"))
398        );
399    }
400
401    #[test]
402    fn ancestor_walk_is_capped_at_the_git_worktree() {
403        let tmp = TempDir::new().unwrap();
404        let (repo, scan_root) = repo_with_env(tmp.path());
405        // Above the worktree (tmp has no .git): invisible to the walk.
406        write_skill(
407            &tmp.path().join(".claude/skills/above-worktree"),
408            "target-skill",
409        );
410        write_skill(&repo.join(".claude/skills/inside-worktree"), "target-skill");
411
412        let report = detect(&scan_root, &tmp.path().join("home"));
413
414        assert_eq!(report.shadowed.len(), 1);
415        assert!(
416            matches!(&report.shadowed[0], ShadowSource::GlobalSkill { path, .. } if path.ends_with("inside-worktree"))
417        );
418    }
419
420    #[test]
421    fn staged_env_root_itself_is_not_scanned() {
422        let tmp = TempDir::new().unwrap();
423        let (_repo, scan_root) = repo_with_env(tmp.path());
424        // The env's own staged copy is intentional, not contamination.
425        write_skill(
426            &scan_root.join(".opencode/skills/staged-copy"),
427            "target-skill",
428        );
429
430        let report = detect(&scan_root, &tmp.path().join("home"));
431
432        assert!(report.shadowed.is_empty());
433    }
434
435    #[test]
436    fn opencode_config_dir_is_scanned_additively() {
437        let tmp = TempDir::new().unwrap();
438        let home = tmp.path().join("home");
439        let override_dir = tmp.path().join("opencode-config");
440        write_skill(
441            &home.join(".config/opencode/skills/default-copy"),
442            "target-skill",
443        );
444        write_skill(&override_dir.join("skills/override-copy"), "target-skill");
445
446        let report = detect_with_sources(
447            tmp.path(),
448            &["target-skill"],
449            &home,
450            None,
451            Some(&override_dir),
452        );
453
454        assert_eq!(report.shadowed.len(), 2);
455    }
456
457    #[test]
458    fn xdg_config_home_redirects_the_default_global_dir() {
459        let tmp = TempDir::new().unwrap();
460        let home = tmp.path().join("home");
461        let xdg = tmp.path().join("xdg");
462        write_skill(&xdg.join("opencode/skills/xdg-copy"), "target-skill");
463        // With XDG_CONFIG_HOME set, the non-xdg default is not scanned.
464        write_skill(
465            &home.join(".config/opencode/skills/non-xdg-copy"),
466            "target-skill",
467        );
468
469        let report = detect_with_sources(tmp.path(), &["target-skill"], &home, Some(&xdg), None);
470
471        assert_eq!(report.shadowed.len(), 1);
472        assert!(
473            matches!(&report.shadowed[0], ShadowSource::GlobalSkill { path, .. } if path.ends_with("xdg-copy"))
474        );
475    }
476
477    #[test]
478    fn repeated_roots_are_reported_once() {
479        let tmp = TempDir::new().unwrap();
480        let home = tmp.path().join("home");
481        let default = home.join(".config/opencode");
482        write_skill(&default.join("skills/live-copy"), "target-skill");
483
484        // OPENCODE_CONFIG_DIR pointing at the default dir must not double-report.
485        let report =
486            detect_with_sources(tmp.path(), &["target-skill"], &home, None, Some(&default));
487
488        assert_eq!(report.shadowed.len(), 1);
489    }
490
491    #[test]
492    fn direct_scan_uses_frontmatter_name_not_folder_name() {
493        let tmp = TempDir::new().unwrap();
494        let home = tmp.path().join("home");
495        write_skill(
496            &home.join(".agents/skills/different-folder"),
497            "target-skill",
498        );
499
500        let report = detect(tmp.path(), &home);
501
502        assert_eq!(report.shadowed.len(), 1);
503        assert_eq!(report.shadowed[0].skill_name(), "target-skill");
504    }
505
506    #[test]
507    fn malformed_skills_do_not_create_false_reports() {
508        let tmp = TempDir::new().unwrap();
509        let home = tmp.path().join("home");
510        let no_frontmatter = home.join(".agents/skills/plain");
511        fs::create_dir_all(&no_frontmatter).unwrap();
512        fs::write(no_frontmatter.join("SKILL.md"), "name: target-skill\n").unwrap();
513        let unclosed = home.join(".agents/skills/unclosed");
514        fs::create_dir_all(&unclosed).unwrap();
515        fs::write(unclosed.join("SKILL.md"), "---\nname: target-skill\n").unwrap();
516
517        let report = detect(tmp.path(), &home);
518
519        assert!(report.shadowed.is_empty());
520    }
521
522    #[test]
523    fn non_staged_skill_names_are_not_reported() {
524        let tmp = TempDir::new().unwrap();
525        let home = tmp.path().join("home");
526        write_skill(&home.join(".agents/skills/live-copy"), "some-other-skill");
527
528        let report = detect(tmp.path(), &home);
529
530        assert!(report.shadowed.is_empty());
531    }
532
533    fn sample_report() -> PluginShadowReport {
534        PluginShadowReport {
535            config_dir: "/home/u/.config/opencode".into(),
536            shadowed: vec![ShadowSource::GlobalSkill {
537                skill_name: "target-skill".into(),
538                path: "/home/u/.claude/skills/target-skill".into(),
539            }],
540        }
541    }
542
543    #[test]
544    fn banner_is_empty_when_nothing_shadowed() {
545        let empty = PluginShadowReport {
546            config_dir: "/x".into(),
547            shadowed: vec![],
548        };
549        assert_eq!(format_shadow_banner(&empty), "");
550    }
551
552    #[test]
553    fn banner_lists_findings_remediation_and_isolation_doc() {
554        let banner = format_shadow_banner(&sample_report());
555        assert!(banner.contains("target-skill"), "{banner}");
556        assert!(banner.contains(".claude/skills"), "{banner}");
557        assert!(banner.contains("opencode run"), "{banner}");
558        assert!(
559            banner.contains("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS"),
560            "banner names the .claude-root kill switch: {banner}"
561        );
562        assert!(
563            banner.contains("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
564            "banner names the external-roots kill switch: {banner}"
565        );
566        assert!(banner.contains("docs/opencode-notes.md"), "{banner}");
567    }
568
569    #[test]
570    fn validity_warnings_name_skill_source_contamination_and_doc() {
571        let warnings = shadow_validity_warnings(&sample_report());
572        assert_eq!(warnings.len(), 1);
573        assert!(warnings[0].contains("target-skill"));
574        assert!(warnings[0].contains(".claude/skills"));
575        assert!(warnings[0].contains("opencode run"));
576        assert!(warnings[0].to_lowercase().contains("contaminat"));
577        assert!(warnings[0].contains("docs/opencode-notes.md"));
578    }
579}