返回 CodeWhale
audit.rs
根目录 / crates / tui / src / skills / audit.rs
1 //! Bounded, read-only skill audit inventory.
2 //!
3 //! Separates "what is on disk" from runtime [`super::SkillRegistry`] merging.
4 //! Never executes skill bodies, never contacts the network, and never writes.
5
6 use std::collections::{HashMap, HashSet};
7 use std::fs::{self, File};
8 use std::io::{Read, Take};
9 use std::path::{Path, PathBuf};
10 use std::time::SystemTime;
11
12 use serde::Deserialize;
13
14 use super::install::{INSTALLED_FROM_MARKER, TRUSTED_MARKER};
15 use super::package_digest::{self, PackageDigestError};
16 use super::roots::{
17 SkillRootAccess, SkillRootCatalog, SkillRootDescriptor, SkillRootId, SkillRootKind,
18 safe_display_path,
19 };
20 use super::system::is_exact_bundled_skill;
21 use super::{SkillRegistry, normalize_skill_name_for_lookup};
22
23 /// Max bytes of `SKILL.md` the auditor will read into memory.
24 pub const AUDIT_MAX_SKILL_MD_BYTES: u64 = 512 * 1024;
25 /// Max directory depth under a skill package (and under a root when locating packages).
26 pub const AUDIT_MAX_DEPTH: usize = package_digest::PACKAGE_DIGEST_MAX_DEPTH;
27
28 /// Which roots the auditor visits.
29 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
30 pub enum SkillAuditMode {
31 /// CodeWhale-owned project/global roots only.
32 OwnedOnly,
33 /// Owned + compatible roots (including `.codex/skills`). Does not change runtime.
34 Compatible,
35 }
36
37 /// Stable identity for one on-disk skill copy.
38 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
39 pub struct AuditedSkillId {
40 pub root_id: SkillRootId,
41 pub relative_dir: PathBuf,
42 pub canonical_name: String,
43 }
44
45 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46 pub enum SkillSourceKind {
47 CodeWhaleManaged,
48 CodeWhaleManual,
49 CompatibleExternal,
50 BuiltIn,
51 ReviewedPluginSnapshot,
52 RegistryCache,
53 }
54
55 #[derive(Debug, Clone, PartialEq, Eq)]
56 pub enum DigestUnknownReason {
57 Unreadable,
58 SymlinkPresent,
59 EscapedRoot,
60 Cycle,
61 Oversized,
62 TooManyFiles,
63 TooDeep,
64 }
65
66 #[derive(Debug, Clone, PartialEq, Eq)]
67 pub enum DigestState {
68 Known(String),
69 Unknown(DigestUnknownReason),
70 }
71
72 #[derive(Debug, Clone, PartialEq, Eq)]
73 pub enum ParserState {
74 Valid,
75 Warning(Vec<String>),
76 Broken(String),
77 Oversized,
78 }
79
80 #[derive(Debug, Clone, PartialEq, Eq)]
81 pub enum PrecedenceState {
82 Active,
83 ShadowedBy(AuditedSkillId),
84 InactiveSource,
85 Unknown,
86 }
87
88 #[derive(Debug, Clone, PartialEq, Eq)]
89 pub enum IntegrityState {
90 Healthy,
91 LocalContentDrift,
92 BrokenManagedInstall,
93 LegacyMetadataUnknown,
94 Unknown,
95 }
96
97 #[derive(Debug, Clone, PartialEq, Eq)]
98 pub enum TrustState {
99 TrustedForDigest(String),
100 TrustStale,
101 LegacyAdvisory,
102 Untrusted,
103 // Matched by the skills manager ("n/a") but never constructed: no
104 // discovery path yields a non-filesystem row yet.
105 #[allow(dead_code)]
106 NotApplicable,
107 Unknown,
108 }
109
110 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
111 pub enum ReadinessState {
112 // Constructed only in tests until the #4407 readiness cache is wired.
113 #[cfg(test)]
114 Ready,
115 Unknown,
116 }
117
118 #[derive(Debug, Clone, PartialEq, Eq)]
119 pub enum ProvenanceState {
120 Managed {
121 spec: Option<String>,
122 safe_url: Option<String>,
123 schema_version: Option<u32>,
124 },
125 Manual,
126 External,
127 BuiltIn,
128 Plugin,
129 Cache,
130 // Matched by the skills manager ("unknown") but never constructed: no
131 // discovery path yields an unclassified logical source yet.
132 #[allow(dead_code)]
133 Unknown,
134 }
135
136 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
137 pub enum SkillActionKind {
138 Install,
139 Import,
140 Update,
141 Remove,
142 Trust,
143 }
144
145 #[derive(Debug, Clone, PartialEq, Eq)]
146 pub enum SkillAuditWarning {
147 Message(String),
148 }
149
150 #[derive(Debug, Clone, PartialEq, Eq)]
151 pub struct AuditedSkill {
152 pub id: AuditedSkillId,
153 pub name: String,
154 pub description: Option<String>,
155 pub root: SkillRootDescriptor,
156 pub safe_display_path: String,
157 pub source_kind: SkillSourceKind,
158 pub parser: ParserState,
159 pub digest: DigestState,
160 pub provenance: ProvenanceState,
161 pub trust: TrustState,
162 pub readiness: ReadinessState,
163 pub precedence: PrecedenceState,
164 pub integrity: IntegrityState,
165 pub available_actions: Vec<SkillActionKind>,
166 pub warnings: Vec<SkillAuditWarning>,
167 /// Same canonical name + same digest as another copy.
168 pub exact_duplicate_of: Option<AuditedSkillId>,
169 /// Same canonical name + different digest.
170 pub conflicts_with: Vec<AuditedSkillId>,
171 /// External copy with no owned same-name skill — import candidate.
172 pub import_candidate: bool,
173 /// Package path left the declared skill root (symlink escape, etc.).
174 pub path_unsafe: bool,
175 }
176
177 #[derive(Debug, Clone, PartialEq, Eq)]
178 pub struct SkillAuditSnapshot {
179 pub scan_mode: SkillAuditMode,
180 pub roots: Vec<SkillRootDescriptor>,
181 pub skills: Vec<AuditedSkill>,
182 pub generated_at: SystemTime,
183 }
184
185 /// Optional readiness cache from Issue #4407. Missing → [`ReadinessState::Unknown`].
186 pub trait SkillReadinessProvider {
187 fn readiness_for(&self, skill: &AuditedSkillId) -> Option<ReadinessState>;
188 }
189
190 /// Scan skill roots into a full, unmerged inventory.
191 #[cfg(test)]
192 #[must_use]
193 pub fn scan(
194 workspace: &Path,
195 home: Option<&Path>,
196 mode: SkillAuditMode,
197 readiness: Option<&dyn SkillReadinessProvider>,
198 ) -> SkillAuditSnapshot {
199 scan_with_configured(workspace, home, None, mode, readiness)
200 }
201
202 /// Scan skill roots with an optional configured `skills_dir`.
203 #[must_use]
204 pub fn scan_with_configured(
205 workspace: &Path,
206 home: Option<&Path>,
207 configured_skills_dir: Option<&Path>,
208 mode: SkillAuditMode,
209 readiness: Option<&dyn SkillReadinessProvider>,
210 ) -> SkillAuditSnapshot {
211 let catalog = SkillRootCatalog::build(workspace, home, configured_skills_dir);
212 let root_refs: Vec<SkillRootDescriptor> = match mode {
213 SkillAuditMode::OwnedOnly => catalog
214 .audit_owned_directories()
215 .into_iter()
216 .cloned()
217 .collect(),
218 SkillAuditMode::Compatible => catalog
219 .audit_compatible_directories()
220 .into_iter()
221 .cloned()
222 .collect(),
223 };
224
225 let mut skills = Vec::new();
226 for root in &root_refs {
227 skills.extend(scan_root(root, workspace, home));
228 }
229
230 classify_cross_root(&mut skills);
231 for skill in &mut skills {
232 skill.readiness = readiness
233 .and_then(|p| p.readiness_for(&skill.id))
234 .unwrap_or(ReadinessState::Unknown);
235 skill.available_actions = action_policy(skill);
236 }
237
238 SkillAuditSnapshot {
239 scan_mode: mode,
240 roots: root_refs,
241 skills,
242 generated_at: SystemTime::now(),
243 }
244 }
245
246 /// Expand an owned-only inventory to compatible roots without re-reading the
247 /// unchanged owned packages.
248 ///
249 /// The manager uses this for its interactive scan-mode toggle. Package audits
250 /// include bounded content hashing, so re-auditing every bundled owned skill
251 /// can make a simple keypress appear lost on a cold filesystem. Reusing rows by
252 /// root keeps the result ordered by catalog precedence while newly eligible
253 /// external roots are still read from disk.
254 #[must_use]
255 pub fn expand_owned_scan_to_compatible(
256 workspace: &Path,
257 home: Option<&Path>,
258 configured_skills_dir: Option<&Path>,
259 owned_skills: &[AuditedSkill],
260 readiness: Option<&dyn SkillReadinessProvider>,
261 ) -> SkillAuditSnapshot {
262 let catalog = SkillRootCatalog::build(workspace, home, configured_skills_dir);
263 let root_refs: Vec<SkillRootDescriptor> = catalog
264 .audit_compatible_directories()
265 .into_iter()
266 .cloned()
267 .collect();
268 let reusable_root_ids: HashSet<SkillRootId> = owned_skills
269 .iter()
270 .map(|skill| skill.id.root_id.clone())
271 .collect();
272
273 let mut skills = Vec::new();
274 for root in &root_refs {
275 if reusable_root_ids.contains(&root.id) {
276 skills.extend(
277 owned_skills
278 .iter()
279 .filter(|skill| skill.id.root_id == root.id)
280 .cloned(),
281 );
282 } else {
283 skills.extend(scan_root(root, workspace, home));
284 }
285 }
286
287 // The owned rows carried their previous cross-root result. Recompute it
288 // against the expanded inventory so precedence/conflict/import actions are
289 // exactly the same as a fresh compatible scan.
290 for skill in &mut skills {
291 skill.precedence = if skill.root.active_for_runtime {
292 PrecedenceState::Unknown
293 } else {
294 PrecedenceState::InactiveSource
295 };
296 skill.exact_duplicate_of = None;
297 skill.conflicts_with.clear();
298 skill.import_candidate = false;
299 skill.available_actions.clear();
300 }
301 classify_cross_root(&mut skills);
302 for skill in &mut skills {
303 skill.readiness = readiness
304 .and_then(|provider| provider.readiness_for(&skill.id))
305 .unwrap_or(ReadinessState::Unknown);
306 skill.available_actions = action_policy(skill);
307 }
308
309 SkillAuditSnapshot {
310 scan_mode: SkillAuditMode::Compatible,
311 roots: root_refs,
312 skills,
313 generated_at: SystemTime::now(),
314 }
315 }
316
317 /// Compute available mutations for one audited row (UI and controller share this).
318 #[must_use]
319 pub fn action_policy(skill: &AuditedSkill) -> Vec<SkillActionKind> {
320 if skill.path_unsafe {
321 return Vec::new();
322 }
323
324 match skill.source_kind {
325 SkillSourceKind::CodeWhaleManaged => {
326 let mut actions = Vec::new();
327 if matches!(skill.parser, ParserState::Valid | ParserState::Warning(_))
328 && !matches!(skill.integrity, IntegrityState::BrokenManagedInstall)
329 {
330 actions.push(SkillActionKind::Update);
331 }
332 actions.push(SkillActionKind::Remove);
333 if matches!(
334 skill.trust,
335 TrustState::Untrusted | TrustState::TrustStale | TrustState::LegacyAdvisory
336 ) && matches!(skill.digest, DigestState::Known(_))
337 && matches!(skill.parser, ParserState::Valid | ParserState::Warning(_))
338 {
339 actions.push(SkillActionKind::Trust);
340 }
341 actions
342 }
343 SkillSourceKind::CodeWhaleManual => Vec::new(),
344 SkillSourceKind::CompatibleExternal => {
345 // Import is offered for fresh candidates and for same-name owned
346 // peers (exact duplicate → AlreadyPresent, conflict → replace confirm).
347 // The mutation controller remains the authority on scope/conflict policy.
348 let importable = matches!(skill.parser, ParserState::Valid | ParserState::Warning(_))
349 && matches!(skill.digest, DigestState::Known(_))
350 && (skill.import_candidate
351 || skill.exact_duplicate_of.is_some()
352 || !skill.conflicts_with.is_empty());
353 if importable {
354 vec![SkillActionKind::Import]
355 } else {
356 Vec::new()
357 }
358 }
359 SkillSourceKind::BuiltIn
360 | SkillSourceKind::ReviewedPluginSnapshot
361 | SkillSourceKind::RegistryCache => Vec::new(),
362 }
363 }
364
365 // ── per-root scan ────────────────────────────────────────────────────────────
366
367 fn scan_root(
368 root: &SkillRootDescriptor,
369 workspace: &Path,
370 home: Option<&Path>,
371 ) -> Vec<AuditedSkill> {
372 let mut out = Vec::new();
373 let Ok(canonical_root) = fs::canonicalize(&root.path) else {
374 return out;
375 };
376 let mut visited = HashSet::new();
377 let mut packages = Vec::new();
378 find_skill_packages(&root.path, &canonical_root, 0, &mut visited, &mut packages);
379
380 for package_dir in packages {
381 out.push(audit_package(
382 root,
383 &package_dir,
384 &canonical_root,
385 workspace,
386 home,
387 ));
388 }
389 out
390 }
391
392 fn find_skill_packages(
393 dir: &Path,
394 canonical_root: &Path,
395 depth: usize,
396 visited: &mut HashSet<PathBuf>,
397 out: &mut Vec<PathBuf>,
398 ) {
399 if depth > AUDIT_MAX_DEPTH {
400 return;
401 }
402 let Ok(meta) = fs::symlink_metadata(dir) else {
403 return;
404 };
405 if meta.file_type().is_symlink() {
406 let Ok(canonical) = fs::canonicalize(dir) else {
407 return;
408 };
409 if !canonical.starts_with(canonical_root) || !canonical.is_dir() {
410 return;
411 }
412 if !visited.insert(canonical) {
413 return;
414 }
415 } else if meta.is_dir() {
416 let Ok(canonical) = fs::canonicalize(dir) else {
417 return;
418 };
419 if !visited.insert(canonical) {
420 return;
421 }
422 } else {
423 return;
424 }
425
426 let skill_md = dir.join("SKILL.md");
427 if skill_md.is_file() || fs::symlink_metadata(&skill_md).is_ok() {
428 out.push(dir.to_path_buf());
429 return; // do not descend into a skill package
430 }
431
432 let Ok(entries) = fs::read_dir(dir) else {
433 return;
434 };
435 for entry in entries.flatten() {
436 let path = entry.path();
437 if path
438 .file_name()
439 .and_then(|s| s.to_str())
440 .is_some_and(|name| name.starts_with('.'))
441 {
442 continue;
443 }
444 let Ok(meta) = fs::symlink_metadata(&path) else {
445 continue;
446 };
447 if meta.is_dir() || meta.file_type().is_symlink() {
448 find_skill_packages(&path, canonical_root, depth + 1, visited, out);
449 }
450 }
451 }
452
453 fn audit_package(
454 root: &SkillRootDescriptor,
455 package_dir: &Path,
456 canonical_root: &Path,
457 workspace: &Path,
458 home: Option<&Path>,
459 ) -> AuditedSkill {
460 let relative_dir = package_dir
461 .strip_prefix(&root.path)
462 .map(Path::to_path_buf)
463 .unwrap_or_else(|_| {
464 package_dir
465 .file_name()
466 .map(PathBuf::from)
467 .unwrap_or_else(|| PathBuf::from("."))
468 });
469
470 let mut warnings = Vec::new();
471 let skill_md = package_dir.join("SKILL.md");
472 let (parser, name, description, skill_md_content) = parse_skill_md_bounded(&skill_md);
473
474 let package = analyze_package(package_dir, canonical_root);
475 let path_unsafe = package.path_unsafe;
476 if path_unsafe {
477 warnings.push(SkillAuditWarning::Message(
478 "package contains a symlink that escapes the skill root or cycles".into(),
479 ));
480 }
481 for w in package.warnings {
482 warnings.push(SkillAuditWarning::Message(w));
483 }
484
485 let canonical_name = name
486 .as_deref()
487 .map(normalize_skill_name_for_lookup)
488 .unwrap_or_else(|| {
489 normalize_skill_name_for_lookup(
490 &relative_dir
491 .file_name()
492 .map(|s| s.to_string_lossy().into_owned())
493 .unwrap_or_else(|| "skill".into()),
494 )
495 });
496
497 let marker = read_installed_from(package_dir);
498 let trust = read_trust_state(package_dir, &package.digest);
499 let (source_kind, provenance, integrity) = classify_source(
500 root,
501 &canonical_name,
502 skill_md_content.as_deref(),
503 &marker,
504 &package.digest,
505 path_unsafe,
506 );
507
508 let display = format!(
509 "{}/{}",
510 safe_display_path(&root.path, Some(workspace), home),
511 relative_dir.display()
512 )
513 .replace('\\', "/");
514
515 AuditedSkill {
516 id: AuditedSkillId {
517 root_id: root.id.clone(),
518 relative_dir,
519 canonical_name: canonical_name.clone(),
520 },
521 name: canonical_name,
522 description,
523 root: root.clone(),
524 safe_display_path: display,
525 source_kind,
526 parser,
527 digest: package.digest,
528 provenance,
529 trust,
530 readiness: ReadinessState::Unknown,
531 precedence: if root.active_for_runtime {
532 PrecedenceState::Unknown // filled in classify_cross_root
533 } else {
534 PrecedenceState::InactiveSource
535 },
536 integrity,
537 available_actions: Vec::new(),
538 warnings,
539 exact_duplicate_of: None,
540 conflicts_with: Vec::new(),
541 import_candidate: false,
542 path_unsafe,
543 }
544 }
545
546 fn parse_skill_md_bounded(
547 path: &Path,
548 ) -> (ParserState, Option<String>, Option<String>, Option<String>) {
549 let meta = match fs::symlink_metadata(path) {
550 Ok(m) => m,
551 Err(err) => {
552 return (
553 ParserState::Broken(format!("cannot stat SKILL.md: {err}")),
554 None,
555 None,
556 None,
557 );
558 }
559 };
560 if meta.file_type().is_symlink() {
561 return (
562 ParserState::Broken("SKILL.md is a symlink".into()),
563 None,
564 None,
565 None,
566 );
567 }
568 if meta.len() > AUDIT_MAX_SKILL_MD_BYTES {
569 return (ParserState::Oversized, None, None, None);
570 }
571
572 let file = match File::open(path) {
573 Ok(f) => f,
574 Err(err) => {
575 return (
576 ParserState::Broken(format!("cannot open SKILL.md: {err}")),
577 None,
578 None,
579 None,
580 );
581 }
582 };
583 let mut limited: Take<File> = file.take(AUDIT_MAX_SKILL_MD_BYTES + 1);
584 let mut buf = Vec::new();
585 if let Err(err) = limited.read_to_end(&mut buf) {
586 return (
587 ParserState::Broken(format!("cannot read SKILL.md: {err}")),
588 None,
589 None,
590 None,
591 );
592 }
593 if buf.len() as u64 > AUDIT_MAX_SKILL_MD_BYTES {
594 return (ParserState::Oversized, None, None, None);
595 }
596 let content = match String::from_utf8(buf) {
597 Ok(s) => s,
598 Err(_) => {
599 return (
600 ParserState::Broken("SKILL.md is not valid UTF-8".into()),
601 None,
602 None,
603 None,
604 );
605 }
606 };
607
608 match SkillRegistry::parse_verified_content(path, &content) {
609 Ok((skill, warnings)) => {
610 let desc = if skill.description.is_empty() {
611 None
612 } else {
613 Some(truncate_desc(&skill.description))
614 };
615 let parser = if warnings.is_empty() {
616 ParserState::Valid
617 } else {
618 ParserState::Warning(warnings)
619 };
620 (parser, Some(skill.name), desc, Some(content))
621 }
622 Err(reason) => (ParserState::Broken(reason), None, None, Some(content)),
623 }
624 }
625
626 fn truncate_desc(s: &str) -> String {
627 const MAX: usize = 280;
628 let count = s.chars().count();
629 if count <= MAX {
630 s.to_string()
631 } else {
632 let truncated: String = s.chars().take(MAX.saturating_sub(1)).collect();
633 format!("{truncated}…")
634 }
635 }
636
637 struct PackageAnalysis {
638 digest: DigestState,
639 path_unsafe: bool,
640 warnings: Vec<String>,
641 }
642
643 /// Test-only digest helper. Prod paths (audit, mutation) call
644 /// `package_digest::compute_package_digest` directly.
645 #[cfg(test)]
646 pub fn compute_package_digest(package_dir: &Path) -> Result<String, DigestUnknownReason> {
647 package_digest::compute_package_digest(package_dir).map_err(digest_error_to_unknown)
648 }
649
650 fn digest_error_to_unknown(err: PackageDigestError) -> DigestUnknownReason {
651 match err {
652 PackageDigestError::Unreadable => DigestUnknownReason::Unreadable,
653 PackageDigestError::SymlinkPresent => DigestUnknownReason::SymlinkPresent,
654 PackageDigestError::EscapedRoot => DigestUnknownReason::EscapedRoot,
655 PackageDigestError::Cycle => DigestUnknownReason::Cycle,
656 PackageDigestError::Oversized => DigestUnknownReason::Oversized,
657 PackageDigestError::TooManyFiles => DigestUnknownReason::TooManyFiles,
658 PackageDigestError::TooDeep => DigestUnknownReason::TooDeep,
659 }
660 }
661
662 fn analyze_package(package_dir: &Path, _canonical_root: &Path) -> PackageAnalysis {
663 match package_digest::compute_package_digest(package_dir) {
664 Ok(digest) => PackageAnalysis {
665 digest: DigestState::Known(digest),
666 path_unsafe: false,
667 warnings: Vec::new(),
668 },
669 Err(err) => {
670 let reason = digest_error_to_unknown(err.clone());
671 let path_unsafe = matches!(
672 err,
673 PackageDigestError::SymlinkPresent
674 | PackageDigestError::EscapedRoot
675 | PackageDigestError::Cycle
676 );
677 PackageAnalysis {
678 digest: DigestState::Unknown(reason),
679 path_unsafe,
680 warnings: vec![err.to_string()],
681 }
682 }
683 }
684 }
685
686 // ── markers ──────────────────────────────────────────────────────────────────
687
688 #[derive(Debug, Clone, Deserialize)]
689 struct InstalledFromFile {
690 #[serde(default)]
691 schema_version: Option<u32>,
692 #[serde(default)]
693 spec: Option<String>,
694 #[serde(default)]
695 url: Option<String>,
696 #[serde(default)]
697 content_digest: Option<String>,
698 }
699
700 #[derive(Debug, Clone)]
701 enum MarkerParse {
702 Absent,
703 V1(InstalledFromFile),
704 V2(InstalledFromFile),
705 // Reason kept for Debug + future warning surfacing; matches bind `_`.
706 Broken(#[allow(dead_code)] String),
707 }
708
709 fn read_installed_from(package_dir: &Path) -> MarkerParse {
710 let path = package_dir.join(INSTALLED_FROM_MARKER);
711 let meta = match fs::symlink_metadata(&path) {
712 Ok(m) => m,
713 Err(_) => return MarkerParse::Absent,
714 };
715 if meta.file_type().is_symlink() {
716 return MarkerParse::Broken("symlink .installed-from".into());
717 }
718 if !meta.is_file() {
719 return MarkerParse::Broken(".installed-from is not a regular file".into());
720 }
721 let Ok(body) = fs::read_to_string(&path) else {
722 return MarkerParse::Broken("unreadable .installed-from".into());
723 };
724 let Ok(parsed) = serde_json::from_str::<InstalledFromFile>(&body) else {
725 return MarkerParse::Broken("malformed .installed-from".into());
726 };
727 match parsed.schema_version {
728 Some(v) if v >= 2 => MarkerParse::V2(parsed),
729 Some(_) | None => MarkerParse::V1(parsed),
730 }
731 }
732
733 #[derive(Debug, Deserialize)]
734 struct TrustFileV2 {
735 #[serde(default)]
736 schema_version: Option<u32>,
737 #[serde(default)]
738 content_digest: Option<String>,
739 }
740
741 fn read_trust_state(package_dir: &Path, digest: &DigestState) -> TrustState {
742 let path = package_dir.join(TRUSTED_MARKER);
743 let meta = match fs::symlink_metadata(&path) {
744 Ok(m) => m,
745 Err(_) => return TrustState::Untrusted,
746 };
747 if meta.file_type().is_symlink() {
748 // Do not follow symlink trust markers.
749 return TrustState::Unknown;
750 }
751 if !meta.is_file() {
752 return TrustState::Unknown;
753 }
754 let Ok(body) = fs::read_to_string(&path) else {
755 return TrustState::Unknown;
756 };
757 if let Ok(parsed) = serde_json::from_str::<TrustFileV2>(&body)
758 && parsed.schema_version == Some(2)
759 {
760 let Some(trusted_digest) = parsed.content_digest else {
761 return TrustState::Unknown;
762 };
763 return match digest {
764 DigestState::Known(current) if current == &trusted_digest => {
765 TrustState::TrustedForDigest(trusted_digest)
766 }
767 DigestState::Known(_) => TrustState::TrustStale,
768 DigestState::Unknown(_) => TrustState::Unknown,
769 };
770 }
771 TrustState::LegacyAdvisory
772 }
773
774 fn sanitize_url_for_display(url: &str) -> String {
775 // Strip userinfo, query, and fragment before UI / receipts.
776 let without_fragment = url.split('#').next().unwrap_or(url);
777 let without_query = without_fragment
778 .split('?')
779 .next()
780 .unwrap_or(without_fragment);
781 if let Some(scheme_end) = without_query.find("://") {
782 let scheme = &without_query[..scheme_end];
783 let rest = &without_query[scheme_end + 3..];
784 if let Some(at) = rest.find('@') {
785 return format!("{scheme}://{}", &rest[at + 1..]);
786 }
787 }
788 without_query.to_string()
789 }
790
791 fn classify_source(
792 root: &SkillRootDescriptor,
793 canonical_name: &str,
794 skill_md_content: Option<&str>,
795 marker: &MarkerParse,
796 digest: &DigestState,
797 _path_unsafe: bool,
798 ) -> (SkillSourceKind, ProvenanceState, IntegrityState) {
799 if matches!(root.kind, SkillRootKind::RegistryCache) {
800 return (
801 SkillSourceKind::RegistryCache,
802 ProvenanceState::Cache,
803 IntegrityState::Unknown,
804 );
805 }
806 if matches!(root.kind, SkillRootKind::ReviewedPluginSnapshot) {
807 return (
808 SkillSourceKind::ReviewedPluginSnapshot,
809 ProvenanceState::Plugin,
810 IntegrityState::Unknown,
811 );
812 }
813
814 if root.access != SkillRootAccess::WritableOwned {
815 return (
816 SkillSourceKind::CompatibleExternal,
817 ProvenanceState::External,
818 IntegrityState::Unknown,
819 );
820 }
821
822 // Managed markers win over bundled-name heuristics so a registry install
823 // that reuses a bundled command name (e.g. `pdf`) stays Update/Remove/Trust
824 // capable. Exact shipped body without a marker is still BuiltIn.
825 match marker {
826 MarkerParse::V1(_) | MarkerParse::V2(_) | MarkerParse::Broken(_) => {}
827 MarkerParse::Absent => {
828 if let Some(content) = skill_md_content
829 && is_exact_bundled_skill(canonical_name, content)
830 {
831 return (
832 SkillSourceKind::BuiltIn,
833 ProvenanceState::BuiltIn,
834 IntegrityState::Healthy,
835 );
836 }
837 }
838 }
839
840 match marker {
841 MarkerParse::Absent => (
842 SkillSourceKind::CodeWhaleManual,
843 ProvenanceState::Manual,
844 IntegrityState::Unknown,
845 ),
846 MarkerParse::Broken(_) => (
847 SkillSourceKind::CodeWhaleManaged,
848 ProvenanceState::Managed {
849 spec: None,
850 safe_url: None,
851 schema_version: None,
852 },
853 IntegrityState::BrokenManagedInstall,
854 ),
855 MarkerParse::V1(m) => (
856 SkillSourceKind::CodeWhaleManaged,
857 ProvenanceState::Managed {
858 spec: m.spec.clone(),
859 safe_url: m.url.as_deref().map(sanitize_url_for_display),
860 schema_version: m.schema_version.or(Some(1)),
861 },
862 IntegrityState::LegacyMetadataUnknown,
863 ),
864 MarkerParse::V2(m) => {
865 let integrity = match (&m.content_digest, digest) {
866 (Some(expected), DigestState::Known(actual)) if expected == actual => {
867 IntegrityState::Healthy
868 }
869 (Some(_), DigestState::Known(_)) => IntegrityState::LocalContentDrift,
870 (None, _) => IntegrityState::Unknown,
871 (_, DigestState::Unknown(_)) => IntegrityState::Unknown,
872 };
873 (
874 SkillSourceKind::CodeWhaleManaged,
875 ProvenanceState::Managed {
876 spec: m.spec.clone(),
877 safe_url: m.url.as_deref().map(sanitize_url_for_display),
878 schema_version: m.schema_version,
879 },
880 integrity,
881 )
882 }
883 }
884 }
885
886 // ── cross-root classification ────────────────────────────────────────────────
887
888 fn classify_cross_root(skills: &mut [AuditedSkill]) {
889 // Group by canonical name preserving first-seen order (catalog precedence).
890 let mut by_name: HashMap<String, Vec<usize>> = HashMap::new();
891 for (idx, skill) in skills.iter().enumerate() {
892 by_name
893 .entry(skill.id.canonical_name.clone())
894 .or_default()
895 .push(idx);
896 }
897
898 let owned_names: HashSet<String> = skills
899 .iter()
900 .filter(|s| s.root.is_writable_owned())
901 .map(|s| s.id.canonical_name.clone())
902 .collect();
903
904 for indices in by_name.values() {
905 if indices.is_empty() {
906 continue;
907 }
908
909 // Runtime-active winners: among copies whose root is active_for_runtime,
910 // the earliest in catalog order wins. Audit-only roots stay InactiveSource.
911 let runtime_indices: Vec<usize> = indices
912 .iter()
913 .copied()
914 .filter(|&i| skills[i].root.active_for_runtime)
915 .collect();
916 // Already in scan order which follows catalog precedence.
917 if let Some(&winner) = runtime_indices.first() {
918 let winner_id = skills[winner].id.clone();
919 for &idx in &runtime_indices {
920 if idx == winner {
921 if !matches!(skills[idx].precedence, PrecedenceState::InactiveSource) {
922 skills[idx].precedence = PrecedenceState::Active;
923 }
924 } else {
925 skills[idx].precedence = PrecedenceState::ShadowedBy(winner_id.clone());
926 }
927 }
928 }
929
930 // Duplicate / conflict among all copies (including inactive).
931 let digests: Vec<(usize, Option<String>)> = indices
932 .iter()
933 .map(|&i| {
934 let d = match &skills[i].digest {
935 DigestState::Known(s) => Some(s.clone()),
936 DigestState::Unknown(_) => None,
937 };
938 (i, d)
939 })
940 .collect();
941
942 for &(i, ref di) in &digests {
943 for &(j, ref dj) in &digests {
944 if i >= j {
945 continue;
946 }
947 match (di, dj) {
948 (Some(a), Some(b)) if a == b => {
949 let other = skills[j].id.clone();
950 if skills[i].exact_duplicate_of.is_none() {
951 skills[i].exact_duplicate_of = Some(other);
952 } else {
953 let other = skills[i].id.clone();
954 if skills[j].exact_duplicate_of.is_none() {
955 skills[j].exact_duplicate_of = Some(other);
956 }
957 }
958 }
959 (Some(_), Some(_)) => {
960 let id_j = skills[j].id.clone();
961 let id_i = skills[i].id.clone();
962 skills[i].conflicts_with.push(id_j);
963 skills[j].conflicts_with.push(id_i);
964 }
965 _ => {}
966 }
967 }
968 }
969 }
970
971 for skill in skills.iter_mut() {
972 if skill.source_kind == SkillSourceKind::CompatibleExternal
973 && !owned_names.contains(&skill.id.canonical_name)
974 && matches!(skill.parser, ParserState::Valid | ParserState::Warning(_))
975 && matches!(skill.digest, DigestState::Known(_))
976 && !skill.path_unsafe
977 {
978 skill.import_candidate = true;
979 }
980 }
981 }
982
983 #[cfg(test)]
984 mod tests {
985 use super::*;
986 use tempfile::TempDir;
987
988 fn write_skill(dir: &Path, name: &str, description: &str, body: &str) {
989 let skill_dir = dir.join(name);
990 fs::create_dir_all(&skill_dir).unwrap();
991 fs::write(
992 skill_dir.join("SKILL.md"),
993 format!("---\nname: {name}\ndescription: {description}\n---\n{body}\n"),
994 )
995 .unwrap();
996 }
997
998 #[test]
999 fn owned_only_skips_compatible_and_codex() {
1000 let tmp = TempDir::new().unwrap();
1001 let workspace = tmp.path().join("ws");
1002 let home = tmp.path().join("home");
1003 write_skill(
1004 &workspace.join(".codewhale").join("skills"),
1005 "owned",
1006 "owned skill",
1007 "body",
1008 );
1009 write_skill(
1010 &workspace.join(".claude").join("skills"),
1011 "claude",
1012 "claude skill",
1013 "body",
1014 );
1015 write_skill(
1016 &workspace.join(".codex").join("skills"),
1017 "codex",
1018 "codex skill",
1019 "body",
1020 );
1021
1022 let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1023 let names: Vec<_> = snap.skills.iter().map(|s| s.name.as_str()).collect();
1024 assert_eq!(names, vec!["owned"]);
1025 assert!(!snap.roots.iter().any(|r| matches!(
1026 r.kind,
1027 SkillRootKind::CompatibleProject(_) | SkillRootKind::CompatibleGlobal(_)
1028 )));
1029 }
1030
1031 #[test]
1032 fn compatible_includes_codex_without_activating_runtime_precedence() {
1033 let tmp = TempDir::new().unwrap();
1034 let workspace = tmp.path().join("ws");
1035 let home = tmp.path().join("home");
1036 write_skill(
1037 &workspace.join(".codewhale").join("skills"),
1038 "shared",
1039 "owned",
1040 "owned-body",
1041 );
1042 write_skill(
1043 &workspace.join(".codex").join("skills"),
1044 "shared",
1045 "codex",
1046 "codex-body",
1047 );
1048
1049 let snap = scan(&workspace, Some(&home), SkillAuditMode::Compatible, None);
1050 assert_eq!(snap.skills.len(), 2);
1051 let codex = snap
1052 .skills
1053 .iter()
1054 .find(|s| {
1055 matches!(
1056 s.root.kind,
1057 SkillRootKind::CompatibleProject(super::super::roots::CompatibleHarness::Codex)
1058 )
1059 })
1060 .expect("codex copy");
1061 assert_eq!(codex.precedence, PrecedenceState::InactiveSource);
1062 assert!(!codex.root.active_for_runtime);
1063
1064 let owned = snap
1065 .skills
1066 .iter()
1067 .find(|s| s.root.kind == SkillRootKind::CodeWhaleProject)
1068 .expect("owned");
1069 assert_eq!(owned.precedence, PrecedenceState::Active);
1070 }
1071
1072 #[test]
1073 fn expanding_owned_scan_matches_fresh_compatible_scan() {
1074 let tmp = TempDir::new().unwrap();
1075 let workspace = tmp.path().join("ws");
1076 let home = tmp.path().join("home");
1077 write_skill(
1078 &workspace.join(".codewhale").join("skills"),
1079 "shared",
1080 "owned",
1081 "owned-body",
1082 );
1083 write_skill(
1084 &workspace.join(".agents").join("skills"),
1085 "shared",
1086 "external conflict",
1087 "external-body",
1088 );
1089 write_skill(
1090 &workspace.join(".codex").join("skills"),
1091 "candidate",
1092 "import candidate",
1093 "candidate-body",
1094 );
1095
1096 let owned = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1097 let expanded =
1098 expand_owned_scan_to_compatible(&workspace, Some(&home), None, &owned.skills, None);
1099 let fresh = scan(&workspace, Some(&home), SkillAuditMode::Compatible, None);
1100
1101 assert_eq!(expanded.scan_mode, SkillAuditMode::Compatible);
1102 assert_eq!(expanded.roots, fresh.roots);
1103 assert_eq!(expanded.skills, fresh.skills);
1104 }
1105
1106 #[test]
1107 fn detects_shadow_duplicate_and_conflict() {
1108 let tmp = TempDir::new().unwrap();
1109 let workspace = tmp.path().join("ws");
1110 let home = tmp.path().join("home");
1111
1112 // Identical package content → exact duplicate (after shadowing).
1113 let identical = "---\nname: shared\ndescription: same\n---\nbody\n";
1114 fs::create_dir_all(workspace.join(".agents").join("skills").join("shared")).unwrap();
1115 fs::write(
1116 workspace
1117 .join(".agents")
1118 .join("skills")
1119 .join("shared")
1120 .join("SKILL.md"),
1121 identical,
1122 )
1123 .unwrap();
1124 fs::create_dir_all(workspace.join(".claude").join("skills").join("shared")).unwrap();
1125 fs::write(
1126 workspace
1127 .join(".claude")
1128 .join("skills")
1129 .join("shared")
1130 .join("SKILL.md"),
1131 identical,
1132 )
1133 .unwrap();
1134 // Different content → conflict with the active copy.
1135 write_skill(
1136 &workspace.join(".cursor").join("skills"),
1137 "shared",
1138 "cursor conflict",
1139 "different-body",
1140 );
1141
1142 let snap = scan(&workspace, Some(&home), SkillAuditMode::Compatible, None);
1143 let shared: Vec<_> = snap.skills.iter().filter(|s| s.name == "shared").collect();
1144 assert_eq!(shared.len(), 3);
1145 assert!(
1146 shared
1147 .iter()
1148 .any(|s| matches!(s.precedence, PrecedenceState::Active))
1149 );
1150 assert!(
1151 shared
1152 .iter()
1153 .any(|s| matches!(s.precedence, PrecedenceState::ShadowedBy(_)))
1154 );
1155 assert!(shared.iter().any(|s| s.exact_duplicate_of.is_some()));
1156 assert!(shared.iter().any(|s| !s.conflicts_with.is_empty()));
1157 }
1158
1159 #[test]
1160 fn external_without_owned_peer_is_import_candidate() {
1161 let tmp = TempDir::new().unwrap();
1162 let workspace = tmp.path().join("ws");
1163 let home = tmp.path().join("home");
1164 fs::create_dir_all(workspace.join(".codewhale").join("skills")).unwrap();
1165 write_skill(
1166 &workspace.join(".claude").join("skills"),
1167 "from-claude",
1168 "desc",
1169 "body",
1170 );
1171
1172 let snap = scan(&workspace, Some(&home), SkillAuditMode::Compatible, None);
1173 let skill = snap
1174 .skills
1175 .iter()
1176 .find(|s| s.name == "from-claude")
1177 .expect("skill");
1178 assert!(skill.import_candidate);
1179 assert_eq!(skill.available_actions, vec![SkillActionKind::Import]);
1180 assert_eq!(skill.source_kind, SkillSourceKind::CompatibleExternal);
1181 }
1182
1183 #[test]
1184 fn external_conflicting_with_owned_still_offers_import() {
1185 let tmp = TempDir::new().unwrap();
1186 let workspace = tmp.path().join("ws");
1187 let home = tmp.path().join("home");
1188 write_skill(
1189 &workspace.join(".codewhale").join("skills"),
1190 "shared",
1191 "desc",
1192 "owned-body",
1193 );
1194 write_skill(
1195 &workspace.join(".claude").join("skills"),
1196 "shared",
1197 "desc",
1198 "external-body",
1199 );
1200
1201 let snap = scan(&workspace, Some(&home), SkillAuditMode::Compatible, None);
1202 let external = snap
1203 .skills
1204 .iter()
1205 .find(|s| s.name == "shared" && s.source_kind == SkillSourceKind::CompatibleExternal)
1206 .expect("external");
1207 assert!(!external.import_candidate);
1208 assert!(!external.conflicts_with.is_empty());
1209 assert_eq!(external.available_actions, vec![SkillActionKind::Import]);
1210 }
1211
1212 #[test]
1213 fn v1_marker_is_legacy_integrity_and_managed_actions() {
1214 let tmp = TempDir::new().unwrap();
1215 let workspace = tmp.path().join("ws");
1216 let home = tmp.path().join("home");
1217 let root = workspace.join(".codewhale").join("skills");
1218 write_skill(&root, "managed", "desc", "body");
1219 fs::write(
1220 root.join("managed").join(INSTALLED_FROM_MARKER),
1221 r#"{"spec":"github:o/r","url":"https://user:pass@example.com/x?token=1#frag","checksum":"abc"}"#,
1222 )
1223 .unwrap();
1224
1225 let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1226 let skill = &snap.skills[0];
1227 assert_eq!(skill.source_kind, SkillSourceKind::CodeWhaleManaged);
1228 assert_eq!(skill.integrity, IntegrityState::LegacyMetadataUnknown);
1229 assert!(skill.available_actions.contains(&SkillActionKind::Update));
1230 assert!(skill.available_actions.contains(&SkillActionKind::Remove));
1231 if let ProvenanceState::Managed { safe_url, .. } = &skill.provenance {
1232 let url = safe_url.as_deref().unwrap();
1233 assert!(!url.contains("user:pass"));
1234 assert!(!url.contains("token"));
1235 assert!(!url.contains("frag"));
1236 } else {
1237 panic!("expected managed provenance");
1238 }
1239 }
1240
1241 #[test]
1242 fn v2_marker_detects_healthy_and_drift() {
1243 let tmp = TempDir::new().unwrap();
1244 let workspace = tmp.path().join("ws");
1245 let home = tmp.path().join("home");
1246 let root = workspace.join(".codewhale").join("skills");
1247 write_skill(&root, "managed", "desc", "body");
1248
1249 // First scan to learn digest, then write matching v2 marker.
1250 let preliminary = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1251 let DigestState::Known(digest) = &preliminary.skills[0].digest else {
1252 panic!("expected known digest");
1253 };
1254 fs::write(
1255 root.join("managed").join(INSTALLED_FROM_MARKER),
1256 format!(r#"{{"schema_version":2,"spec":"github:o/r","content_digest":"{digest}"}}"#),
1257 )
1258 .unwrap();
1259 let healthy = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1260 assert_eq!(healthy.skills[0].integrity, IntegrityState::Healthy);
1261
1262 fs::write(
1263 root.join("managed").join(INSTALLED_FROM_MARKER),
1264 r#"{"schema_version":2,"spec":"github:o/r","content_digest":"deadbeef"}"#,
1265 )
1266 .unwrap();
1267 let drift = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1268 assert_eq!(drift.skills[0].integrity, IntegrityState::LocalContentDrift);
1269 }
1270
1271 #[test]
1272 fn legacy_trust_and_digest_bound_trust() {
1273 let tmp = TempDir::new().unwrap();
1274 let workspace = tmp.path().join("ws");
1275 let home = tmp.path().join("home");
1276 let root = workspace.join(".codewhale").join("skills");
1277 write_skill(&root, "managed", "desc", "body");
1278 fs::write(
1279 root.join("managed").join(INSTALLED_FROM_MARKER),
1280 r#"{"spec":"github:o/r","checksum":"x"}"#,
1281 )
1282 .unwrap();
1283 fs::write(root.join("managed").join(TRUSTED_MARKER), "trusted\n").unwrap();
1284
1285 let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1286 assert_eq!(snap.skills[0].trust, TrustState::LegacyAdvisory);
1287
1288 let DigestState::Known(digest) = &snap.skills[0].digest else {
1289 panic!("digest");
1290 };
1291 fs::write(
1292 root.join("managed").join(TRUSTED_MARKER),
1293 format!(r#"{{"schema_version":2,"content_digest":"{digest}"}}"#),
1294 )
1295 .unwrap();
1296 let trusted = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1297 assert!(matches!(
1298 trusted.skills[0].trust,
1299 TrustState::TrustedForDigest(_)
1300 ));
1301
1302 fs::write(
1303 root.join("managed").join(TRUSTED_MARKER),
1304 r#"{"schema_version":2,"content_digest":"stale"}"#,
1305 )
1306 .unwrap();
1307 let stale = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1308 assert_eq!(stale.skills[0].trust, TrustState::TrustStale);
1309 }
1310
1311 #[test]
1312 fn oversized_skill_md_is_fail_closed() {
1313 let tmp = TempDir::new().unwrap();
1314 let workspace = tmp.path().join("ws");
1315 let home = tmp.path().join("home");
1316 let skill_dir = workspace.join(".codewhale").join("skills").join("big");
1317 fs::create_dir_all(&skill_dir).unwrap();
1318 let huge = format!(
1319 "---\nname: big\ndescription: x\n---\n{}",
1320 "x".repeat(AUDIT_MAX_SKILL_MD_BYTES as usize + 64)
1321 );
1322 fs::write(skill_dir.join("SKILL.md"), huge).unwrap();
1323
1324 let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1325 assert_eq!(snap.skills[0].parser, ParserState::Oversized);
1326 assert!(snap.skills[0].available_actions.is_empty());
1327 }
1328
1329 #[test]
1330 fn readiness_missing_stays_unknown() {
1331 let tmp = TempDir::new().unwrap();
1332 let workspace = tmp.path().join("ws");
1333 let home = tmp.path().join("home");
1334 write_skill(&workspace.join(".codewhale").join("skills"), "a", "d", "b");
1335 let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1336 assert_eq!(snap.skills[0].readiness, ReadinessState::Unknown);
1337 }
1338
1339 #[test]
1340 fn bundled_name_alone_is_not_built_in() {
1341 let tmp = TempDir::new().unwrap();
1342 let workspace = tmp.path().join("ws");
1343 let home = tmp.path().join("home");
1344 // `pdf` is a bundled name, but custom body must not classify as BuiltIn.
1345 write_skill(
1346 &workspace.join(".codewhale").join("skills"),
1347 "pdf",
1348 "user override",
1349 "not-the-bundled-body",
1350 );
1351 let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1352 assert_eq!(snap.skills[0].source_kind, SkillSourceKind::CodeWhaleManual);
1353 assert!(snap.skills[0].available_actions.is_empty());
1354 }
1355
1356 #[test]
1357 fn managed_marker_wins_over_bundled_name() {
1358 let tmp = TempDir::new().unwrap();
1359 let workspace = tmp.path().join("ws");
1360 let home = tmp.path().join("home");
1361 let root = workspace.join(".codewhale").join("skills");
1362 // Bundled command name + different body + install marker → managed.
1363 write_skill(&root, "pdf", "registry pdf", "community-body");
1364 fs::write(
1365 root.join("pdf").join(INSTALLED_FROM_MARKER),
1366 r#"{"spec":"github:o/pdf-skill","checksum":"abc"}"#,
1367 )
1368 .unwrap();
1369
1370 let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1371 assert_eq!(
1372 snap.skills[0].source_kind,
1373 SkillSourceKind::CodeWhaleManaged
1374 );
1375 assert!(
1376 snap.skills[0]
1377 .available_actions
1378 .contains(&SkillActionKind::Update)
1379 );
1380 assert!(
1381 snap.skills[0]
1382 .available_actions
1383 .contains(&SkillActionKind::Remove)
1384 );
1385 assert!(
1386 snap.skills[0]
1387 .available_actions
1388 .contains(&SkillActionKind::Trust)
1389 );
1390 }
1391
1392 #[test]
1393 fn exact_bundled_content_is_built_in() {
1394 let tmp = TempDir::new().unwrap();
1395 let workspace = tmp.path().join("ws");
1396 let home = tmp.path().join("home");
1397 let root = workspace.join(".codewhale").join("skills");
1398 fs::create_dir_all(root.join("pdf")).unwrap();
1399 fs::write(
1400 root.join("pdf").join("SKILL.md"),
1401 include_str!("../../assets/skills/pdf/SKILL.md"),
1402 )
1403 .unwrap();
1404
1405 let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1406 assert_eq!(snap.skills[0].source_kind, SkillSourceKind::BuiltIn);
1407 assert!(snap.skills[0].available_actions.is_empty());
1408 }
1409
1410 #[cfg(unix)]
1411 #[test]
1412 fn symlink_installed_from_marker_is_fail_closed() {
1413 let tmp = TempDir::new().unwrap();
1414 let workspace = tmp.path().join("ws");
1415 let home = tmp.path().join("home");
1416 let root = workspace.join(".codewhale").join("skills");
1417 write_skill(&root, "managed", "desc", "body");
1418 let outside = tmp.path().join("outside-marker.json");
1419 fs::write(&outside, r#"{"spec":"github:o/r","checksum":"x"}"#).unwrap();
1420 std::os::unix::fs::symlink(&outside, root.join("managed").join(INSTALLED_FROM_MARKER))
1421 .unwrap();
1422
1423 let snap = scan(&workspace, Some(&home), SkillAuditMode::OwnedOnly, None);
1424 assert!(snap.skills[0].path_unsafe);
1425 assert!(matches!(
1426 snap.skills[0].digest,
1427 DigestState::Unknown(DigestUnknownReason::SymlinkPresent)
1428 ));
1429 assert_eq!(
1430 snap.skills[0].integrity,
1431 IntegrityState::BrokenManagedInstall
1432 );
1433 assert!(snap.skills[0].available_actions.is_empty());
1434 }
1435
1436 #[test]
1437 fn sanitize_url_strips_secrets() {
1438 assert_eq!(
1439 sanitize_url_for_display("https://user:pw@host/path?token=1#x"),
1440 "https://host/path"
1441 );
1442 }
1443
1444 struct AlwaysReady;
1445 impl SkillReadinessProvider for AlwaysReady {
1446 fn readiness_for(&self, _: &AuditedSkillId) -> Option<ReadinessState> {
1447 Some(ReadinessState::Ready)
1448 }
1449 }
1450
1451 #[test]
1452 fn readiness_provider_is_consulted_when_present() {
1453 let tmp = TempDir::new().unwrap();
1454 let workspace = tmp.path().join("ws");
1455 let home = tmp.path().join("home");
1456 write_skill(&workspace.join(".codewhale").join("skills"), "a", "d", "b");
1457 let snap = scan(
1458 &workspace,
1459 Some(&home),
1460 SkillAuditMode::OwnedOnly,
1461 Some(&AlwaysReady),
1462 );
1463 assert_eq!(snap.skills[0].readiness, ReadinessState::Ready);
1464 }
1465 }
1466
1466 lines RUST