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