fix: harden filesystem trust boundaries
This commit is contained in:
@@ -49,7 +49,13 @@ def cleanup_previous_sync():
|
||||
if not flat_name:
|
||||
continue
|
||||
|
||||
skill_dir = TARGET_DIR / flat_name
|
||||
sanitized = sanitize_flat_name(flat_name, "")
|
||||
if not sanitized:
|
||||
continue
|
||||
|
||||
skill_dir = TARGET_DIR / sanitized
|
||||
if not is_path_within(TARGET_DIR, skill_dir):
|
||||
continue
|
||||
if skill_dir.exists() and skill_dir.is_dir():
|
||||
shutil.rmtree(skill_dir)
|
||||
removed_count += 1
|
||||
@@ -61,6 +67,50 @@ def cleanup_previous_sync():
|
||||
|
||||
import yaml
|
||||
|
||||
SAFE_FLAT_NAME_PATTERN = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
|
||||
def is_path_within(base_dir: Path, target_path: Path) -> bool:
|
||||
"""Return True when target_path resolves inside base_dir."""
|
||||
try:
|
||||
target_path.resolve().relative_to(base_dir.resolve())
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def sanitize_flat_name(candidate: str | None, fallback: str) -> str:
|
||||
"""Accept only flat skill directory names; fall back on unsafe values."""
|
||||
if not candidate:
|
||||
return fallback
|
||||
|
||||
stripped = candidate.strip()
|
||||
parts = Path(stripped).parts
|
||||
if (
|
||||
not stripped
|
||||
or Path(stripped).is_absolute()
|
||||
or any(part in ("..", ".") for part in parts)
|
||||
or "/" in stripped
|
||||
or "\\" in stripped
|
||||
):
|
||||
return fallback
|
||||
|
||||
sanitized = SAFE_FLAT_NAME_PATTERN.sub("-", stripped).strip("-.")
|
||||
return sanitized or fallback
|
||||
|
||||
|
||||
def copy_safe_skill_files(source_dir: Path, target_dir: Path, source_root: Path):
|
||||
"""Copy regular files only when their resolved path stays inside source_root."""
|
||||
for file_item in source_dir.iterdir():
|
||||
if file_item.name == "SKILL.md" or file_item.is_symlink() or not file_item.is_file():
|
||||
continue
|
||||
|
||||
resolved = file_item.resolve()
|
||||
if not is_path_within(source_root, resolved):
|
||||
continue
|
||||
|
||||
shutil.copy2(resolved, target_dir / file_item.name)
|
||||
|
||||
def extract_skill_name(skill_md_path: Path) -> str | None:
|
||||
"""Extract the 'name' field from SKILL.md YAML frontmatter using PyYAML."""
|
||||
try:
|
||||
@@ -95,6 +145,7 @@ def find_skills_in_directory(source_dir: Path):
|
||||
Returns list of dicts: {relative_path, skill_md_path, source_dir}.
|
||||
"""
|
||||
skills_source = source_dir / "skills"
|
||||
source_root = source_dir.resolve()
|
||||
results = []
|
||||
|
||||
if not skills_source.exists():
|
||||
@@ -110,6 +161,8 @@ def find_skills_in_directory(source_dir: Path):
|
||||
if item.is_symlink():
|
||||
try:
|
||||
resolved = item.resolve()
|
||||
if not is_path_within(source_root, resolved):
|
||||
continue
|
||||
if (resolved / "SKILL.md").exists():
|
||||
skill_md = resolved / "SKILL.md"
|
||||
actual_dir = resolved
|
||||
@@ -207,10 +260,11 @@ def sync_skills_flat(source_dir: Path, target_dir: Path):
|
||||
used_names: dict[str, str] = {}
|
||||
|
||||
for entry in all_skill_entries:
|
||||
skill_name = extract_skill_name(entry["skill_md"])
|
||||
fallback_name = generate_fallback_name(entry["relative_path"])
|
||||
skill_name = sanitize_flat_name(
|
||||
extract_skill_name(entry["skill_md"]), fallback_name)
|
||||
|
||||
if not skill_name:
|
||||
skill_name = generate_fallback_name(entry["relative_path"])
|
||||
if skill_name == fallback_name:
|
||||
print(
|
||||
f" ⚠️ No frontmatter name for {entry['relative_path']}, using fallback: {skill_name}")
|
||||
|
||||
@@ -241,9 +295,7 @@ def sync_skills_flat(source_dir: Path, target_dir: Path):
|
||||
shutil.copy2(entry["skill_md"], target_skill_dir / "SKILL.md")
|
||||
|
||||
# Copy other files from the skill directory
|
||||
for file_item in entry["source_dir"].iterdir():
|
||||
if file_item.name != "SKILL.md" and file_item.is_file():
|
||||
shutil.copy2(file_item, target_skill_dir / file_item.name)
|
||||
copy_safe_skill_files(entry["source_dir"], target_skill_dir, source_dir)
|
||||
|
||||
skill_metadata.append({
|
||||
"flat_name": skill_name,
|
||||
@@ -265,9 +317,8 @@ def sync_skills_flat(source_dir: Path, target_dir: Path):
|
||||
if plugin_entries:
|
||||
print(f"\n 📦 Found {len(plugin_entries)} additional plugin skills")
|
||||
for entry in plugin_entries:
|
||||
skill_name = extract_skill_name(entry["skill_md"])
|
||||
if not skill_name:
|
||||
skill_name = entry["source_dir"].name
|
||||
skill_name = sanitize_flat_name(
|
||||
extract_skill_name(entry["skill_md"]), entry["source_dir"].name)
|
||||
|
||||
if skill_name in synced_names:
|
||||
skill_name = f"{skill_name}-plugin"
|
||||
@@ -288,9 +339,7 @@ def sync_skills_flat(source_dir: Path, target_dir: Path):
|
||||
|
||||
shutil.copy2(entry["skill_md"], target_skill_dir / "SKILL.md")
|
||||
|
||||
for file_item in entry["source_dir"].iterdir():
|
||||
if file_item.name != "SKILL.md" and file_item.is_file():
|
||||
shutil.copy2(file_item, target_skill_dir / file_item.name)
|
||||
copy_safe_skill_files(entry["source_dir"], target_skill_dir, source_dir)
|
||||
|
||||
skill_metadata.append({
|
||||
"flat_name": skill_name,
|
||||
@@ -309,9 +358,8 @@ def sync_skills_flat(source_dir: Path, target_dir: Path):
|
||||
print(
|
||||
f"\n <20> Found {len(github_skill_entries)} skills in .github/skills/ not linked from skills/")
|
||||
for entry in github_skill_entries:
|
||||
skill_name = extract_skill_name(entry["skill_md"])
|
||||
if not skill_name:
|
||||
skill_name = entry["source_dir"].name
|
||||
skill_name = sanitize_flat_name(
|
||||
extract_skill_name(entry["skill_md"]), entry["source_dir"].name)
|
||||
|
||||
if skill_name in synced_names:
|
||||
skill_name = f"{skill_name}-github"
|
||||
@@ -331,9 +379,7 @@ def sync_skills_flat(source_dir: Path, target_dir: Path):
|
||||
|
||||
shutil.copy2(entry["skill_md"], target_skill_dir / "SKILL.md")
|
||||
|
||||
for file_item in entry["source_dir"].iterdir():
|
||||
if file_item.name != "SKILL.md" and file_item.is_file():
|
||||
shutil.copy2(file_item, target_skill_dir / file_item.name)
|
||||
copy_safe_skill_files(entry["source_dir"], target_skill_dir, source_dir)
|
||||
|
||||
skill_metadata.append({
|
||||
"flat_name": skill_name,
|
||||
|
||||
Reference in New Issue
Block a user