eval_magic/adapters/codex/
skill_shadow.rs1use 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
79fn 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
219pub 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
248pub 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
266pub 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}