1use 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 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
222pub 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
251pub 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
279pub 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}