Skip to main content

eval_magic/adapters/codex/
skill_shadow.rs

1//! Live-skill shadow detector and reporting for Codex.
2//!
3//! Codex discovers skills from repository-ancestor `.agents/skills`
4//! directories, the user's `~/.agents/skills`, `/etc/codex/skills`, and
5//! enabled installed plugins. A logical eval skill present in any of those
6//! sources contaminates the control arm even when eval-magic stages its test
7//! copy under a generated name. Detection is deliberately best-effort:
8//! unreadable/malformed skills and an unavailable or invalid plugin listing
9//! are ignored, while all other sources continue to be scanned.
10
11use std::collections::HashSet;
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::process::Command;
15
16use serde::Deserialize;
17
18use crate::adapters::skill_shadow::{PluginShadowReport, ShadowSource};
19
20const ISOLATION_DOC: &str = "docs/codex-notes.md → \"Isolating from live skills and plugins\"";
21
22#[derive(Debug, Deserialize)]
23#[serde(rename_all = "camelCase")]
24struct PluginList {
25    #[serde(default)]
26    installed: Vec<InstalledPlugin>,
27}
28
29#[derive(Debug, Deserialize)]
30#[serde(rename_all = "camelCase")]
31struct InstalledPlugin {
32    plugin_id: Option<String>,
33    name: String,
34    marketplace_name: String,
35    version: String,
36    #[serde(default)]
37    installed: bool,
38    #[serde(default)]
39    enabled: bool,
40}
41
42fn env_path(name: &str) -> Option<PathBuf> {
43    std::env::var_os(name)
44        .filter(|value| !value.is_empty())
45        .map(PathBuf::from)
46}
47
48fn user_home() -> PathBuf {
49    env_path("HOME").unwrap_or_else(|| std::env::home_dir().unwrap_or_default())
50}
51
52fn codex_home(home: &Path) -> PathBuf {
53    env_path("CODEX_HOME").unwrap_or_else(|| home.join(".codex"))
54}
55
56fn plugin_list_json() -> Option<String> {
57    let output = Command::new("codex")
58        .args(["plugin", "list", "--json"])
59        .output()
60        .ok()?;
61    if !output.status.success() {
62        return None;
63    }
64    String::from_utf8(output.stdout).ok()
65}
66
67fn unquote(value: &str) -> &str {
68    let value = value.trim();
69    if value.len() >= 2
70        && ((value.starts_with('"') && value.ends_with('"'))
71            || (value.starts_with('\'') && value.ends_with('\'')))
72    {
73        value[1..value.len() - 1].trim()
74    } else {
75        value
76    }
77}
78
79/// Read the top-level `name:` from a skill's YAML frontmatter. Codex keys
80/// discovery by this value, not necessarily by the enclosing folder name.
81fn frontmatter_name(skill_md: &Path) -> Option<String> {
82    let raw = fs::read_to_string(skill_md).ok()?;
83    let mut lines = raw.lines();
84    (lines.next()?.trim() == "---").then_some(())?;
85    let mut found = None;
86    for line in lines {
87        if line.trim() == "---" {
88            return found;
89        }
90        if line.starts_with(' ') || line.starts_with('\t') {
91            continue;
92        }
93        let Some((key, value)) = line.split_once(':') else {
94            continue;
95        };
96        if key.trim() == "name" {
97            let name = unquote(value);
98            found = (!name.is_empty()).then(|| name.to_string());
99        }
100    }
101    None
102}
103
104fn direct_skill_sources(dir: &Path) -> Vec<ShadowSource> {
105    let Ok(entries) = fs::read_dir(dir) else {
106        return Vec::new();
107    };
108    entries
109        .flatten()
110        .filter_map(|entry| {
111            let path = entry.path();
112            if !path.is_dir() {
113                return None;
114            }
115            let skill_name = frontmatter_name(&path.join("SKILL.md"))?;
116            Some(ShadowSource::GlobalSkill {
117                skill_name,
118                path: path.to_string_lossy().into_owned(),
119            })
120        })
121        .collect()
122}
123
124fn repository_skill_dirs(scan_root: &Path) -> Vec<PathBuf> {
125    let Some(repo_root) = scan_root
126        .ancestors()
127        .find(|path| path.join(".git").exists())
128    else {
129        return Vec::new();
130    };
131    let mut dirs = Vec::new();
132    let mut cursor = scan_root.parent();
133    while let Some(path) = cursor {
134        dirs.push(path.join(".agents/skills"));
135        if path == repo_root {
136            break;
137        }
138        cursor = path.parent();
139    }
140    dirs
141}
142
143fn plugin_skill_sources(codex_home: &Path, raw: Option<&str>) -> Vec<ShadowSource> {
144    let Some(list) = raw.and_then(|json| serde_json::from_str::<PluginList>(json).ok()) else {
145        return Vec::new();
146    };
147    let mut out = Vec::new();
148    for plugin in list
149        .installed
150        .into_iter()
151        .filter(|plugin| plugin.installed && plugin.enabled)
152    {
153        let label = plugin
154            .plugin_id
155            .unwrap_or_else(|| format!("{}@{}", plugin.name, plugin.marketplace_name));
156        let skills_dir = codex_home
157            .join("plugins/cache")
158            .join(&plugin.marketplace_name)
159            .join(&plugin.name)
160            .join(&plugin.version)
161            .join("skills");
162        for source in direct_skill_sources(&skills_dir) {
163            if let ShadowSource::GlobalSkill { skill_name, path } = source {
164                out.push(ShadowSource::Plugin {
165                    plugin: label.clone(),
166                    skill_name,
167                    path,
168                });
169            }
170        }
171    }
172    out
173}
174
175fn sort_and_dedup(sources: &mut Vec<ShadowSource>) {
176    sources.sort_by_key(|source| match source {
177        ShadowSource::Plugin {
178            plugin,
179            skill_name,
180            path,
181        } => format!("plugin\0{plugin}\0{skill_name}\0{path}"),
182        ShadowSource::GlobalSkill { skill_name, path } => {
183            format!("skill\0{skill_name}\0{path}")
184        }
185    });
186    sources.dedup();
187}
188
189fn detect_with_sources(
190    scan_root: &Path,
191    staged_skill_names: &[&str],
192    home: &Path,
193    codex_home: &Path,
194    admin_skills: &Path,
195    plugin_json: Option<&str>,
196) -> PluginShadowReport {
197    let staged: HashSet<&str> = staged_skill_names.iter().copied().collect();
198    let mut dirs = repository_skill_dirs(scan_root);
199    dirs.push(home.join(".agents/skills"));
200    dirs.push(admin_skills.to_path_buf());
201
202    let mut seen_dirs = HashSet::new();
203    let mut shadowed = Vec::new();
204    for dir in dirs {
205        if seen_dirs.insert(dir.clone()) {
206            shadowed.extend(direct_skill_sources(&dir));
207        }
208    }
209    shadowed.extend(plugin_skill_sources(codex_home, plugin_json));
210    shadowed.retain(|source| staged.contains(source.skill_name()));
211    sort_and_dedup(&mut shadowed);
212
213    PluginShadowReport {
214        config_dir: codex_home.to_string_lossy().into_owned(),
215        shadowed,
216    }
217}
218
219/// Detect logical eval skill names that Codex can also load from live sources.
220pub fn shadow_preflight(
221    scan_root: &Path,
222    staged_skill_names: &[&str],
223) -> Option<PluginShadowReport> {
224    let home = user_home();
225    let codex_home = codex_home(&home);
226    let plugin_json = plugin_list_json();
227    let report = detect_with_sources(
228        scan_root,
229        staged_skill_names,
230        &home,
231        &codex_home,
232        Path::new("/etc/codex/skills"),
233        plugin_json.as_deref(),
234    );
235    (!report.shadowed.is_empty()).then_some(report)
236}
237
238fn source_label(source: &ShadowSource) -> String {
239    match source {
240        ShadowSource::Plugin { plugin, .. } => format!("enabled Codex plugin '{plugin}'"),
241        ShadowSource::GlobalSkill { path, .. } => {
242            let parent = Path::new(path).parent().unwrap_or_else(|| Path::new(path));
243            format!("Codex skill directory '{}'", parent.display())
244        }
245    }
246}
247
248/// One Codex-specific `validity_warnings` line per shadowed skill.
249pub fn shadow_validity_warnings(report: &PluginShadowReport) -> Vec<String> {
250    report
251        .shadowed
252        .iter()
253        .map(|source| {
254            format!(
255                "staged skill '{}' is also provided by {} — each `codex exec` dispatch could \
256                 discover both copies, so with/without results may be contaminated. Isolate the \
257                 live Codex skill source before dispatch (see {}).",
258                source.skill_name(),
259                source_label(source),
260                ISOLATION_DOC
261            )
262        })
263        .collect()
264}
265
266/// Codex-specific build-time banner. Empty when nothing is shadowed.
267pub fn format_shadow_banner(report: &PluginShadowReport) -> String {
268    if report.shadowed.is_empty() {
269        return String::new();
270    }
271    let mut lines = vec![
272        String::new(),
273        "⚠ Codex skill-shadow warning: skills staged for this eval are ALSO discoverable"
274            .to_string(),
275        "  from your live Codex environment:".to_string(),
276    ];
277    for source in &report.shadowed {
278        lines.push(format!(
279            "  • {} — {}",
280            source.skill_name(),
281            source_label(source)
282        ));
283    }
284    lines.extend([
285        "  Each `codex exec` dispatch can load both copies, so the with/without".to_string(),
286        "  comparison may be contaminated and the control arm may not be skill-absent.".to_string(),
287        "  eval-magic cannot unload a live Codex skill or plugin. Before dispatch:".to_string(),
288        "  1. Disable a conflicting installed plugin from Codex's `/plugins` UI.".to_string(),
289        "  2. Move or rename a conflicting repo, user, or admin `.agents/skills` entry."
290            .to_string(),
291        "  3. For user skills only, use a clean `HOME` while preserving `CODEX_HOME`.".to_string(),
292        format!("  Full mechanics and detection limits: {ISOLATION_DOC}."),
293    ]);
294    lines.join("\n")
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use serde_json::json;
301    use tempfile::TempDir;
302
303    fn write_skill(path: &Path, name: &str) {
304        fs::create_dir_all(path).unwrap();
305        fs::write(
306            path.join("SKILL.md"),
307            format!("---\nname: '{name}'\ndescription: test\n---\n"),
308        )
309        .unwrap();
310    }
311
312    #[test]
313    fn direct_scan_uses_frontmatter_name_and_skips_staged_env() {
314        let tmp = TempDir::new().unwrap();
315        let repo = tmp.path().join("repo");
316        let scan_root = repo.join(".eval-magic/skill/iteration-1/env-g1-with_skill");
317        fs::create_dir_all(repo.join(".git")).unwrap();
318        write_skill(
319            &repo.join(".agents/skills/different-folder"),
320            "target-skill",
321        );
322        write_skill(
323            &scan_root.join(".agents/skills/staged-copy"),
324            "target-skill",
325        );
326
327        let report = detect_with_sources(
328            &scan_root,
329            &["target-skill"],
330            &tmp.path().join("home"),
331            &tmp.path().join("codex-home"),
332            &tmp.path().join("etc-skills"),
333            None,
334        );
335
336        assert_eq!(report.shadowed.len(), 1);
337        assert!(matches!(
338            &report.shadowed[0],
339            ShadowSource::GlobalSkill { path, .. } if path.ends_with("different-folder")
340        ));
341    }
342
343    #[test]
344    fn enabled_plugin_scan_uses_installed_cache_layout() {
345        let tmp = TempDir::new().unwrap();
346        let codex_home = tmp.path().join("codex-home");
347        let skill = codex_home.join("plugins/cache/slowdini/slow-powers/0.5.3/skills/review");
348        write_skill(&skill, "mr-review");
349        let plugin_json = json!({
350            "installed": [
351                {
352                    "pluginId": "slow-powers@slowdini",
353                    "name": "slow-powers",
354                    "marketplaceName": "slowdini",
355                    "version": "0.5.3",
356                    "installed": true,
357                    "enabled": true
358                },
359                {
360                    "pluginId": "disabled@slowdini",
361                    "name": "slow-powers",
362                    "marketplaceName": "slowdini",
363                    "version": "0.5.3",
364                    "installed": true,
365                    "enabled": false
366                }
367            ]
368        })
369        .to_string();
370
371        let report = detect_with_sources(
372            tmp.path(),
373            &["mr-review"],
374            &tmp.path().join("home"),
375            &codex_home,
376            &tmp.path().join("etc-skills"),
377            Some(&plugin_json),
378        );
379
380        assert_eq!(report.shadowed.len(), 1);
381        assert!(matches!(
382            &report.shadowed[0],
383            ShadowSource::Plugin { plugin, skill_name, .. }
384                if plugin == "slow-powers@slowdini" && skill_name == "mr-review"
385        ));
386    }
387
388    #[test]
389    fn invalid_plugin_list_does_not_hide_direct_skills() {
390        let tmp = TempDir::new().unwrap();
391        let home = tmp.path().join("home");
392        write_skill(&home.join(".agents/skills/review"), "mr-review");
393
394        let report = detect_with_sources(
395            tmp.path(),
396            &["mr-review"],
397            &home,
398            &tmp.path().join("codex-home"),
399            &tmp.path().join("etc-skills"),
400            Some("not json"),
401        );
402
403        assert_eq!(report.shadowed.len(), 1);
404        assert!(matches!(
405            report.shadowed[0],
406            ShadowSource::GlobalSkill { .. }
407        ));
408    }
409
410    #[test]
411    fn malformed_skills_do_not_create_false_reports() {
412        let tmp = TempDir::new().unwrap();
413        let home = tmp.path().join("home");
414        let malformed = home.join(".agents/skills/review");
415        fs::create_dir_all(&malformed).unwrap();
416        fs::write(malformed.join("SKILL.md"), "name: mr-review\n").unwrap();
417        let unclosed = home.join(".agents/skills/unclosed");
418        fs::create_dir_all(&unclosed).unwrap();
419        fs::write(unclosed.join("SKILL.md"), "---\nname: mr-review\n").unwrap();
420
421        let report = detect_with_sources(
422            tmp.path(),
423            &["mr-review"],
424            &home,
425            &tmp.path().join("codex-home"),
426            &tmp.path().join("etc-skills"),
427            None,
428        );
429
430        assert!(report.shadowed.is_empty());
431    }
432}