1use serde::de::DeserializeOwned;
13use serde::{Deserialize, Serialize};
14use std::collections::{HashMap, HashSet};
15use std::fs;
16use std::path::{Path, PathBuf};
17
18const ISOLATION_DOC: &str = "docs/harness-claude-code.md → \"Isolating from installed plugins\"";
19
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22#[serde(tag = "kind", rename_all = "kebab-case")]
23pub enum ShadowSource {
24 Plugin {
25 plugin: String,
26 skill_name: String,
27 path: String,
28 },
29 GlobalSkill {
30 skill_name: String,
31 path: String,
32 },
33}
34
35impl ShadowSource {
36 fn skill_name(&self) -> &str {
37 match self {
38 ShadowSource::Plugin { skill_name, .. } => skill_name,
39 ShadowSource::GlobalSkill { skill_name, .. } => skill_name,
40 }
41 }
42
43 fn source_label(&self) -> String {
44 match self {
45 ShadowSource::Plugin { plugin, .. } => format!("enabled plugin '{plugin}'"),
46 ShadowSource::GlobalSkill { .. } => "the global skills dir".to_string(),
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53pub struct PluginShadowReport {
54 pub config_dir: String,
55 pub shadowed: Vec<ShadowSource>,
56}
57
58pub fn resolve_config_dir(config_dir_override: Option<&str>) -> PathBuf {
61 match config_dir_override {
62 Some(o) if !o.trim().is_empty() => PathBuf::from(o),
63 _ => std::env::home_dir().unwrap_or_default().join(".claude"),
64 }
65}
66
67pub fn config_dir_from_env() -> PathBuf {
72 resolve_config_dir(std::env::var("CLAUDE_CONFIG_DIR").ok().as_deref())
73}
74
75fn read_json_safe<T: DeserializeOwned>(path: &Path) -> Option<T> {
76 let raw = fs::read_to_string(path).ok()?;
77 serde_json::from_str(&raw).ok()
78}
79
80#[derive(Debug, Deserialize)]
81struct Settings {
82 #[serde(rename = "enabledPlugins")]
83 enabled_plugins: Option<HashMap<String, bool>>,
84}
85
86fn resolve_enabled_plugins(config_dir: &Path, cwd: &Path) -> HashMap<String, bool> {
90 let sources = [
91 config_dir.join("settings.json"),
92 cwd.join(".claude").join("settings.json"),
93 cwd.join(".claude").join("settings.local.json"),
94 ];
95 let mut merged = HashMap::new();
96 for path in sources {
97 if let Some(s) = read_json_safe::<Settings>(&path)
98 && let Some(ep) = s.enabled_plugins
99 {
100 merged.extend(ep);
101 }
102 }
103 merged
104}
105
106fn skill_folder_names(dir: &Path) -> Vec<(String, PathBuf)> {
108 let mut out = Vec::new();
109 let Ok(entries) = fs::read_dir(dir) else {
110 return out;
111 };
112 for entry in entries.flatten() {
113 let path = entry.path();
114 if !path.is_dir() {
115 continue;
116 }
117 if path.join("SKILL.md").exists() {
118 out.push((entry.file_name().to_string_lossy().into_owned(), path));
119 }
120 }
121 out
122}
123
124#[derive(Debug, Deserialize)]
125struct Install {
126 #[serde(rename = "installPath")]
127 install_path: Option<String>,
128}
129
130#[derive(Debug, Deserialize)]
131struct InstalledPlugins {
132 plugins: Option<HashMap<String, Vec<Install>>>,
133}
134
135fn enabled_plugin_skills(config_dir: &Path, enabled: &HashMap<String, bool>) -> Vec<ShadowSource> {
137 let mut out = Vec::new();
138 let manifest: Option<InstalledPlugins> =
139 read_json_safe(&config_dir.join("plugins").join("installed_plugins.json"));
140 let Some(plugins) = manifest.and_then(|m| m.plugins) else {
141 return out;
142 };
143 for (key, installs) in plugins {
144 if enabled.get(&key) != Some(&true) {
145 continue; }
147 for inst in installs {
148 let Some(install_path) = inst.install_path else {
149 continue;
150 };
151 for (name, path) in skill_folder_names(&Path::new(&install_path).join("skills")) {
152 out.push(ShadowSource::Plugin {
153 plugin: key.clone(),
154 skill_name: name,
155 path: path.to_string_lossy().into_owned(),
156 });
157 }
158 }
159 }
160 out
161}
162
163fn global_skills(config_dir: &Path) -> Vec<ShadowSource> {
165 skill_folder_names(&config_dir.join("skills"))
166 .into_iter()
167 .map(|(name, path)| ShadowSource::GlobalSkill {
168 skill_name: name,
169 path: path.to_string_lossy().into_owned(),
170 })
171 .collect()
172}
173
174pub fn detect_plugin_shadows(
177 config_dir: &Path,
178 cwd: &Path,
179 staged_skill_names: &[&str],
180) -> PluginShadowReport {
181 let staged: HashSet<&str> = staged_skill_names.iter().copied().collect();
182 let enabled = resolve_enabled_plugins(config_dir, cwd);
183 let mut shadowed = Vec::new();
184
185 for s in enabled_plugin_skills(config_dir, &enabled) {
186 if staged.contains(s.skill_name()) {
187 shadowed.push(s);
188 }
189 }
190 for s in global_skills(config_dir) {
191 if staged.contains(s.skill_name()) {
192 shadowed.push(s);
193 }
194 }
195
196 PluginShadowReport {
197 config_dir: config_dir.to_string_lossy().into_owned(),
198 shadowed,
199 }
200}
201
202pub fn shadow_validity_warnings(report: &PluginShadowReport) -> Vec<String> {
204 report
205 .shadowed
206 .iter()
207 .map(|s| {
208 format!(
209 "staged skill '{}' is also provided by {} — eval subagents could discover both \
210 copies, so with/without results may be contaminated. Re-run from an isolated \
211 session (see {}).",
212 s.skill_name(),
213 s.source_label(),
214 ISOLATION_DOC
215 )
216 })
217 .collect()
218}
219
220pub fn format_shadow_banner(report: &PluginShadowReport) -> String {
222 if report.shadowed.is_empty() {
223 return String::new();
224 }
225 let mut lines = vec![
226 String::new(),
227 "⚠ Plugin-shadow warning: skills staged for this eval are ALSO discoverable".to_string(),
228 " from your live environment:".to_string(),
229 ];
230 for s in &report.shadowed {
231 lines.push(format!(" • {} — {}", s.skill_name(), s.source_label()));
232 }
233 lines.push(
234 " Eval subagents (dispatched via the Task tool) inherit this session's plugins,"
235 .to_string(),
236 );
237 lines.push(
238 " so both the staged copy and the installed copy are discoverable — the".to_string(),
239 );
240 lines.push(
241 " with/without comparison may be contaminated and the control arm is not truly"
242 .to_string(),
243 );
244 lines.push(
245 " skill-absent. The runner cannot unload a plugin from a running session.".to_string(),
246 );
247 lines.push(format!(
248 " Re-run from an isolated session — see {ISOLATION_DOC}."
249 ));
250 lines.join("\n")
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use serde_json::json;
257 use std::fs;
258 use std::path::{Path, PathBuf};
259 use tempfile::TempDir;
260
261 fn fresh(dir: &TempDir) -> (PathBuf, PathBuf) {
262 let config = dir.path().join("config");
263 let cwd = dir.path().join("cwd");
264 fs::create_dir_all(&config).unwrap();
265 fs::create_dir_all(&cwd).unwrap();
266 (config, cwd)
267 }
268
269 fn write_file(path: &Path, body: &str) {
270 fs::create_dir_all(path.parent().unwrap()).unwrap();
271 fs::write(path, body).unwrap();
272 }
273
274 fn install_plugin(config: &Path, key: &str, skill_names: &[&str]) -> PathBuf {
275 let install = config
276 .join("plugins")
277 .join("cache")
278 .join(key.replace('@', "__"));
279 for name in skill_names {
280 write_file(
281 &install.join("skills").join(name).join("SKILL.md"),
282 &format!("---\nname: {name}\ndescription: x\n---\n"),
283 );
284 }
285 install
286 }
287
288 fn write_installed_manifest(config: &Path, entries: &[(&str, &Path)]) {
289 let mut plugins = serde_json::Map::new();
290 for (key, install) in entries {
291 plugins.insert(
292 (*key).to_string(),
293 json!([{"installPath": install.to_string_lossy()}]),
294 );
295 }
296 write_file(
297 &config.join("plugins").join("installed_plugins.json"),
298 &serde_json::to_string_pretty(&json!({"version": 2, "plugins": plugins})).unwrap(),
299 );
300 }
301
302 fn write_settings(path: &Path, enabled: &[(&str, bool)]) {
303 let mut m = serde_json::Map::new();
304 for (k, v) in enabled {
305 m.insert((*k).to_string(), json!(v));
306 }
307 write_file(
308 path,
309 &serde_json::to_string(&json!({"enabledPlugins": m})).unwrap(),
310 );
311 }
312
313 #[test]
314 fn honors_config_dir_override() {
315 assert_eq!(
316 resolve_config_dir(Some("/custom/cfg")),
317 PathBuf::from("/custom/cfg")
318 );
319 }
320
321 #[test]
322 fn defaults_to_home_claude_when_unset() {
323 let expected = std::env::home_dir().unwrap_or_default().join(".claude");
324 assert_eq!(resolve_config_dir(None), expected);
325 assert_eq!(resolve_config_dir(Some(" ")), expected);
327 }
328
329 #[test]
330 fn flags_skill_also_provided_by_enabled_plugin() {
331 let tmp = TempDir::new().unwrap();
332 let (config, cwd) = fresh(&tmp);
333 let ip = install_plugin(
334 &config,
335 "slow-powers@slowdini",
336 &["verification-before-completion", "writing-skills"],
337 );
338 write_installed_manifest(&config, &[("slow-powers@slowdini", &ip)]);
339 write_settings(
340 &config.join("settings.json"),
341 &[("slow-powers@slowdini", true)],
342 );
343
344 let report = detect_plugin_shadows(&config, &cwd, &["verification-before-completion"]);
345 assert_eq!(report.shadowed.len(), 1);
346 match &report.shadowed[0] {
347 ShadowSource::Plugin {
348 plugin, skill_name, ..
349 } => {
350 assert_eq!(plugin, "slow-powers@slowdini");
351 assert_eq!(skill_name, "verification-before-completion");
352 }
353 other => panic!("expected plugin shadow, got {other:?}"),
354 }
355 }
356
357 #[test]
358 fn does_not_flag_disabled_plugin() {
359 let tmp = TempDir::new().unwrap();
360 let (config, cwd) = fresh(&tmp);
361 let ip = install_plugin(
362 &config,
363 "slow-powers@slowdini",
364 &["verification-before-completion"],
365 );
366 write_installed_manifest(&config, &[("slow-powers@slowdini", &ip)]);
367 write_settings(
368 &config.join("settings.json"),
369 &[("slow-powers@slowdini", false)],
370 );
371
372 let report = detect_plugin_shadows(&config, &cwd, &["verification-before-completion"]);
373 assert_eq!(report.shadowed.len(), 0);
374 }
375
376 #[test]
377 fn project_settings_disabling_user_enabled_plugin_suppresses_shadow() {
378 let tmp = TempDir::new().unwrap();
379 let (config, cwd) = fresh(&tmp);
380 let ip = install_plugin(
381 &config,
382 "slow-powers@slowdini",
383 &["verification-before-completion"],
384 );
385 write_installed_manifest(&config, &[("slow-powers@slowdini", &ip)]);
386 write_settings(
387 &config.join("settings.json"),
388 &[("slow-powers@slowdini", true)],
389 );
390 write_settings(
392 &cwd.join(".claude").join("settings.json"),
393 &[("slow-powers@slowdini", false)],
394 );
395
396 let report = detect_plugin_shadows(&config, &cwd, &["verification-before-completion"]);
397 assert_eq!(report.shadowed.len(), 0);
398 }
399
400 #[test]
401 fn flags_skill_also_in_global_skills_dir() {
402 let tmp = TempDir::new().unwrap();
403 let (config, cwd) = fresh(&tmp);
404 write_file(
405 &config.join("skills").join("my-skill").join("SKILL.md"),
406 "---\nname: my-skill\n---\n",
407 );
408
409 let report = detect_plugin_shadows(&config, &cwd, &["my-skill"]);
410 assert_eq!(report.shadowed.len(), 1);
411 match &report.shadowed[0] {
412 ShadowSource::GlobalSkill { skill_name, .. } => assert_eq!(skill_name, "my-skill"),
413 other => panic!("expected global-skill shadow, got {other:?}"),
414 }
415 }
416
417 #[test]
418 fn no_shadow_when_staged_names_match_nothing() {
419 let tmp = TempDir::new().unwrap();
420 let (config, cwd) = fresh(&tmp);
421 let ip = install_plugin(&config, "p@m", &["other"]);
422 write_installed_manifest(&config, &[("p@m", &ip)]);
423 write_settings(&config.join("settings.json"), &[("p@m", true)]);
424
425 let report = detect_plugin_shadows(&config, &cwd, &["mine"]);
426 assert_eq!(report.shadowed.len(), 0);
427 }
428
429 #[test]
430 fn graceful_when_config_dir_has_no_plugins_or_skills() {
431 let tmp = TempDir::new().unwrap();
432 let (config, cwd) = fresh(&tmp);
433 let report = detect_plugin_shadows(&config, &cwd, &["x"]);
434 assert_eq!(report.shadowed.len(), 0);
435 assert_eq!(report.config_dir, config.to_string_lossy());
436 }
437
438 fn sample_report() -> PluginShadowReport {
439 PluginShadowReport {
440 config_dir: "/x".into(),
441 shadowed: vec![ShadowSource::Plugin {
442 plugin: "slow-powers@slowdini".into(),
443 skill_name: "verification-before-completion".into(),
444 path: "/p".into(),
445 }],
446 }
447 }
448
449 #[test]
450 fn validity_warnings_name_skill_plugin_and_contamination() {
451 let warnings = shadow_validity_warnings(&sample_report());
452 assert_eq!(warnings.len(), 1);
453 assert!(warnings[0].contains("verification-before-completion"));
454 assert!(warnings[0].contains("slow-powers@slowdini"));
455 assert!(warnings[0].to_lowercase().contains("contaminat"));
456 }
457
458 #[test]
459 fn banner_is_empty_when_nothing_shadowed() {
460 let empty = PluginShadowReport {
461 config_dir: "/x".into(),
462 shadowed: vec![],
463 };
464 assert_eq!(format_shadow_banner(&empty), "");
465 }
466
467 #[test]
468 fn banner_lists_shadowed_skills_and_points_at_isolation_docs() {
469 let banner = format_shadow_banner(&sample_report());
470 assert!(banner.contains("verification-before-completion"));
471 assert!(banner.contains("slow-powers@slowdini"));
472 assert!(banner.to_lowercase().contains("isolat"));
473 }
474}