fix: harden filesystem trust boundaries

This commit is contained in:
sck_0
2026-03-15 08:39:22 +01:00
parent 226f10c2a6
commit fe07e07215
20 changed files with 630 additions and 124 deletions

View File

@@ -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,