返回 CodeWhale
mutation.rs
根目录 / crates / tui / src / skills / mutation.rs
1 //! Unique skill mutation controller for CodeWhale-owned roots.
2 //!
3 //! All install / import / update / remove / trust writes go through this
4 //! module. Compatible harness roots, built-ins, and plugin snapshots are
5 //! never mutated.
6
7 use std::fs;
8 use std::io::ErrorKind;
9 use std::path::{Component, Path, PathBuf};
10
11 use anyhow::{Context, Result, bail};
12
13 use crate::network_policy::NetworkPolicy;
14
15 use super::audit::{
16 AuditedSkill, AuditedSkillId, DigestState, SkillActionKind, SkillAuditMode, SkillSourceKind,
17 scan_with_configured,
18 };
19 use super::install::{
20 self, InstallOutcome, InstallSource, UpdateResult, write_installed_from_v2, write_trust_v2,
21 };
22 use super::normalize_skill_name_for_lookup;
23 use super::package_digest;
24 use super::roots::{
25 SkillRootCatalog, SkillRootDescriptor, SkillRootKind, SkillScope, safe_display_path,
26 };
27
28 /// Project vs global CodeWhale-owned install target.
29 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
30 pub enum SkillTargetScope {
31 Project,
32 Global,
33 }
34
35 impl SkillTargetScope {
36 #[must_use]
37 pub fn as_skill_scope(self) -> SkillScope {
38 match self {
39 Self::Project => SkillScope::Project,
40 Self::Global => SkillScope::Global,
41 }
42 }
43 }
44
45 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
46 pub enum ConflictPolicy {
47 Reject,
48 ReplaceConfirmed,
49 }
50
51 #[derive(Debug, Clone)]
52 pub enum SkillMutationRequest {
53 InstallRemote {
54 source: InstallSource,
55 target: SkillTargetScope,
56 },
57 ImportExternal {
58 source_id: AuditedSkillId,
59 expected_digest: String,
60 target: SkillTargetScope,
61 conflict_policy: ConflictPolicy,
62 },
63 Update {
64 skill_id: AuditedSkillId,
65 expected_digest: Option<String>,
66 },
67 /// Resolve by name inside owned roots (compatible `/skill update` path).
68 UpdateByName {
69 name: String,
70 scope: Option<SkillTargetScope>,
71 expected_digest: Option<String>,
72 },
73 Remove {
74 skill_id: AuditedSkillId,
75 expected_digest: Option<String>,
76 },
77 RemoveByName {
78 name: String,
79 scope: Option<SkillTargetScope>,
80 expected_digest: Option<String>,
81 },
82 Trust {
83 skill_id: AuditedSkillId,
84 expected_digest: String,
85 },
86 TrustByName {
87 name: String,
88 scope: Option<SkillTargetScope>,
89 expected_digest: Option<String>,
90 },
91 }
92
93 #[derive(Debug, Clone, PartialEq, Eq)]
94 pub enum SkillMutationOutcome {
95 Installed,
96 Updated,
97 NoChange,
98 Removed,
99 Trusted,
100 Imported,
101 AlreadyPresent,
102 NeedsApproval(String),
103 NetworkDenied(String),
104 }
105
106 #[derive(Debug, Clone)]
107 pub struct SkillMutationReceipt {
108 #[allow(dead_code)] // surfaced by manager detail / future receipt toast
109 pub action: SkillActionKind,
110 pub name: String,
111 #[allow(dead_code)] // surfaced by manager detail / future receipt toast
112 pub scope: SkillScope,
113 pub safe_target_path: String,
114 #[allow(dead_code)] // reserved for digest-diff UI
115 pub before_digest: Option<String>,
116 #[allow(dead_code)] // reserved for digest-diff UI
117 pub after_digest: Option<String>,
118 pub outcome: SkillMutationOutcome,
119 }
120
121 /// Inputs shared by mutation operations.
122 pub struct MutationContext<'a> {
123 pub workspace: &'a Path,
124 pub home: Option<&'a Path>,
125 pub configured_skills_dir: Option<&'a Path>,
126 pub network: &'a NetworkPolicy,
127 pub max_size: u64,
128 pub registry_url: &'a str,
129 }
130
131 fn owned_anchor<'a>(
132 workspace: &'a Path,
133 home: Option<&'a Path>,
134 target: SkillTargetScope,
135 ) -> Result<&'a Path> {
136 match target {
137 SkillTargetScope::Project => Ok(workspace),
138 SkillTargetScope::Global => home.context("global skill mutations require a home directory"),
139 }
140 }
141
142 /// Return whether `path` is an existing real directory, rejecting links and
143 /// non-directory components. `symlink_metadata` is intentional: following a
144 /// link before checking it would turn a lexical CodeWhale-owned root into an
145 /// attacker-selected write target.
146 fn checked_real_directory(path: &Path) -> Result<bool> {
147 match fs::symlink_metadata(path) {
148 Ok(meta) if meta.file_type().is_symlink() => {
149 bail!(
150 "refusing to mutate symlinked CodeWhale skills path component {}",
151 path.display()
152 )
153 }
154 Ok(meta) if !meta.is_dir() => bail!(
155 "refusing to mutate through non-directory CodeWhale skills path component {}",
156 path.display()
157 ),
158 Ok(_) => Ok(true),
159 Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
160 Err(err) => Err(err).with_context(|| format!("failed to inspect {}", path.display())),
161 }
162 }
163
164 /// Validate the complete owned-root chain without following a symlink in the
165 /// workspace/home anchor, `.codewhale`, or `skills` component.
166 fn validate_owned_target_chain(
167 anchor: &Path,
168 skills_dir: &Path,
169 require_existing: bool,
170 ) -> Result<()> {
171 let expected = anchor.join(".codewhale").join("skills");
172 if skills_dir != expected {
173 bail!(
174 "refusing to mutate non-canonical CodeWhale skills root {}",
175 skills_dir.display()
176 );
177 }
178
179 let anchor_exists = checked_real_directory(anchor)?;
180 if !anchor_exists {
181 if require_existing {
182 bail!("owned skill anchor {} does not exist", anchor.display());
183 }
184 return Ok(());
185 }
186
187 let codewhale_dir = anchor.join(".codewhale");
188 let codewhale_exists = checked_real_directory(&codewhale_dir)?;
189 if !codewhale_exists {
190 if require_existing {
191 bail!(
192 "owned skill parent {} does not exist",
193 codewhale_dir.display()
194 );
195 }
196 return Ok(());
197 }
198
199 let skills_exists = checked_real_directory(skills_dir)?;
200 if !skills_exists {
201 if require_existing {
202 bail!("owned skill root {} does not exist", skills_dir.display());
203 }
204 return Ok(());
205 }
206
207 let canonical_anchor = fs::canonicalize(anchor)
208 .with_context(|| format!("failed to resolve owned anchor {}", anchor.display()))?;
209 let canonical_skills = fs::canonicalize(skills_dir).with_context(|| {
210 format!(
211 "failed to resolve owned skill root {}",
212 skills_dir.display()
213 )
214 })?;
215 if !canonical_skills.starts_with(&canonical_anchor) {
216 bail!(
217 "owned skill root {} escapes anchor {}",
218 skills_dir.display(),
219 anchor.display()
220 );
221 }
222 Ok(())
223 }
224
225 fn create_owned_directory(path: &Path) -> Result<()> {
226 match fs::create_dir(path) {
227 Ok(()) => {}
228 Err(err) if err.kind() == ErrorKind::AlreadyExists => {}
229 Err(err) => {
230 return Err(err).with_context(|| format!("failed to create {}", path.display()));
231 }
232 }
233 if !checked_real_directory(path)? {
234 bail!("failed to create owned skill directory {}", path.display());
235 }
236 Ok(())
237 }
238
239 fn prepare_owned_target(
240 workspace: &Path,
241 home: Option<&Path>,
242 target: SkillTargetScope,
243 ) -> Result<PathBuf> {
244 let skills_dir = resolve_owned_target(workspace, home, target)?;
245 let anchor = owned_anchor(workspace, home, target)?;
246 if !checked_real_directory(anchor)? {
247 bail!("owned skill anchor {} does not exist", anchor.display());
248 }
249
250 let codewhale_dir = anchor.join(".codewhale");
251 if !checked_real_directory(&codewhale_dir)? {
252 create_owned_directory(&codewhale_dir)?;
253 }
254 validate_owned_target_chain(anchor, &skills_dir, false)?;
255 if !checked_real_directory(&skills_dir)? {
256 create_owned_directory(&skills_dir)?;
257 }
258 validate_owned_target_chain(anchor, &skills_dir, true)?;
259 Ok(skills_dir)
260 }
261
262 fn target_scope_for_root(root: &SkillRootDescriptor) -> Result<SkillTargetScope> {
263 if !root.is_writable_owned() {
264 bail!("refusing to mutate non-owned root {}", root.path.display());
265 }
266 match root.kind {
267 SkillRootKind::CodeWhaleProject => Ok(SkillTargetScope::Project),
268 SkillRootKind::CodeWhaleGlobal => Ok(SkillTargetScope::Global),
269 _ => bail!("refusing to mutate non-owned root {}", root.path.display()),
270 }
271 }
272
273 fn validate_owned_root_descriptor(
274 ctx: &MutationContext<'_>,
275 root: &SkillRootDescriptor,
276 ) -> Result<PathBuf> {
277 let target = target_scope_for_root(root)?;
278 let expected = resolve_owned_target(ctx.workspace, ctx.home, target)?;
279 if root.path != expected {
280 bail!(
281 "audited owned root {} does not match mutation target {}",
282 root.path.display(),
283 expected.display()
284 );
285 }
286 let anchor = owned_anchor(ctx.workspace, ctx.home, target)?;
287 validate_owned_target_chain(anchor, &expected, true)?;
288 Ok(expected)
289 }
290
291 /// Validate a real direct child of an already validated owned skills root.
292 /// Returns false only when a missing child is permitted.
293 fn validate_owned_child(skills_dir: &Path, child: &Path, require_existing: bool) -> Result<bool> {
294 if child.parent() != Some(skills_dir) {
295 bail!(
296 "refusing to mutate path {} outside direct owned root {}",
297 child.display(),
298 skills_dir.display()
299 );
300 }
301 let exists = checked_real_directory(child)?;
302 if !exists {
303 if require_existing {
304 bail!("owned skill path {} does not exist", child.display());
305 }
306 return Ok(false);
307 }
308
309 let canonical_root = fs::canonicalize(skills_dir).with_context(|| {
310 format!(
311 "failed to resolve owned skill root {}",
312 skills_dir.display()
313 )
314 })?;
315 let canonical_child = fs::canonicalize(child)
316 .with_context(|| format!("failed to resolve owned skill path {}", child.display()))?;
317 if canonical_child.parent() != Some(canonical_root.as_path()) {
318 bail!(
319 "owned skill path {} escapes direct root {}",
320 child.display(),
321 skills_dir.display()
322 );
323 }
324 Ok(true)
325 }
326
327 fn validate_owned_skill_path(
328 ctx: &MutationContext<'_>,
329 skill: &AuditedSkill,
330 path: &Path,
331 ) -> Result<PathBuf> {
332 if skill.id.root_id != skill.root.id {
333 bail!("audited skill root identity changed; refusing mutation");
334 }
335 let skills_dir = validate_owned_root_descriptor(ctx, &skill.root)?;
336 let package_name = on_disk_package_name(&skill.id)?;
337 let expected = skills_dir.join(package_name);
338 if path != expected {
339 bail!(
340 "audited skill path {} does not match owned package {}",
341 path.display(),
342 expected.display()
343 );
344 }
345 validate_owned_child(&skills_dir, path, true)?;
346 Ok(skills_dir)
347 }
348
349 /// Resolve the on-disk CodeWhale-owned skills directory for a target scope.
350 pub fn resolve_owned_target(
351 workspace: &Path,
352 home: Option<&Path>,
353 target: SkillTargetScope,
354 ) -> Result<PathBuf> {
355 let catalog = SkillRootCatalog::build(workspace, home, None);
356 let kind = match target {
357 SkillTargetScope::Project => SkillRootKind::CodeWhaleProject,
358 SkillTargetScope::Global => SkillRootKind::CodeWhaleGlobal,
359 };
360 let root = catalog
361 .owned_writable_roots()
362 .into_iter()
363 .find(|r| r.kind == kind)
364 .with_context(|| format!("no owned root for {target:?}"))?;
365 if !root.is_writable_owned() {
366 bail!("refusing to mutate non-owned root {}", root.path.display());
367 }
368 let anchor = owned_anchor(workspace, home, target)?;
369 validate_owned_target_chain(anchor, &root.path, false)?;
370 Ok(root.path.clone())
371 }
372
373 /// Execute a mutation request against CodeWhale-owned roots only.
374 pub async fn execute(
375 request: SkillMutationRequest,
376 ctx: &MutationContext<'_>,
377 ) -> Result<SkillMutationReceipt> {
378 match request {
379 SkillMutationRequest::InstallRemote { source, target } => {
380 install_remote(source, target, ctx).await
381 }
382 SkillMutationRequest::Update {
383 skill_id,
384 expected_digest,
385 } => update_skill(skill_id, expected_digest, ctx).await,
386 SkillMutationRequest::UpdateByName {
387 name,
388 scope,
389 expected_digest,
390 } => {
391 let resolved = resolve_owned_skill_by_name(ctx, &name, scope)?;
392 update_skill(resolved.id, expected_digest.or(Some(resolved.digest)), ctx).await
393 }
394 sync => execute_sync(sync, ctx),
395 }
396 }
397
398 /// Sync mutations (import / remove / trust) — safe to call without a Tokio runtime.
399 pub fn execute_sync(
400 request: SkillMutationRequest,
401 ctx: &MutationContext<'_>,
402 ) -> Result<SkillMutationReceipt> {
403 match request {
404 SkillMutationRequest::ImportExternal {
405 source_id,
406 expected_digest,
407 target,
408 conflict_policy,
409 } => import_external(source_id, expected_digest, target, conflict_policy, ctx),
410 SkillMutationRequest::Remove {
411 skill_id,
412 expected_digest,
413 } => remove_skill(skill_id, expected_digest, ctx),
414 SkillMutationRequest::RemoveByName {
415 name,
416 scope,
417 expected_digest,
418 } => {
419 let resolved = resolve_owned_skill_by_name(ctx, &name, scope)?;
420 remove_skill(resolved.id, expected_digest.or(Some(resolved.digest)), ctx)
421 }
422 SkillMutationRequest::Trust {
423 skill_id,
424 expected_digest,
425 } => trust_skill(skill_id, expected_digest, ctx),
426 SkillMutationRequest::TrustByName {
427 name,
428 scope,
429 expected_digest,
430 } => {
431 let resolved = resolve_owned_skill_by_name(ctx, &name, scope)?;
432 let digest = expected_digest.unwrap_or(resolved.digest);
433 trust_skill(resolved.id, digest, ctx)
434 }
435 SkillMutationRequest::InstallRemote { .. }
436 | SkillMutationRequest::Update { .. }
437 | SkillMutationRequest::UpdateByName { .. } => {
438 bail!("this mutation requires async execute (network I/O)")
439 }
440 }
441 }
442
443 #[derive(Debug)]
444 struct ResolvedOwnedSkill {
445 id: AuditedSkillId,
446 digest: String,
447 }
448
449 fn resolve_owned_skill_by_name(
450 ctx: &MutationContext<'_>,
451 name: &str,
452 scope: Option<SkillTargetScope>,
453 ) -> Result<ResolvedOwnedSkill> {
454 // Match SkillRegistry::get — users may pass frontmatter/dir forms
455 // (Hello_World) while audit stores the folded canonical key (hello-world).
456 let canonical = normalize_skill_name_for_lookup(name);
457 let snap = scan_with_configured(
458 ctx.workspace,
459 ctx.home,
460 ctx.configured_skills_dir,
461 SkillAuditMode::OwnedOnly,
462 None,
463 );
464 let mut matches: Vec<&AuditedSkill> = snap
465 .skills
466 .iter()
467 .filter(|s| s.id.canonical_name == canonical)
468 .filter(|s| s.root.is_writable_owned())
469 .collect();
470
471 if let Some(scope) = scope {
472 let want = match scope {
473 SkillTargetScope::Project => SkillRootKind::CodeWhaleProject,
474 SkillTargetScope::Global => SkillRootKind::CodeWhaleGlobal,
475 };
476 matches.retain(|s| s.root.kind == want);
477 }
478
479 match matches.as_slice() {
480 [] => {
481 // If the name only exists externally, tell the user to import.
482 let compatible = scan_with_configured(
483 ctx.workspace,
484 ctx.home,
485 ctx.configured_skills_dir,
486 SkillAuditMode::Compatible,
487 None,
488 );
489 if compatible.skills.iter().any(|s| {
490 s.id.canonical_name == canonical
491 && s.source_kind == SkillSourceKind::CompatibleExternal
492 }) {
493 bail!(
494 "skill '{name}' exists only in a compatible external root; \
495 import it with /skills (refusing to write external harness directories)"
496 );
497 }
498 bail!("skill '{name}' not found in CodeWhale-owned project/global roots");
499 }
500 [only] => {
501 let DigestState::Known(digest) = &only.digest else {
502 bail!("skill '{name}' has unknown package digest; refusing mutation");
503 };
504 Ok(ResolvedOwnedSkill {
505 id: only.id.clone(),
506 digest: digest.clone(),
507 })
508 }
509 _ => bail!(
510 "skill '{name}' exists in both project and global CodeWhale roots; \
511 specify --project or --global"
512 ),
513 }
514 }
515
516 fn find_audited_skill(
517 ctx: &MutationContext<'_>,
518 skill_id: &AuditedSkillId,
519 ) -> Result<(AuditedSkill, PathBuf)> {
520 let snap = scan_with_configured(
521 ctx.workspace,
522 ctx.home,
523 ctx.configured_skills_dir,
524 SkillAuditMode::Compatible,
525 None,
526 );
527 let skill = snap
528 .skills
529 .into_iter()
530 .find(|s| &s.id == skill_id)
531 .with_context(|| format!("audited skill {} not found", skill_id.canonical_name))?;
532 let path = skill.root.path.join(&skill.id.relative_dir);
533 Ok((skill, path))
534 }
535
536 /// Directory segment under the skills root. Prefer this over `canonical_name`
537 /// when calling install helpers that join `skills_dir / name` — installs use
538 /// raw frontmatter names, while audit stores a normalized lookup key.
539 fn on_disk_package_name(skill_id: &AuditedSkillId) -> Result<&str> {
540 let mut components = skill_id.relative_dir.components();
541 let name = match (components.next(), components.next()) {
542 (Some(Component::Normal(name)), None) => name.to_str(),
543 _ => None,
544 }
545 .filter(|name| !name.is_empty())
546 .ok_or_else(|| {
547 anyhow::anyhow!(
548 "invalid on-disk package directory for skill '{}'",
549 skill_id.canonical_name
550 )
551 })?;
552 Ok(name)
553 }
554
555 fn verify_expected_digest(path: &Path, expected: Option<&str>) -> Result<Option<String>> {
556 let current = package_digest::compute_package_digest(path)
557 .with_context(|| format!("cannot digest {}", path.display()))?;
558 if let Some(expected) = expected
559 && expected != current
560 {
561 bail!(
562 "skill content changed since audit (expected {expected}, found {current}); \
563 re-review before mutating"
564 );
565 }
566 Ok(Some(current))
567 }
568
569 fn ensure_remote_updatable(skill_dir: &Path) -> Result<()> {
570 let marker_path = skill_dir.join(install::INSTALLED_FROM_MARKER);
571 let body = fs::read_to_string(&marker_path)
572 .with_context(|| format!("failed to read {}", marker_path.display()))?;
573 let value: serde_json::Value = serde_json::from_str(&body)
574 .with_context(|| format!("malformed {}", install::INSTALLED_FROM_MARKER))?;
575 let spec = value
576 .get("spec")
577 .and_then(|v| v.as_str())
578 .unwrap_or_default();
579 if !install::is_registry_updatable_spec(spec) {
580 bail!(
581 "skill was imported locally (spec '{spec}') and cannot be updated from a registry; \
582 re-import or remove it first"
583 );
584 }
585 Ok(())
586 }
587
588 async fn install_remote(
589 source: InstallSource,
590 target: SkillTargetScope,
591 ctx: &MutationContext<'_>,
592 ) -> Result<SkillMutationReceipt> {
593 let skills_dir = prepare_owned_target(ctx.workspace, ctx.home, target)?;
594 let anchor = owned_anchor(ctx.workspace, ctx.home, target)?;
595
596 let outcome = install::install_with_registry(
597 source,
598 &skills_dir,
599 ctx.max_size,
600 ctx.network,
601 false,
602 ctx.registry_url,
603 )
604 .await?;
605 validate_owned_target_chain(anchor, &skills_dir, true)?;
606
607 match outcome {
608 InstallOutcome::Installed(installed) => {
609 validate_owned_child(&skills_dir, &installed.path, true)?;
610 let after = package_digest::compute_package_digest(&installed.path).ok();
611 Ok(SkillMutationReceipt {
612 action: SkillActionKind::Install,
613 name: installed.name,
614 scope: target.as_skill_scope(),
615 safe_target_path: safe_display_path(&installed.path, Some(ctx.workspace), ctx.home),
616 before_digest: None,
617 after_digest: after,
618 outcome: SkillMutationOutcome::Installed,
619 })
620 }
621 InstallOutcome::NeedsApproval(host) => Ok(SkillMutationReceipt {
622 action: SkillActionKind::Install,
623 name: String::new(),
624 scope: target.as_skill_scope(),
625 safe_target_path: safe_display_path(&skills_dir, Some(ctx.workspace), ctx.home),
626 before_digest: None,
627 after_digest: None,
628 outcome: SkillMutationOutcome::NeedsApproval(host),
629 }),
630 InstallOutcome::NetworkDenied(host) => Ok(SkillMutationReceipt {
631 action: SkillActionKind::Install,
632 name: String::new(),
633 scope: target.as_skill_scope(),
634 safe_target_path: safe_display_path(&skills_dir, Some(ctx.workspace), ctx.home),
635 before_digest: None,
636 after_digest: None,
637 outcome: SkillMutationOutcome::NetworkDenied(host),
638 }),
639 }
640 }
641
642 fn import_external(
643 source_id: AuditedSkillId,
644 expected_digest: String,
645 target: SkillTargetScope,
646 conflict_policy: ConflictPolicy,
647 ctx: &MutationContext<'_>,
648 ) -> Result<SkillMutationReceipt> {
649 let (source_skill, source_path) = find_audited_skill(ctx, &source_id)?;
650 if source_skill.source_kind != SkillSourceKind::CompatibleExternal {
651 bail!("import source must be a compatible external skill");
652 }
653 if source_skill.path_unsafe {
654 bail!("refusing to import unsafe external skill package");
655 }
656
657 let owned_snap = scan_with_configured(
658 ctx.workspace,
659 ctx.home,
660 ctx.configured_skills_dir,
661 SkillAuditMode::OwnedOnly,
662 None,
663 );
664 let owned_has_name = owned_snap
665 .skills
666 .iter()
667 .any(|s| s.id.canonical_name == source_id.canonical_name);
668
669 if !owned_has_name && !source_skill.import_candidate {
670 bail!("skill is not an import candidate");
671 }
672
673 let before = verify_expected_digest(&source_path, Some(&expected_digest))?;
674
675 let skills_dir = prepare_owned_target(ctx.workspace, ctx.home, target)?;
676 let anchor = owned_anchor(ctx.workspace, ctx.home, target)?;
677
678 let want_kind = match target {
679 SkillTargetScope::Project => SkillRootKind::CodeWhaleProject,
680 SkillTargetScope::Global => SkillRootKind::CodeWhaleGlobal,
681 };
682 let existing_in_target: Vec<&AuditedSkill> = owned_snap
683 .skills
684 .iter()
685 .filter(|s| s.id.canonical_name == source_id.canonical_name)
686 .filter(|s| s.root.kind == want_kind)
687 .collect();
688
689 // Prefer the on-disk directory of an existing same-scope package so we
690 // replace/reject against Hello_World rather than creating a parallel
691 // hello-world next to it.
692 let (dest, dest_segment) = match existing_in_target.as_slice() {
693 [] => {
694 let segment = source_id.canonical_name.clone();
695 (skills_dir.join(&segment), segment)
696 }
697 [only] => {
698 let segment = on_disk_package_name(&only.id)?.to_string();
699 (only.root.path.join(&only.id.relative_dir), segment)
700 }
701 _ => bail!(
702 "skill '{}' has multiple owned packages in the target scope; \
703 remove or consolidate them before importing",
704 source_id.canonical_name
705 ),
706 };
707
708 let dest_exists = validate_owned_child(&skills_dir, &dest, false)?;
709 if dest_exists {
710 let existing = package_digest::compute_package_digest(&dest).ok();
711 if existing.as_deref() == Some(expected_digest.as_str()) {
712 return Ok(SkillMutationReceipt {
713 action: SkillActionKind::Import,
714 name: source_id.canonical_name.clone(),
715 scope: target.as_skill_scope(),
716 safe_target_path: safe_display_path(&dest, Some(ctx.workspace), ctx.home),
717 before_digest: before,
718 after_digest: existing,
719 outcome: SkillMutationOutcome::AlreadyPresent,
720 });
721 }
722 if conflict_policy == ConflictPolicy::Reject {
723 bail!(
724 "destination {} already exists with different content",
725 dest.display()
726 );
727 }
728 }
729
730 // Re-verify source digest immediately before copy (TOCTOU).
731 let _ = verify_expected_digest(&source_path, Some(&expected_digest))?;
732 validate_owned_target_chain(anchor, &skills_dir, true)?;
733
734 let staging = skills_dir.join(format!("{dest_segment}.tmp"));
735 if validate_owned_child(&skills_dir, &staging, false)? {
736 fs::remove_dir_all(&staging)
737 .with_context(|| format!("failed to clean stale staging dir {}", staging.display()))?;
738 }
739 copy_skill_package(&source_path, &staging)?;
740 validate_owned_target_chain(anchor, &skills_dir, true)?;
741 validate_owned_child(&skills_dir, &staging, true)?;
742 package_digest::compute_package_digest(&staging)
743 .context("staged import package failed digest validation")?;
744
745 let mut backup_path: Option<PathBuf> = None;
746 if dest_exists {
747 validate_owned_child(&skills_dir, &dest, true)?;
748 let backup = skills_dir.join(format!("{dest_segment}.bak"));
749 if validate_owned_child(&skills_dir, &backup, false)? {
750 fs::remove_dir_all(&backup).with_context(|| {
751 format!("failed to clean stale backup dir {}", backup.display())
752 })?;
753 }
754 fs::rename(&dest, &backup)
755 .with_context(|| format!("failed to backup {}", dest.display()))?;
756 if let Err(err) = fs::rename(&staging, &dest) {
757 let _ = fs::rename(&backup, &dest);
758 let _ = fs::remove_dir_all(&staging);
759 return Err(err).context("failed to replace destination with imported skill");
760 }
761 backup_path = Some(backup);
762 } else if let Err(err) = fs::rename(&staging, &dest) {
763 let _ = fs::remove_dir_all(&staging);
764 return Err(err).context("failed to install imported skill");
765 }
766 validate_owned_target_chain(anchor, &skills_dir, true)?;
767 validate_owned_child(&skills_dir, &dest, true)?;
768
769 // Keep backup until digest + marker finalize succeed so a failed import
770 // can restore the previous owned skill.
771 let finalize = (|| -> Result<String> {
772 let _ = verify_expected_digest(&source_path, Some(&expected_digest))?;
773 validate_owned_target_chain(anchor, &skills_dir, true)?;
774 validate_owned_child(&skills_dir, &dest, true)?;
775 let after = package_digest::compute_package_digest(&dest)?;
776 if after != expected_digest {
777 bail!("imported content digest mismatch after copy; aborted");
778 }
779 write_installed_from_v2(
780 &dest,
781 &format!("import:{}", source_skill.safe_display_path),
782 None,
783 &expected_digest,
784 &after,
785 &source_id.canonical_name,
786 )?;
787 Ok(after)
788 })();
789
790 let after = match finalize {
791 Ok(after) => {
792 if let Some(backup) = backup_path.take() {
793 fs::remove_dir_all(&backup).ok();
794 }
795 after
796 }
797 Err(err) => {
798 let _ = fs::remove_dir_all(&dest);
799 if let Some(backup) = backup_path.take() {
800 let _ = fs::rename(&backup, &dest);
801 }
802 return Err(err);
803 }
804 };
805
806 Ok(SkillMutationReceipt {
807 action: SkillActionKind::Import,
808 name: source_id.canonical_name,
809 scope: target.as_skill_scope(),
810 safe_target_path: safe_display_path(&dest, Some(ctx.workspace), ctx.home),
811 before_digest: before,
812 after_digest: Some(after),
813 outcome: SkillMutationOutcome::Imported,
814 })
815 }
816
817 fn copy_skill_package(src: &Path, dest: &Path) -> Result<()> {
818 // Fail closed: source must already pass package digest (no symlinks).
819 package_digest::compute_package_digest(src).context("source package is not safe to copy")?;
820 copy_dir_regular_files(src, dest)?;
821 Ok(())
822 }
823
824 fn copy_dir_regular_files(src: &Path, dest: &Path) -> Result<()> {
825 fs::create_dir_all(dest)?;
826 for entry in fs::read_dir(src)? {
827 let entry = entry?;
828 let path = entry.path();
829 let meta = fs::symlink_metadata(&path)?;
830 if meta.file_type().is_symlink() {
831 bail!("refusing to copy symlink {}", path.display());
832 }
833 let name = entry.file_name();
834 let name_str = name.to_string_lossy();
835 if name_str == install::INSTALLED_FROM_MARKER
836 || name_str == install::TRUSTED_MARKER
837 || name_str == ".system-installed-version"
838 {
839 continue;
840 }
841 let target = dest.join(&name);
842 if meta.is_dir() {
843 if name_str.starts_with('.') {
844 continue;
845 }
846 copy_dir_regular_files(&path, &target)?;
847 } else if meta.is_file() {
848 if name_str.starts_with('.') {
849 continue;
850 }
851 fs::copy(&path, &target)?;
852 }
853 }
854 Ok(())
855 }
856
857 async fn update_skill(
858 skill_id: AuditedSkillId,
859 expected_digest: Option<String>,
860 ctx: &MutationContext<'_>,
861 ) -> Result<SkillMutationReceipt> {
862 let (skill, path) = find_audited_skill(ctx, &skill_id)?;
863 if !skill.root.is_writable_owned() {
864 bail!("refusing to update skill outside CodeWhale-owned roots");
865 }
866 if skill.source_kind != SkillSourceKind::CodeWhaleManaged {
867 bail!("only CodeWhale managed skills can be updated");
868 }
869 let skills_dir = validate_owned_skill_path(ctx, &skill, &path)?;
870 // Imported skills carry `import:…` provenance and must not hit the registry.
871 ensure_remote_updatable(&path)?;
872 let before = verify_expected_digest(&path, expected_digest.as_deref())?;
873 let scope = match skill.root.kind {
874 SkillRootKind::CodeWhaleProject => SkillScope::Project,
875 SkillRootKind::CodeWhaleGlobal => SkillScope::Global,
876 _ => SkillScope::Logical,
877 };
878
879 let package_name = on_disk_package_name(&skill_id)?;
880 validate_owned_skill_path(ctx, &skill, &path)?;
881 let outcome = install::update_with_registry(
882 package_name,
883 &skills_dir,
884 ctx.max_size,
885 ctx.network,
886 ctx.registry_url,
887 )
888 .await?;
889 validate_owned_root_descriptor(ctx, &skill.root)?;
890 validate_owned_child(&skills_dir, &path, true)?;
891
892 match outcome {
893 UpdateResult::NoChange => Ok(SkillMutationReceipt {
894 action: SkillActionKind::Update,
895 name: skill_id.canonical_name,
896 scope,
897 safe_target_path: safe_display_path(&path, Some(ctx.workspace), ctx.home),
898 before_digest: before.clone(),
899 after_digest: before,
900 outcome: SkillMutationOutcome::NoChange,
901 }),
902 UpdateResult::Updated(installed) => {
903 let after = package_digest::compute_package_digest(&installed.path).ok();
904 Ok(SkillMutationReceipt {
905 action: SkillActionKind::Update,
906 name: installed.name,
907 scope,
908 safe_target_path: safe_display_path(&installed.path, Some(ctx.workspace), ctx.home),
909 before_digest: before,
910 after_digest: after,
911 outcome: SkillMutationOutcome::Updated,
912 })
913 }
914 UpdateResult::NeedsApproval(host) => Ok(SkillMutationReceipt {
915 action: SkillActionKind::Update,
916 name: skill_id.canonical_name,
917 scope,
918 safe_target_path: safe_display_path(&path, Some(ctx.workspace), ctx.home),
919 before_digest: before,
920 after_digest: None,
921 outcome: SkillMutationOutcome::NeedsApproval(host),
922 }),
923 UpdateResult::NetworkDenied(host) => Ok(SkillMutationReceipt {
924 action: SkillActionKind::Update,
925 name: skill_id.canonical_name,
926 scope,
927 safe_target_path: safe_display_path(&path, Some(ctx.workspace), ctx.home),
928 before_digest: before,
929 after_digest: None,
930 outcome: SkillMutationOutcome::NetworkDenied(host),
931 }),
932 }
933 }
934
935 fn remove_skill(
936 skill_id: AuditedSkillId,
937 expected_digest: Option<String>,
938 ctx: &MutationContext<'_>,
939 ) -> Result<SkillMutationReceipt> {
940 let (skill, path) = find_audited_skill(ctx, &skill_id)?;
941 if !skill.root.is_writable_owned() {
942 bail!("refusing to remove skill outside CodeWhale-owned roots");
943 }
944 if skill.source_kind != SkillSourceKind::CodeWhaleManaged {
945 bail!("only CodeWhale managed skills can be removed");
946 }
947 let skills_dir = validate_owned_skill_path(ctx, &skill, &path)?;
948 let before = verify_expected_digest(&path, expected_digest.as_deref())?;
949 let scope = match skill.root.kind {
950 SkillRootKind::CodeWhaleProject => SkillScope::Project,
951 SkillRootKind::CodeWhaleGlobal => SkillScope::Global,
952 _ => SkillScope::Logical,
953 };
954 let package_name = on_disk_package_name(&skill_id)?;
955 validate_owned_skill_path(ctx, &skill, &path)?;
956 install::uninstall(package_name, &skills_dir)?;
957 Ok(SkillMutationReceipt {
958 action: SkillActionKind::Remove,
959 name: skill_id.canonical_name,
960 scope,
961 safe_target_path: safe_display_path(&path, Some(ctx.workspace), ctx.home),
962 before_digest: before,
963 after_digest: None,
964 outcome: SkillMutationOutcome::Removed,
965 })
966 }
967
968 fn trust_skill(
969 skill_id: AuditedSkillId,
970 expected_digest: String,
971 ctx: &MutationContext<'_>,
972 ) -> Result<SkillMutationReceipt> {
973 let (skill, path) = find_audited_skill(ctx, &skill_id)?;
974 if !skill.root.is_writable_owned() {
975 bail!("refusing to trust skill outside CodeWhale-owned roots");
976 }
977 if skill.source_kind != SkillSourceKind::CodeWhaleManaged {
978 bail!("only CodeWhale managed skills can be trusted");
979 }
980 validate_owned_skill_path(ctx, &skill, &path)?;
981 let before = verify_expected_digest(&path, Some(&expected_digest))?;
982 validate_owned_skill_path(ctx, &skill, &path)?;
983 write_trust_v2(&path, &expected_digest)?;
984 let scope = match skill.root.kind {
985 SkillRootKind::CodeWhaleProject => SkillScope::Project,
986 SkillRootKind::CodeWhaleGlobal => SkillScope::Global,
987 _ => SkillScope::Logical,
988 };
989 Ok(SkillMutationReceipt {
990 action: SkillActionKind::Trust,
991 name: skill_id.canonical_name,
992 scope,
993 safe_target_path: safe_display_path(&path, Some(ctx.workspace), ctx.home),
994 before_digest: before.clone(),
995 after_digest: before,
996 outcome: SkillMutationOutcome::Trusted,
997 })
998 }
999
1000 #[cfg(test)]
1001 mod tests {
1002 use super::*;
1003 use crate::network_policy::NetworkPolicy;
1004 use tempfile::TempDir;
1005
1006 fn write_skill(dir: &Path, name: &str, body: &str) {
1007 let skill_dir = dir.join(name);
1008 fs::create_dir_all(&skill_dir).unwrap();
1009 fs::write(
1010 skill_dir.join("SKILL.md"),
1011 format!("---\nname: {name}\ndescription: d\n---\n{body}\n"),
1012 )
1013 .unwrap();
1014 }
1015
1016 #[cfg(unix)]
1017 fn write_managed_skill(root: &Path, name: &str) -> String {
1018 write_skill(root, name, "managed body");
1019 let skill_dir = root.join(name);
1020 let digest = package_digest::compute_package_digest(&skill_dir).unwrap();
1021 write_installed_from_v2(
1022 &skill_dir,
1023 "github:owner/repo",
1024 None,
1025 "source-digest",
1026 &digest,
1027 name,
1028 )
1029 .unwrap();
1030 digest
1031 }
1032
1033 fn ctx<'a>(
1034 workspace: &'a Path,
1035 home: &'a Path,
1036 network: &'a NetworkPolicy,
1037 ) -> MutationContext<'a> {
1038 MutationContext {
1039 workspace,
1040 home: Some(home),
1041 configured_skills_dir: None,
1042 network,
1043 max_size: install::DEFAULT_MAX_SIZE_BYTES,
1044 registry_url: install::DEFAULT_REGISTRY_URL,
1045 }
1046 }
1047
1048 #[test]
1049 fn resolve_owned_target_project_and_global() {
1050 let tmp = TempDir::new().unwrap();
1051 let workspace = tmp.path().join("ws");
1052 let home = tmp.path().join("home");
1053 fs::create_dir_all(&workspace).unwrap();
1054 fs::create_dir_all(&home).unwrap();
1055
1056 let project =
1057 resolve_owned_target(&workspace, Some(&home), SkillTargetScope::Project).unwrap();
1058 let global =
1059 resolve_owned_target(&workspace, Some(&home), SkillTargetScope::Global).unwrap();
1060 assert_eq!(project, workspace.join(".codewhale").join("skills"));
1061 assert_eq!(global, home.join(".codewhale").join("skills"));
1062 }
1063
1064 #[cfg(unix)]
1065 #[test]
1066 fn owned_target_resolution_rejects_symlinked_parent_and_root_in_both_scopes() {
1067 for target in [SkillTargetScope::Project, SkillTargetScope::Global] {
1068 let tmp = TempDir::new().unwrap();
1069 let workspace = tmp.path().join("ws");
1070 let home = tmp.path().join("home");
1071 fs::create_dir_all(&workspace).unwrap();
1072 fs::create_dir_all(&home).unwrap();
1073 let anchor = match target {
1074 SkillTargetScope::Project => &workspace,
1075 SkillTargetScope::Global => &home,
1076 };
1077
1078 let outside_parent = tmp.path().join("outside-parent");
1079 fs::create_dir_all(&outside_parent).unwrap();
1080 std::os::unix::fs::symlink(&outside_parent, anchor.join(".codewhale")).unwrap();
1081 let err = resolve_owned_target(&workspace, Some(&home), target).unwrap_err();
1082 assert!(err.to_string().contains("symlinked"), "got: {err}");
1083
1084 fs::remove_file(anchor.join(".codewhale")).unwrap();
1085 fs::create_dir(anchor.join(".codewhale")).unwrap();
1086 let outside_root = tmp.path().join("outside-root");
1087 fs::create_dir_all(&outside_root).unwrap();
1088 std::os::unix::fs::symlink(&outside_root, anchor.join(".codewhale").join("skills"))
1089 .unwrap();
1090 let err = resolve_owned_target(&workspace, Some(&home), target).unwrap_err();
1091 assert!(err.to_string().contains("symlinked"), "got: {err}");
1092 }
1093 }
1094
1095 #[cfg(unix)]
1096 #[tokio::test]
1097 async fn install_rejects_symlinked_owned_root_without_external_writes() {
1098 let tmp = TempDir::new().unwrap();
1099 let workspace = tmp.path().join("ws");
1100 let home = tmp.path().join("home");
1101 let outside = tmp.path().join("outside");
1102 fs::create_dir_all(workspace.join(".codewhale")).unwrap();
1103 fs::create_dir_all(&home).unwrap();
1104 fs::create_dir_all(&outside).unwrap();
1105 fs::write(outside.join("SENTINEL"), "untouched").unwrap();
1106 std::os::unix::fs::symlink(&outside, workspace.join(".codewhale").join("skills")).unwrap();
1107
1108 let network = NetworkPolicy::default();
1109 let c = ctx(&workspace, &home, &network);
1110 let err = execute(
1111 SkillMutationRequest::InstallRemote {
1112 source: InstallSource::GitHubRepo("owner/repo".into()),
1113 target: SkillTargetScope::Project,
1114 },
1115 &c,
1116 )
1117 .await
1118 .unwrap_err();
1119 assert!(err.to_string().contains("symlinked"), "got: {err}");
1120 assert_eq!(
1121 fs::read_to_string(outside.join("SENTINEL")).unwrap(),
1122 "untouched"
1123 );
1124 assert_eq!(fs::read_dir(&outside).unwrap().count(), 1);
1125 }
1126
1127 #[cfg(unix)]
1128 #[test]
1129 fn import_rejects_symlinked_owned_parent_without_external_writes() {
1130 let tmp = TempDir::new().unwrap();
1131 let workspace = tmp.path().join("ws");
1132 let home = tmp.path().join("home");
1133 let outside_parent = tmp.path().join("outside-parent");
1134 let outside_root = outside_parent.join("skills");
1135 fs::create_dir_all(&workspace).unwrap();
1136 fs::create_dir_all(&home).unwrap();
1137 fs::create_dir_all(&outside_root).unwrap();
1138 fs::write(outside_root.join("SENTINEL"), "untouched").unwrap();
1139 write_skill(
1140 &workspace.join(".claude").join("skills"),
1141 "from-claude",
1142 "import me",
1143 );
1144 std::os::unix::fs::symlink(&outside_parent, workspace.join(".codewhale")).unwrap();
1145
1146 let network = NetworkPolicy::default();
1147 let c = ctx(&workspace, &home, &network);
1148 let snap = scan_with_configured(
1149 &workspace,
1150 Some(&home),
1151 None,
1152 SkillAuditMode::Compatible,
1153 None,
1154 );
1155 let external = snap
1156 .skills
1157 .iter()
1158 .find(|skill| skill.source_kind == SkillSourceKind::CompatibleExternal)
1159 .unwrap();
1160 let DigestState::Known(digest) = &external.digest else {
1161 panic!("digest");
1162 };
1163 let err = execute_sync(
1164 SkillMutationRequest::ImportExternal {
1165 source_id: external.id.clone(),
1166 expected_digest: digest.clone(),
1167 target: SkillTargetScope::Project,
1168 conflict_policy: ConflictPolicy::Reject,
1169 },
1170 &c,
1171 )
1172 .unwrap_err();
1173 assert!(err.to_string().contains("symlinked"), "got: {err}");
1174 assert_eq!(
1175 fs::read_to_string(outside_root.join("SENTINEL")).unwrap(),
1176 "untouched"
1177 );
1178 assert!(!outside_root.join("from-claude").exists());
1179 }
1180
1181 #[cfg(unix)]
1182 #[test]
1183 fn remove_rejects_symlinked_owned_root_without_external_deletion() {
1184 let tmp = TempDir::new().unwrap();
1185 let workspace = tmp.path().join("ws");
1186 let home = tmp.path().join("home");
1187 let outside_root = tmp.path().join("outside-root");
1188 fs::create_dir_all(workspace.join(".codewhale")).unwrap();
1189 fs::create_dir_all(&home).unwrap();
1190 fs::create_dir_all(&outside_root).unwrap();
1191 let digest = write_managed_skill(&outside_root, "managed");
1192 std::os::unix::fs::symlink(&outside_root, workspace.join(".codewhale").join("skills"))
1193 .unwrap();
1194
1195 let network = NetworkPolicy::default();
1196 let c = ctx(&workspace, &home, &network);
1197 let snap = scan_with_configured(
1198 &workspace,
1199 Some(&home),
1200 None,
1201 SkillAuditMode::OwnedOnly,
1202 None,
1203 );
1204 let skill = snap.skills.first().expect("managed skill through symlink");
1205 let err = execute_sync(
1206 SkillMutationRequest::Remove {
1207 skill_id: skill.id.clone(),
1208 expected_digest: Some(digest),
1209 },
1210 &c,
1211 )
1212 .unwrap_err();
1213 assert!(err.to_string().contains("symlinked"), "got: {err}");
1214 assert!(outside_root.join("managed").join("SKILL.md").is_file());
1215 }
1216
1217 #[cfg(unix)]
1218 #[test]
1219 fn trust_rejects_symlinked_owned_parent_without_external_marker_write() {
1220 let tmp = TempDir::new().unwrap();
1221 let workspace = tmp.path().join("ws");
1222 let home = tmp.path().join("home");
1223 let outside_parent = tmp.path().join("outside-parent");
1224 let outside_root = outside_parent.join("skills");
1225 fs::create_dir_all(&workspace).unwrap();
1226 fs::create_dir_all(&home).unwrap();
1227 fs::create_dir_all(&outside_root).unwrap();
1228 let digest = write_managed_skill(&outside_root, "managed");
1229 std::os::unix::fs::symlink(&outside_parent, workspace.join(".codewhale")).unwrap();
1230
1231 let network = NetworkPolicy::default();
1232 let c = ctx(&workspace, &home, &network);
1233 let snap = scan_with_configured(
1234 &workspace,
1235 Some(&home),
1236 None,
1237 SkillAuditMode::OwnedOnly,
1238 None,
1239 );
1240 let skill = snap.skills.first().expect("managed skill through symlink");
1241 let err = execute_sync(
1242 SkillMutationRequest::Trust {
1243 skill_id: skill.id.clone(),
1244 expected_digest: digest,
1245 },
1246 &c,
1247 )
1248 .unwrap_err();
1249 assert!(err.to_string().contains("symlinked"), "got: {err}");
1250 assert!(
1251 !outside_root
1252 .join("managed")
1253 .join(install::TRUSTED_MARKER)
1254 .exists()
1255 );
1256 }
1257
1258 #[test]
1259 fn remove_and_trust_require_managed_owned() {
1260 let tmp = TempDir::new().unwrap();
1261 let workspace = tmp.path().join("ws");
1262 let home = tmp.path().join("home");
1263 let network = NetworkPolicy::default();
1264 let c = ctx(&workspace, &home, &network);
1265
1266 write_skill(
1267 &workspace.join(".codewhale").join("skills"),
1268 "manual",
1269 "body",
1270 );
1271 let err = resolve_owned_skill_by_name(&c, "manual", Some(SkillTargetScope::Project));
1272 // Manual skill resolves, but remove/trust should fail source_kind check.
1273 let resolved = err.unwrap();
1274 let remove = remove_skill(resolved.id.clone(), Some(resolved.digest.clone()), &c);
1275 assert!(remove.is_err());
1276
1277 write_skill(&workspace.join(".claude").join("skills"), "ext", "body");
1278 // Place sentinel in external root.
1279 let sentinel = workspace.join(".claude").join("skills").join("SENTINEL");
1280 fs::write(&sentinel, "do-not-touch").unwrap();
1281
1282 let err = resolve_owned_skill_by_name(&c, "ext", None);
1283 assert!(err.unwrap_err().to_string().contains("compatible external"));
1284 assert_eq!(fs::read_to_string(&sentinel).unwrap(), "do-not-touch");
1285 }
1286
1287 #[test]
1288 fn import_external_copies_into_project_owned() {
1289 let tmp = TempDir::new().unwrap();
1290 let workspace = tmp.path().join("ws");
1291 let home = tmp.path().join("home");
1292 let network = NetworkPolicy::default();
1293 let c = ctx(&workspace, &home, &network);
1294
1295 fs::create_dir_all(workspace.join(".codewhale").join("skills")).unwrap();
1296 write_skill(
1297 &workspace.join(".claude").join("skills"),
1298 "from-claude",
1299 "import-me",
1300 );
1301 let sentinel = workspace.join(".claude").join("skills").join("SENTINEL");
1302 fs::write(&sentinel, "keep").unwrap();
1303
1304 let snap = scan_with_configured(
1305 &workspace,
1306 Some(&home),
1307 None,
1308 SkillAuditMode::Compatible,
1309 None,
1310 );
1311 let external = snap
1312 .skills
1313 .iter()
1314 .find(|s| s.name == "from-claude")
1315 .unwrap();
1316 let DigestState::Known(digest) = &external.digest else {
1317 panic!("digest");
1318 };
1319
1320 let receipt = import_external(
1321 external.id.clone(),
1322 digest.clone(),
1323 SkillTargetScope::Project,
1324 ConflictPolicy::Reject,
1325 &c,
1326 )
1327 .unwrap();
1328 assert_eq!(receipt.outcome, SkillMutationOutcome::Imported);
1329 assert!(
1330 workspace
1331 .join(".codewhale")
1332 .join("skills")
1333 .join("from-claude")
1334 .join("SKILL.md")
1335 .is_file()
1336 );
1337 assert_eq!(fs::read_to_string(&sentinel).unwrap(), "keep");
1338 // Source untouched.
1339 assert!(
1340 workspace
1341 .join(".claude")
1342 .join("skills")
1343 .join("from-claude")
1344 .join("SKILL.md")
1345 .is_file()
1346 );
1347 }
1348
1349 #[test]
1350 fn import_exact_duplicate_is_already_present() {
1351 let tmp = TempDir::new().unwrap();
1352 let workspace = tmp.path().join("ws");
1353 let home = tmp.path().join("home");
1354 let network = NetworkPolicy::default();
1355 let c = ctx(&workspace, &home, &network);
1356
1357 let content = "---\nname: shared\ndescription: d\n---\nbody\n";
1358 let owned = workspace.join(".codewhale").join("skills").join("shared");
1359 let external = workspace.join(".claude").join("skills").join("shared");
1360 fs::create_dir_all(&owned).unwrap();
1361 fs::create_dir_all(&external).unwrap();
1362 fs::write(owned.join("SKILL.md"), content).unwrap();
1363 fs::write(external.join("SKILL.md"), content).unwrap();
1364
1365 let snap = scan_with_configured(
1366 &workspace,
1367 Some(&home),
1368 None,
1369 SkillAuditMode::Compatible,
1370 None,
1371 );
1372 let external = snap
1373 .skills
1374 .iter()
1375 .find(|s| s.name == "shared" && s.source_kind == SkillSourceKind::CompatibleExternal)
1376 .unwrap();
1377 let DigestState::Known(digest) = &external.digest else {
1378 panic!("digest");
1379 };
1380
1381 let receipt = import_external(
1382 external.id.clone(),
1383 digest.clone(),
1384 SkillTargetScope::Project,
1385 ConflictPolicy::Reject,
1386 &c,
1387 )
1388 .unwrap();
1389 assert_eq!(receipt.outcome, SkillMutationOutcome::AlreadyPresent);
1390 }
1391
1392 #[test]
1393 fn trust_writes_v2_digest_binding() {
1394 let tmp = TempDir::new().unwrap();
1395 let workspace = tmp.path().join("ws");
1396 let home = tmp.path().join("home");
1397 let network = NetworkPolicy::default();
1398 let c = ctx(&workspace, &home, &network);
1399 let root = workspace.join(".codewhale").join("skills");
1400 write_skill(&root, "managed", "body");
1401 let digest = package_digest::compute_package_digest(&root.join("managed")).unwrap();
1402 write_installed_from_v2(
1403 &root.join("managed"),
1404 "github:o/r",
1405 None,
1406 "src",
1407 &digest,
1408 "managed",
1409 )
1410 .unwrap();
1411
1412 let snap = scan_with_configured(
1413 &workspace,
1414 Some(&home),
1415 None,
1416 SkillAuditMode::OwnedOnly,
1417 None,
1418 );
1419 let skill = &snap.skills[0];
1420 let receipt = trust_skill(skill.id.clone(), digest.clone(), &c).unwrap();
1421 assert_eq!(receipt.outcome, SkillMutationOutcome::Trusted);
1422 let trust_body =
1423 fs::read_to_string(root.join("managed").join(install::TRUSTED_MARKER)).unwrap();
1424 assert!(trust_body.contains("schema_version"));
1425 assert!(trust_body.contains(&digest));
1426 }
1427
1428 #[test]
1429 fn remove_managed_skill() {
1430 let tmp = TempDir::new().unwrap();
1431 let workspace = tmp.path().join("ws");
1432 let home = tmp.path().join("home");
1433 let network = NetworkPolicy::default();
1434 let c = ctx(&workspace, &home, &network);
1435 let root = workspace.join(".codewhale").join("skills");
1436 write_skill(&root, "managed", "body");
1437 let digest = package_digest::compute_package_digest(&root.join("managed")).unwrap();
1438 write_installed_from_v2(
1439 &root.join("managed"),
1440 "github:o/r",
1441 None,
1442 "src",
1443 &digest,
1444 "managed",
1445 )
1446 .unwrap();
1447
1448 let snap = scan_with_configured(
1449 &workspace,
1450 Some(&home),
1451 None,
1452 SkillAuditMode::OwnedOnly,
1453 None,
1454 );
1455 let skill = &snap.skills[0];
1456 let receipt = remove_skill(skill.id.clone(), Some(digest), &c).unwrap();
1457 assert_eq!(receipt.outcome, SkillMutationOutcome::Removed);
1458 assert!(!root.join("managed").exists());
1459 }
1460
1461 #[test]
1462 fn resolve_by_name_normalizes_like_registry() {
1463 let tmp = TempDir::new().unwrap();
1464 let workspace = tmp.path().join("ws");
1465 let home = tmp.path().join("home");
1466 let network = NetworkPolicy::default();
1467 let c = ctx(&workspace, &home, &network);
1468 let root = workspace.join(".codewhale").join("skills");
1469 write_skill(&root, "Hello_World", "body");
1470 let digest = package_digest::compute_package_digest(&root.join("Hello_World")).unwrap();
1471 write_installed_from_v2(
1472 &root.join("Hello_World"),
1473 "github:o/r",
1474 None,
1475 "src",
1476 &digest,
1477 "Hello_World",
1478 )
1479 .unwrap();
1480
1481 let resolved =
1482 resolve_owned_skill_by_name(&c, "Hello_World", Some(SkillTargetScope::Project))
1483 .unwrap();
1484 assert_eq!(resolved.id.canonical_name, "hello-world");
1485 assert_eq!(
1486 resolve_owned_skill_by_name(&c, "hello-world", Some(SkillTargetScope::Project))
1487 .unwrap()
1488 .id,
1489 resolved.id
1490 );
1491 }
1492
1493 #[test]
1494 fn remove_uses_on_disk_dir_not_canonical_name() {
1495 // Frontmatter/dir keep underscores+case; audit canonicalizes to dashes+lower.
1496 let tmp = TempDir::new().unwrap();
1497 let workspace = tmp.path().join("ws");
1498 let home = tmp.path().join("home");
1499 let network = NetworkPolicy::default();
1500 let c = ctx(&workspace, &home, &network);
1501 let root = workspace.join(".codewhale").join("skills");
1502 let dir_name = "Hello_World";
1503 write_skill(&root, dir_name, "body");
1504 let digest = package_digest::compute_package_digest(&root.join(dir_name)).unwrap();
1505 write_installed_from_v2(
1506 &root.join(dir_name),
1507 "github:o/r",
1508 None,
1509 "src",
1510 &digest,
1511 dir_name,
1512 )
1513 .unwrap();
1514
1515 let snap = scan_with_configured(
1516 &workspace,
1517 Some(&home),
1518 None,
1519 SkillAuditMode::OwnedOnly,
1520 None,
1521 );
1522 let skill = snap
1523 .skills
1524 .iter()
1525 .find(|s| s.id.canonical_name == "hello-world")
1526 .expect("canonical name should fold Hello_World → hello-world");
1527 assert_ne!(skill.id.canonical_name, dir_name);
1528 assert_eq!(
1529 skill.id.relative_dir.file_name().unwrap().to_str().unwrap(),
1530 dir_name
1531 );
1532
1533 let receipt = remove_skill(skill.id.clone(), Some(digest), &c).unwrap();
1534 assert_eq!(receipt.outcome, SkillMutationOutcome::Removed);
1535 assert!(!root.join(dir_name).exists());
1536 }
1537
1538 #[test]
1539 fn imported_skill_rejects_registry_update() {
1540 let tmp = TempDir::new().unwrap();
1541 let workspace = tmp.path().join("ws");
1542 let home = tmp.path().join("home");
1543 let network = NetworkPolicy::default();
1544 let c = ctx(&workspace, &home, &network);
1545
1546 fs::create_dir_all(workspace.join(".codewhale").join("skills")).unwrap();
1547 write_skill(
1548 &workspace.join(".claude").join("skills"),
1549 "from-claude",
1550 "import-me",
1551 );
1552 let snap = scan_with_configured(
1553 &workspace,
1554 Some(&home),
1555 None,
1556 SkillAuditMode::Compatible,
1557 None,
1558 );
1559 let external = snap
1560 .skills
1561 .iter()
1562 .find(|s| s.name == "from-claude")
1563 .unwrap();
1564 let DigestState::Known(digest) = &external.digest else {
1565 panic!("digest");
1566 };
1567 import_external(
1568 external.id.clone(),
1569 digest.clone(),
1570 SkillTargetScope::Project,
1571 ConflictPolicy::Reject,
1572 &c,
1573 )
1574 .unwrap();
1575
1576 let owned = workspace
1577 .join(".codewhale")
1578 .join("skills")
1579 .join("from-claude");
1580 assert!(ensure_remote_updatable(&owned).is_err());
1581 assert!(!install::is_registry_updatable_spec(
1582 "import:<workspace>/.claude/skills/from-claude"
1583 ));
1584 assert!(install::is_registry_updatable_spec("github:owner/repo"));
1585 }
1586
1587 #[test]
1588 fn import_rejects_when_owned_dir_differs_from_canonical() {
1589 let tmp = TempDir::new().unwrap();
1590 let workspace = tmp.path().join("ws");
1591 let home = tmp.path().join("home");
1592 let network = NetworkPolicy::default();
1593 let c = ctx(&workspace, &home, &network);
1594
1595 let owned_root = workspace.join(".codewhale").join("skills");
1596 write_skill(&owned_root, "Hello_World", "original-owned");
1597 let original_digest =
1598 package_digest::compute_package_digest(&owned_root.join("Hello_World")).unwrap();
1599 write_installed_from_v2(
1600 &owned_root.join("Hello_World"),
1601 "github:o/r",
1602 None,
1603 "src",
1604 &original_digest,
1605 "Hello_World",
1606 )
1607 .unwrap();
1608
1609 write_skill(
1610 &workspace.join(".claude").join("skills"),
1611 "Hello_World",
1612 "external-different",
1613 );
1614 let snap = scan_with_configured(
1615 &workspace,
1616 Some(&home),
1617 None,
1618 SkillAuditMode::Compatible,
1619 None,
1620 );
1621 let external = snap
1622 .skills
1623 .iter()
1624 .find(|s| {
1625 s.id.canonical_name == "hello-world"
1626 && s.source_kind == SkillSourceKind::CompatibleExternal
1627 })
1628 .unwrap();
1629 let DigestState::Known(ext_digest) = &external.digest else {
1630 panic!("digest");
1631 };
1632
1633 let err = import_external(
1634 external.id.clone(),
1635 ext_digest.clone(),
1636 SkillTargetScope::Project,
1637 ConflictPolicy::Reject,
1638 &c,
1639 );
1640 assert!(err.unwrap_err().to_string().contains("already exists"));
1641 assert!(owned_root.join("Hello_World").exists());
1642 assert!(!owned_root.join("hello-world").exists());
1643 let still =
1644 package_digest::compute_package_digest(&owned_root.join("Hello_World")).unwrap();
1645 assert_eq!(still, original_digest);
1646
1647 let receipt = import_external(
1648 external.id.clone(),
1649 ext_digest.clone(),
1650 SkillTargetScope::Project,
1651 ConflictPolicy::ReplaceConfirmed,
1652 &c,
1653 )
1654 .unwrap();
1655 assert_eq!(receipt.outcome, SkillMutationOutcome::Imported);
1656 assert!(owned_root.join("Hello_World").exists());
1657 assert!(!owned_root.join("hello-world").exists());
1658 assert!(!owned_root.join("Hello_World.bak").exists());
1659 let after =
1660 package_digest::compute_package_digest(&owned_root.join("Hello_World")).unwrap();
1661 assert_eq!(after, *ext_digest);
1662 }
1663
1664 #[test]
1665 fn import_conflict_reject_keeps_owned_and_replace_cleans_backup() {
1666 let tmp = TempDir::new().unwrap();
1667 let workspace = tmp.path().join("ws");
1668 let home = tmp.path().join("home");
1669 let network = NetworkPolicy::default();
1670 let c = ctx(&workspace, &home, &network);
1671
1672 let owned_root = workspace.join(".codewhale").join("skills");
1673 write_skill(&owned_root, "shared", "original-owned");
1674 let original_digest =
1675 package_digest::compute_package_digest(&owned_root.join("shared")).unwrap();
1676 write_installed_from_v2(
1677 &owned_root.join("shared"),
1678 "github:o/r",
1679 None,
1680 "src",
1681 &original_digest,
1682 "shared",
1683 )
1684 .unwrap();
1685
1686 write_skill(
1687 &workspace.join(".claude").join("skills"),
1688 "shared",
1689 "external-different",
1690 );
1691 let snap = scan_with_configured(
1692 &workspace,
1693 Some(&home),
1694 None,
1695 SkillAuditMode::Compatible,
1696 None,
1697 );
1698 let external = snap
1699 .skills
1700 .iter()
1701 .find(|s| s.name == "shared" && s.source_kind == SkillSourceKind::CompatibleExternal)
1702 .unwrap();
1703 let DigestState::Known(ext_digest) = &external.digest else {
1704 panic!("digest");
1705 };
1706
1707 let err = import_external(
1708 external.id.clone(),
1709 ext_digest.clone(),
1710 SkillTargetScope::Project,
1711 ConflictPolicy::Reject,
1712 &c,
1713 );
1714 assert!(err.is_err());
1715 let still = package_digest::compute_package_digest(&owned_root.join("shared")).unwrap();
1716 assert_eq!(still, original_digest);
1717
1718 let receipt = import_external(
1719 external.id.clone(),
1720 ext_digest.clone(),
1721 SkillTargetScope::Project,
1722 ConflictPolicy::ReplaceConfirmed,
1723 &c,
1724 )
1725 .unwrap();
1726 assert_eq!(receipt.outcome, SkillMutationOutcome::Imported);
1727 let after = package_digest::compute_package_digest(&owned_root.join("shared")).unwrap();
1728 assert_eq!(after, *ext_digest);
1729 assert!(!owned_root.join("shared.bak").exists());
1730 }
1731
1732 #[test]
1733 fn digest_mismatch_rejects_remove() {
1734 let tmp = TempDir::new().unwrap();
1735 let workspace = tmp.path().join("ws");
1736 let home = tmp.path().join("home");
1737 let network = NetworkPolicy::default();
1738 let c = ctx(&workspace, &home, &network);
1739 let root = workspace.join(".codewhale").join("skills");
1740 write_skill(&root, "managed", "body");
1741 let digest = package_digest::compute_package_digest(&root.join("managed")).unwrap();
1742 write_installed_from_v2(
1743 &root.join("managed"),
1744 "github:o/r",
1745 None,
1746 "src",
1747 &digest,
1748 "managed",
1749 )
1750 .unwrap();
1751
1752 let snap = scan_with_configured(
1753 &workspace,
1754 Some(&home),
1755 None,
1756 SkillAuditMode::OwnedOnly,
1757 None,
1758 );
1759 let err = remove_skill(snap.skills[0].id.clone(), Some("deadbeef".into()), &c);
1760 assert!(err.unwrap_err().to_string().contains("changed since audit"));
1761 assert!(root.join("managed").exists());
1762 }
1763
1764 #[test]
1765 fn dual_scope_same_name_requires_explicit_scope() {
1766 let tmp = TempDir::new().unwrap();
1767 let workspace = tmp.path().join("ws");
1768 let home = tmp.path().join("home");
1769 let network = NetworkPolicy::default();
1770 let c = ctx(&workspace, &home, &network);
1771
1772 write_skill(
1773 &workspace.join(".codewhale").join("skills"),
1774 "dup",
1775 "project",
1776 );
1777 write_skill(&home.join(".codewhale").join("skills"), "dup", "global");
1778
1779 let err = resolve_owned_skill_by_name(&c, "dup", None).unwrap_err();
1780 assert!(
1781 err.to_string().contains("--project") && err.to_string().contains("--global"),
1782 "got: {err}"
1783 );
1784
1785 let project =
1786 resolve_owned_skill_by_name(&c, "dup", Some(SkillTargetScope::Project)).unwrap();
1787 let global =
1788 resolve_owned_skill_by_name(&c, "dup", Some(SkillTargetScope::Global)).unwrap();
1789 assert_ne!(project.id.root_id, global.id.root_id);
1790 assert_eq!(project.id.canonical_name, "dup");
1791 assert_eq!(global.id.canonical_name, "dup");
1792 }
1793
1794 #[test]
1795 fn uninstall_external_only_refuses_and_keeps_sentinel() {
1796 let tmp = TempDir::new().unwrap();
1797 let workspace = tmp.path().join("ws");
1798 let home = tmp.path().join("home");
1799 let network = NetworkPolicy::default();
1800 let c = ctx(&workspace, &home, &network);
1801
1802 fs::create_dir_all(workspace.join(".codewhale").join("skills")).unwrap();
1803 write_skill(
1804 &workspace.join(".claude").join("skills"),
1805 "only-ext",
1806 "body",
1807 );
1808 let sentinel = workspace.join(".claude").join("skills").join("SENTINEL");
1809 fs::write(&sentinel, "untouched").unwrap();
1810
1811 let err = resolve_owned_skill_by_name(&c, "only-ext", None).unwrap_err();
1812 assert!(
1813 err.to_string().contains("compatible external"),
1814 "got: {err}"
1815 );
1816 assert_eq!(fs::read_to_string(&sentinel).unwrap(), "untouched");
1817 assert!(
1818 workspace
1819 .join(".claude")
1820 .join("skills")
1821 .join("only-ext")
1822 .join("SKILL.md")
1823 .is_file()
1824 );
1825 }
1826 }
1827
1827 lines RUST