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