@@ -17,6 +17,7 @@ use super::skills_models::{
1717 InstallOperation , InstallPlanResponse , InstalledSkillMetadata , InstalledSkillRequest ,
1818 SkillDetail , Warning ,
1919} ;
20+ use super :: skills_slug:: { confined_skill_path, ensure_confined_skill_path, validate_slug} ;
2021use super :: skills_targets:: {
2122 backup_unmanaged_path, copy_dir_recursive, finish_link, iso8601_utc, link_into_target,
2223 remove_any, rollback_link, BackupOutcome , LinkOutcome , ResolvedTarget , Scope ,
@@ -215,7 +216,12 @@ impl PackageReplacement {
215216 }
216217}
217218
218- pub fn replace_managed_dir ( staging : & Path , final_dir : & Path ) -> Result < PackageReplacement > {
219+ pub fn replace_managed_dir (
220+ root : & Path ,
221+ staging : & Path ,
222+ final_dir : & Path ,
223+ ) -> Result < PackageReplacement > {
224+ ensure_confined_skill_path ( root, final_dir) ?;
219225 let final_metadata = fs:: symlink_metadata ( final_dir) . ok ( ) ;
220226 let final_exists = final_metadata. is_some ( ) ;
221227 let is_bb_owned = final_metadata. is_some_and ( |metadata| metadata. is_dir ( ) )
@@ -344,6 +350,18 @@ pub fn execute_plan(
344350 plan : InstallPlanResponse ,
345351 options : & ExecuteOptions ,
346352) -> Result < PlanExecution > {
353+ // Validate the entire untrusted plan before the first operation can fetch
354+ // an artifact or mutate the filesystem. Deserialization already applies
355+ // this contract; this second gate protects programmatic future callers.
356+ for operation in & plan. operations {
357+ validate_slug ( & operation. skill . slug ) . with_context ( || {
358+ format ! (
359+ "invalid skill slug in install plan operation `{}`" ,
360+ operation. action
361+ )
362+ } ) ?;
363+ }
364+
347365 let mut execution = PlanExecution {
348366 plan_id : plan. plan_id ,
349367 warnings : plan. warnings ,
@@ -395,7 +413,7 @@ fn execute_install_operation(
395413 . as_ref ( )
396414 . context ( "install operation did not include artifact metadata" ) ?;
397415
398- let final_dir = canonical_dir ( config, options. scope , slug) ;
416+ let final_dir = confined_skill_path ( & canonical_root ( config, options. scope ) , slug) ? ;
399417
400418 // Source provenance comes from the catalog detail; failures downgrade to
401419 // missing provenance rather than blocking the install.
@@ -466,7 +484,13 @@ fn persist_download(config: &SkillsConfig, slug: &str, version_id: &str, bytes:
466484 if fs:: create_dir_all ( & downloads) . is_err ( ) {
467485 return ;
468486 }
469- let _ = fs:: write ( downloads. join ( format ! ( "{slug}-{version_id}.zip" ) ) , bytes) ;
487+ let version_key = sha256_hex ( version_id. as_bytes ( ) ) ;
488+ let Ok ( download_path) = confined_skill_path ( & downloads, slug)
489+ . map ( |path| path. with_file_name ( format ! ( "{slug}-{version_key}.zip" ) ) )
490+ else {
491+ return ;
492+ } ;
493+ let _ = fs:: write ( download_path, bytes) ;
470494}
471495
472496fn write_package (
@@ -478,6 +502,7 @@ fn write_package(
478502 let parent = final_dir
479503 . parent ( )
480504 . context ( "package directory has no parent" ) ?;
505+ ensure_confined_skill_path ( parent, final_dir) ?;
481506 fs:: create_dir_all ( parent) . with_context ( || format ! ( "create {}" , parent. display( ) ) ) ?;
482507
483508 let staging = parent. join ( format ! ( ".{}.tmp-{}" , metadata. slug, unique_suffix( ) ) ) ;
@@ -499,7 +524,7 @@ fn write_package(
499524 serde_json:: to_vec_pretty ( metadata) . context ( "serialize install metadata" ) ?,
500525 )
501526 . context ( "write install metadata" ) ?;
502- replace_managed_dir ( & staging, final_dir)
527+ replace_managed_dir ( parent , & staging, final_dir)
503528 } ) ( ) ;
504529 if result. is_err ( ) && staging. exists ( ) {
505530 let _ = fs:: remove_dir_all ( & staging) ;
@@ -513,6 +538,7 @@ pub fn link_targets(
513538 targets : & [ ResolvedTarget ] ,
514539 slug : & str ,
515540) -> Result < Vec < LinkOutcome > > {
541+ validate_slug ( slug) ?;
516542 let mut links = Vec :: new ( ) ;
517543 for target in targets {
518544 for base_dir in & target. base_dirs {
@@ -653,6 +679,7 @@ pub fn install_local_path(
653679 let parent = final_dir
654680 . parent ( )
655681 . context ( "package directory has no parent" ) ?;
682+ ensure_confined_skill_path ( parent, & final_dir) ?;
656683 fs:: create_dir_all ( parent) . with_context ( || format ! ( "create {}" , parent. display( ) ) ) ?;
657684 let staging = parent. join ( format ! ( ".{slug}.tmp-{}" , unique_suffix( ) ) ) ;
658685 if staging. exists ( ) {
@@ -685,7 +712,7 @@ pub fn install_local_path(
685712 serde_json:: to_vec_pretty ( & metadata) . context ( "serialize install metadata" ) ?,
686713 )
687714 . context ( "write install metadata" ) ?;
688- let package_replacement = replace_managed_dir ( & staging, & final_dir) ?;
715+ let package_replacement = replace_managed_dir ( parent , & staging, & final_dir) ?;
689716 let links = match link_targets ( & final_dir, targets, & slug) {
690717 Ok ( links) => links,
691718 Err ( error) => {
@@ -717,18 +744,6 @@ pub fn install_local_path(
717744 } )
718745}
719746
720- fn validate_slug ( slug : & str ) -> Result < ( ) > {
721- let valid = !slug. is_empty ( )
722- && slug
723- . chars ( )
724- . all ( |c| c. is_ascii_lowercase ( ) || c. is_ascii_digit ( ) || c == '-' || c == '_' ) ;
725- if valid {
726- Ok ( ( ) )
727- } else {
728- anyhow:: bail!( "invalid skill name `{slug}`; use lowercase letters, digits, `-`, and `_`" )
729- }
730- }
731-
732747/// Deterministic content hash over a directory's files (path + bytes).
733748fn hash_directory ( dir : & Path ) -> Result < String > {
734749 let mut entries = Vec :: new ( ) ;
@@ -803,7 +818,7 @@ pub fn remove_skill(
803818) -> Result < RemovalReport > {
804819 use super :: skills_targets:: { inspect_link, remove_any, LinkState , TargetRegistry } ;
805820
806- let final_dir = canonical_dir ( config, scope, slug) ;
821+ let final_dir = confined_skill_path ( & canonical_root ( config, scope) , slug) ? ;
807822 let metadata = read_metadata ( & final_dir) . ok ( ) ;
808823 if metadata. is_none ( ) && !( include_unmanaged && force) {
809824 return Err ( failure (
@@ -840,7 +855,7 @@ pub fn remove_skill(
840855 let canonical_root = final_dir. parent ( ) ;
841856 for target in targets {
842857 for base_dir in & target. base_dirs {
843- let link_path = base_dir . join ( slug) ;
858+ let link_path = confined_skill_path ( base_dir , slug) ? ;
844859 // The agents target's directory is the canonical packages root
845860 // itself (skills live there; other targets link to it), so its
846861 // entry is never a link to remove — package removal below
@@ -875,7 +890,8 @@ pub fn remove_skill(
875890 }
876891 }
877892 // Clean up legacy Phase 1 copies under <skills_home>/targets/.
878- let legacy = config. legacy_target_dir ( & target. name ) . join ( slug) ;
893+ let legacy_root = config. legacy_target_dir ( & target. name ) ;
894+ let legacy = confined_skill_path ( & legacy_root, slug) ?;
879895 if legacy. exists ( ) {
880896 if legacy. join ( META_FILE_NAME ) . is_file ( ) || ( include_unmanaged && force) {
881897 remove_any ( & legacy) ?;
@@ -999,7 +1015,7 @@ mod tests {
9991015 fs:: create_dir_all ( & staging) . expect ( "create staging skill" ) ;
10001016 fs:: write ( staging. join ( "SKILL.md" ) , "new" ) . expect ( "write new skill" ) ;
10011017
1002- let backup = replace_managed_dir ( & staging, & final_dir)
1018+ let backup = replace_managed_dir ( & temp , & staging, & final_dir)
10031019 . expect ( "replace managed skill" )
10041020 . finish ( )
10051021 . expect ( "finish replacement" ) ;
@@ -1029,7 +1045,7 @@ mod tests {
10291045 fs:: create_dir_all ( & staging) . expect ( "create staging skill" ) ;
10301046 fs:: write ( staging. join ( "SKILL.md" ) , "new" ) . expect ( "write new skill" ) ;
10311047
1032- let recovery = replace_managed_dir ( & staging, & final_dir)
1048+ let recovery = replace_managed_dir ( & temp , & staging, & final_dir)
10331049 . expect ( "replace managed skill" )
10341050 . restore ( & final_dir)
10351051 . expect ( "restore previous package" ) ;
@@ -1052,7 +1068,7 @@ mod tests {
10521068 fs:: create_dir_all ( & staging) . expect ( "create staging skill" ) ;
10531069 fs:: write ( staging. join ( "SKILL.md" ) , "new" ) . expect ( "write new skill" ) ;
10541070
1055- let recovery = replace_managed_dir ( & staging, & final_dir)
1071+ let recovery = replace_managed_dir ( & temp , & staging, & final_dir)
10561072 . expect ( "replace unmanaged skill" )
10571073 . restore ( & final_dir)
10581074 . expect ( "restore unmanaged skill" ) ;
@@ -1079,7 +1095,7 @@ mod tests {
10791095 fs:: create_dir_all ( & staging) . expect ( "create staging skill" ) ;
10801096 fs:: write ( staging. join ( "SKILL.md" ) , "new" ) . expect ( "write new skill" ) ;
10811097
1082- replace_managed_dir ( & staging, & final_dir)
1098+ replace_managed_dir ( & temp , & staging, & final_dir)
10831099 . expect ( "replace manual symlink" )
10841100 . restore ( & final_dir)
10851101 . expect ( "restore manual symlink" ) ;
0 commit comments