2727logger = logging .getLogger (__name__ )
2828
2929
30+ def _semver_key (version : str ) -> tuple :
31+ """Parse version string to comparable tuple for correct semver sorting.
32+
33+ Handles formats: '0.1.0', 'v1.2.3', '1.0'.
34+ Returns (0, 0, 0) on parse failure for safe fallback.
35+ """
36+ cleaned = version .strip ().lstrip ("v" )
37+ try :
38+ return tuple (int (x ) for x in cleaned .split ("." ))
39+ except (ValueError , AttributeError ):
40+ return (0 , 0 , 0 )
41+
42+
3043class LocalBackend :
3144 """HubBackend using local filesystem as storage.
3245
@@ -54,6 +67,26 @@ def base_dir(self) -> Path:
5467 """Return the base directory for local storage."""
5568 return self ._base_dir
5669
70+ def _safe_repo_path (self , repo_id : str ) -> Path :
71+ """Validate repo_id and resolve to safe path under base_dir.
72+
73+ Prevents path traversal attacks via '..' segments or absolute paths.
74+ """
75+ from pathlib import PurePosixPath
76+
77+ parts = PurePosixPath (repo_id ).parts
78+ if not parts :
79+ raise ValueError (f"Empty repo_id" )
80+ if any (p == ".." for p in parts ):
81+ raise ValueError (f"Path traversal detected in repo_id: '{ repo_id } '" )
82+ if PurePosixPath (repo_id ).is_absolute ():
83+ raise ValueError (f"Absolute path not allowed in repo_id: '{ repo_id } '" )
84+
85+ resolved = (self ._base_dir / repo_id ).resolve ()
86+ if not str (resolved ).startswith (str (self ._base_dir .resolve ())):
87+ raise ValueError (f"repo_id escapes base directory: '{ repo_id } '" )
88+ return resolved
89+
5790 async def authenticate (self ) -> UserInfo :
5891 """Return local user info (no actual authentication needed)."""
5992 username = os .environ .get ("USER" , os .environ .get ("USERNAME" , "local" ))
@@ -65,13 +98,9 @@ async def push_skill(
6598 repo_id : str ,
6699 visibility : Visibility = Visibility .PRIVATE ,
67100 ) -> PushResult :
68- """Write skill bundle to local filesystem.
69-
70- Creates a versioned directory structure:
71- base_dir/repo_id/version/files...
72- """
101+ """Write skill bundle to local filesystem."""
73102 version = bundle .manifest .version or "0.1.0"
74- repo_path = self ._base_dir / repo_id / version
103+ repo_path = self ._safe_repo_path ( repo_id ) / version
75104 repo_path .mkdir (parents = True , exist_ok = True )
76105
77106 # Write files
@@ -95,20 +124,18 @@ async def pull_skill(
95124 version : Optional [str ] = None ,
96125 ) -> SkillBundle :
97126 """Read skill bundle from local filesystem."""
98- repo_base = self ._base_dir / repo_id
127+ repo_base = self ._safe_repo_path ( repo_id )
99128
100129 if not repo_base .exists ():
101130 raise FileNotFoundError (f"Skill repository not found: { repo_id } " )
102131
103132 # Resolve version
104133 if version is None :
105- # Get latest version (sorted lexicographically)
106- versions = sorted (
107- [d .name for d in repo_base .iterdir () if d .is_dir ()],
108- reverse = True ,
109- )
110- if not versions :
134+ # Get latest version (semantic version sort, not lexicographic)
135+ version_dirs = [d .name for d in repo_base .iterdir () if d .is_dir ()]
136+ if not version_dirs :
111137 raise FileNotFoundError (f"No versions found for: { repo_id } " )
138+ versions = sorted (version_dirs , key = _semver_key , reverse = True )
112139 version = versions [0 ]
113140
114141 version_path = repo_base / version
@@ -221,7 +248,7 @@ async def delete_skill(self, repo_id: str) -> None:
221248 """Delete a skill from local storage."""
222249 import shutil
223250
224- repo_base = self ._base_dir / repo_id
251+ repo_base = self ._safe_repo_path ( repo_id )
225252
226253 if not repo_base .exists ():
227254 raise FileNotFoundError (f"Skill repository not found: { repo_id } " )
0 commit comments