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    if repo_root == scan_root {
132        return Vec::new();
133    }
134    let mut dirs = Vec::new();
135    let mut cursor = scan_root.parent();
136    while let Some(path) = cursor {
137        dirs.push(path.join(".agents/skills"));
138        if path == repo_root {
139            break;
140        }
141        cursor = path.parent();
142    }
143    dirs
144}
145
146fn plugin_skill_sources(codex_home: &Path, raw: Option<&str>) -> Vec<ShadowSource> {
147    let Some(list) = raw.and_then(|json| serde_json::from_str::<PluginList>(json).ok()) else {
148        return Vec::new();
149    };
150    let mut out = Vec::new();
151    for plugin in list
152        .installed
153        .into_iter()
154        .filter(|plugin| plugin.installed && plugin.enabled)
155    {
156        let label = plugin
157            .plugin_id
158            .unwrap_or_else(|| format!("{}@{}", plugin.name, plugin.marketplace_name));
159        let skills_dir = codex_home
160            .join("plugins/cache")
161            .join(&plugin.marketplace_name)
162            .join(&plugin.name)
163            .join(&plugin.version)
164            .join("skills");
165        for source in direct_skill_sources(&skills_dir) {
166            if let ShadowSource::GlobalSkill { skill_name, path } = source {
167                out.push(ShadowSource::Plugin {
168                    plugin: label.clone(),
169                    skill_name,
170                    path,
171                });
172            }
173        }
174    }
175    out
176}
177
178fn sort_and_dedup(sources: &mut Vec<ShadowSource>) {
179    sources.sort_by_key(|source| match source {
180        ShadowSource::Plugin {
181            plugin,
182            skill_name,
183            path,
184        } => format!("plugin\0{plugin}\0{skill_name}\0{path}"),
185        ShadowSource::GlobalSkill { skill_name, path } => {
186            format!("skill\0{skill_name}\0{path}")
187        }
188    });
189    sources.dedup();
190}
191
192fn detect_with_sources(
193    scan_root: &Path,
194    staged_skill_names: &[&str],
195    home: &Path,
196    codex_home: &Path,
197    admin_skills: &Path,
198    plugin_json: Option<&str>,
199) -> PluginShadowReport {
200    let staged: HashSet<&str> = staged_skill_names.iter().copied().collect();
201    let mut dirs = repository_skill_dirs(scan_root);
202    dirs.push(home.join(".agents/skills"));
203    dirs.push(admin_skills.to_path_buf());
204
205    let mut seen_dirs = HashSet::new();
206    let mut shadowed = Vec::new();
207    for dir in dirs {
208        if seen_dirs.insert(dir.clone()) {
209            shadowed.extend(direct_skill_sources(&dir));
210        }
211    }
212    shadowed.extend(plugin_skill_sources(codex_home, plugin_json));
213    shadowed.retain(|source| staged.contains(source.skill_name()));
214    sort_and_dedup(&mut shadowed);
215
216    PluginShadowReport {
217        config_dir: codex_home.to_string_lossy().into_owned(),
218        shadowed,
219    }
220}
221
222/// Detect logical eval skill names that Codex can also load from live sources.
223pub fn shadow_preflight(
224    scan_root: &Path,
225    staged_skill_names: &[&str],
226) -> Option<PluginShadowReport> {
227    let home = user_home();
228    let codex_home = codex_home(&home);
229    let plugin_json = plugin_list_json();
230    let report = detect_with_sources(
231        scan_root,
232        staged_skill_names,
233        &home,
234        &codex_home,
235        Path::new("/etc/codex/skills"),
236        plugin_json.as_deref(),
237    );
238    (!report.shadowed.is_empty()).then_some(report)
239}
240
241fn source_label(source: &ShadowSource) -> String {
242    match source {
243        ShadowSource::Plugin { plugin, .. } => format!("enabled Codex plugin '{plugin}'"),
244        ShadowSource::GlobalSkill { path, .. } => {
245            let parent = Path::new(path).parent().unwrap_or_else(|| Path::new(path));
246            format!("Codex skill directory '{}'", parent.display())
247        }
248    }
249}
250
251/// One Codex-specific `validity_warnings` line per shadowed skill.
252pub fn shadow_validity_warnings(report: &PluginShadowReport) -> Vec<String> {
253    report
254        .shadowed
255        .iter()
256        .map(|source| match source {
257            ShadowSource::Plugin { .. } => format!(
258                "staged skill '{}' is also provided by {} — each `codex exec` dispatch could \
259                     discover both copies. Add `--disable plugins` to every eval-agent `codex \
260                     exec`; eval-magic does not record manual launch arguments, so this retained \
261                     contamination warning is conservative when the flag was applied consistently \
262                     (see {}).",
263                source.skill_name(),
264                source_label(source),
265                ISOLATION_DOC
266            ),
267            ShadowSource::GlobalSkill { .. } => format!(
268                "staged skill '{}' is also provided by {} — each `codex exec` dispatch could \
269                     discover both copies, so with/without results may be contaminated. Before \
270                     dispatch, move or rename the live repo, user, or admin skill (see {}).",
271                source.skill_name(),
272                source_label(source),
273                ISOLATION_DOC
274            ),
275        })
276        .collect()
277}
278
279/// Codex-specific build-time banner. Empty when nothing is shadowed.
280pub fn format_shadow_banner(report: &PluginShadowReport) -> String {
281    if report.shadowed.is_empty() {
282        return String::new();
283    }
284    let mut lines = vec![
285        String::new(),
286        "⚠ Codex skill-shadow warning: skills staged for this eval are ALSO discoverable"
287            .to_string(),
288        "  from your live Codex environment:".to_string(),
289    ];
290    for source in &report.shadowed {
291        lines.push(format!(
292            "  • {} — {}",
293            source.skill_name(),
294            source_label(source)
295        ));
296    }
297    lines.extend([
298        "  Each `codex exec` dispatch can load both copies, so the with/without".to_string(),
299        "  comparison may be contaminated and the control arm may not be skill-absent.".to_string(),
300    ]);
301    if report
302        .shadowed
303        .iter()
304        .any(|source| matches!(source, ShadowSource::Plugin { .. }))
305    {
306        lines.extend([
307            "  For plugin collisions, add `--disable plugins` to every eval-agent `codex exec`"
308                .to_string(),
309            "  invocation, including resumed turns. This disables installed plugins for that run,"
310                .to_string(),
311            "  but not repo, user, or admin skill directories.".to_string(),
312            "  Because manual launch arguments are not recorded, the preflight artifact and"
313                .to_string(),
314            "  aggregate warnings remain conservative.".to_string(),
315        ]);
316    }
317    if report
318        .shadowed
319        .iter()
320        .any(|source| matches!(source, ShadowSource::GlobalSkill { .. }))
321    {
322        lines.extend([
323            "  For direct skill collisions, move or rename the conflicting repo, user, or admin"
324                .to_string(),
325            "  `.agents/skills` entry. For user skills only, a clean `HOME` can isolate the source."
326                .to_string(),
327        ]);
328    }
329    lines.push(format!(
330        "  Full mechanics and detection limits: {ISOLATION_DOC}."
331    ));
332    lines.join("\n")
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use serde_json::json;
339    use tempfile::TempDir;
340
341    fn write_skill(path: &Path, name: &str) {
342        fs::create_dir_all(path).unwrap();
343        fs::write(
344            path.join("SKILL.md"),
345            format!("---\nname: '{name}'\ndescription: test\n---\n"),
346        )
347        .unwrap();
348    }
349
350    #[test]
351    fn direct_scan_uses_frontmatter_name_and_skips_staged_env() {
352        let tmp = TempDir::new().unwrap();
353        let repo = tmp.path().join("repo");
354        let scan_root = repo.join(".eval-magic/skill/iteration-1/env-g1-with_skill");
355        fs::create_dir_all(repo.join(".git")).unwrap();
356        write_skill(
357            &repo.join(".agents/skills/different-folder"),
358            "target-skill",
359        );
360        write_skill(
361            &scan_root.join(".agents/skills/staged-copy"),
362            "target-skill",
363        );
364
365        let report = detect_with_sources(
366            &scan_root,
367            &["target-skill"],
368            &tmp.path().join("home"),
369            &tmp.path().join("codex-home"),
370            &tmp.path().join("etc-skills"),
371            None,
372        );
373
374        assert_eq!(report.shadowed.len(), 1);
375        assert!(matches!(
376            &report.shadowed[0],
377            ShadowSource::GlobalSkill { path, .. } if path.ends_with("different-folder")
378        ));
379    }
380
381    #[test]
382    fn enabled_plugin_scan_uses_installed_cache_layout() {
383        let tmp = TempDir::new().unwrap();
384        let codex_home = tmp.path().join("codex-home");
385        let skill = codex_home.join("plugins/cache/slowdini/slow-powers/0.5.3/skills/review");
386        write_skill(&skill, "mr-review");
387        let plugin_json = json!({
388            "installed": [
389                {
390                    "pluginId": "slow-powers@slowdini",
391                    "name": "slow-powers",
392                    "marketplaceName": "slowdini",
393                    "version": "0.5.3",
394                    "installed": true,
395                    "enabled": true
396                },
397                {
398                    "pluginId": "disabled@slowdini",
399                    "name": "slow-powers",
400                    "marketplaceName": "slowdini",
401                    "version": "0.5.3",
402                    "installed": true,
403                    "enabled": false
404                }
405            ]
406        })
407        .to_string();
408
409        let report = detect_with_sources(
410            tmp.path(),
411            &["mr-review"],
412            &tmp.path().join("home"),
413            &codex_home,
414            &tmp.path().join("etc-skills"),
415            Some(&plugin_json),
416        );
417
418        assert_eq!(report.shadowed.len(), 1);
419        assert!(matches!(
420            &report.shadowed[0],
421            ShadowSource::Plugin { plugin, skill_name, .. }
422                if plugin == "slow-powers@slowdini" && skill_name == "mr-review"
423        ));
424    }
425
426    #[test]
427    fn invalid_plugin_list_does_not_hide_direct_skills() {
428        let tmp = TempDir::new().unwrap();
429        let home = tmp.path().join("home");
430        write_skill(&home.join(".agents/skills/review"), "mr-review");
431
432        let report = detect_with_sources(
433            tmp.path(),
434            &["mr-review"],
435            &home,
436            &tmp.path().join("codex-home"),
437            &tmp.path().join("etc-skills"),
438            Some("not json"),
439        );
440
441        assert_eq!(report.shadowed.len(), 1);
442        assert!(matches!(
443            report.shadowed[0],
444            ShadowSource::GlobalSkill { .. }
445        ));
446    }
447
448    #[test]
449    fn malformed_skills_do_not_create_false_reports() {
450        let tmp = TempDir::new().unwrap();
451        let home = tmp.path().join("home");
452        let malformed = home.join(".agents/skills/review");
453        fs::create_dir_all(&malformed).unwrap();
454        fs::write(malformed.join("SKILL.md"), "name: mr-review\n").unwrap();
455        let unclosed = home.join(".agents/skills/unclosed");
456        fs::create_dir_all(&unclosed).unwrap();
457        fs::write(unclosed.join("SKILL.md"), "---\nname: mr-review\n").unwrap();
458
459        let report = detect_with_sources(
460            tmp.path(),
461            &["mr-review"],
462            &home,
463            &tmp.path().join("codex-home"),
464            &tmp.path().join("etc-skills"),
465            None,
466        );
467
468        assert!(report.shadowed.is_empty());
469    }
470
471    #[test]
472    fn plugin_shadow_guidance_names_runtime_disable_and_conservative_warning() {
473        let report = PluginShadowReport {
474            config_dir: "/codex".into(),
475            shadowed: vec![ShadowSource::Plugin {
476                plugin: "slow-powers@slowdini".into(),
477                skill_name: "mr-review".into(),
478                path: "/codex/plugins/cache/slowdini/slow-powers/1/skills/mr-review".into(),
479            }],
480        };
481
482        let banner = format_shadow_banner(&report);
483        assert!(banner.contains("`--disable plugins`"), "{banner}");
484        assert!(banner.contains("every eval-agent `codex exec`"), "{banner}");
485        assert!(
486            banner.contains("repo, user, or admin skill directories"),
487            "{banner}"
488        );
489        assert!(banner.contains("not recorded"), "{banner}");
490
491        let warning = shadow_validity_warnings(&report).join("\n");
492        assert!(warning.contains("`--disable plugins`"), "{warning}");
493        assert!(warning.contains("conservative"), "{warning}");
494    }
495
496    #[test]
497    fn direct_skill_shadow_guidance_does_not_recommend_plugin_disable() {
498        let report = PluginShadowReport {
499            config_dir: "/codex".into(),
500            shadowed: vec![ShadowSource::GlobalSkill {
501                skill_name: "mr-review".into(),
502                path: "/repo/.agents/skills/mr-review".into(),
503            }],
504        };
505
506        let warning = shadow_validity_warnings(&report).join("\n");
507        assert!(!warning.contains("--disable plugins"), "{warning}");
508        assert!(warning.contains("move or rename"), "{warning}");
509    }
510}