fix: address CodeRabbit review feedback on cleanup-legacy.py

- Fix config restoration for zero-byte files by using `is not None`
  instead of truthiness checks on `config_backup` (empty bytes `b""` is
  falsy, causing silent loss of empty config.yaml files)
- Move config restore into the try/except block so mkdir/write_bytes
  errors are caught and reported as structured JSON instead of tracebacks
- Add logging.error() call on failure for observability
- Replace rglob("SKILL.md") with targeted glob() calls to avoid
  unnecessary deep traversal — only the two canonical installable
  layouts are checked
- Add docstring note explaining why find_skill_dirs() is intentionally
  stricter than the installer's recursive collectSkills()
- Add path traversal validation rejecting "..", "/", "\\" in dir names

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJ 2026-04-04 04:24:27 -07:00
parent db7b497eeb
commit 9924dc6344
1 changed files with 44 additions and 17 deletions

View File

@ -20,10 +20,13 @@ Exit codes: 0=success (including nothing to remove), 1=validation error, 2=runti
import argparse import argparse
import json import json
import logging
import shutil import shutil
import sys import sys
from pathlib import Path from pathlib import Path
logger = logging.getLogger(__name__)
def parse_args(): def parse_args():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@ -68,6 +71,13 @@ def find_skill_dirs(base_path: str) -> list:
SKILL.md files nested deeper (e.g. in tasks/, assets/, or within a SKILL.md files nested deeper (e.g. in tasks/, assets/, or within a
skill's own subdirectories) are not installable skills and are skipped. skill's own subdirectories) are not installable skills and are skipped.
NOTE: These discovery rules are intentionally stricter than the installer's
recursive collectSkills() behavior. The installer is permissive it walks
the entire tree to find all SKILL.md files for installation. Cleanup must
be conservative: we only match the two canonical installable layouts so we
never accidentally validate a SKILL.md buried in tasks/, assets/, or other
non-installable subdirectories as proof that a skill is present.
Returns: Returns:
List of skill directory names (e.g. ['bmad-agent-builder', 'bmad-builder-setup']) List of skill directory names (e.g. ['bmad-agent-builder', 'bmad-builder-setup'])
""" """
@ -75,15 +85,17 @@ def find_skill_dirs(base_path: str) -> list:
root = Path(base_path) root = Path(base_path)
if not root.exists(): if not root.exists():
return skills return skills
for skill_md in root.rglob("SKILL.md"):
rel = skill_md.parent.relative_to(root) # Direct child: {name}/SKILL.md
parts = rel.parts for skill_md in root.glob("*/SKILL.md"):
# Direct child: {name}/SKILL.md skills.append(skill_md.parent.name)
if len(parts) == 1:
skills.append(parts[0]) # Skills subfolder: skills/{name}/SKILL.md
# Skills subfolder: skills/{name}/SKILL.md skills_root = root / "skills"
elif len(parts) == 2 and parts[0] == "skills": if skills_root.exists():
skills.append(parts[1]) for skill_md in skills_root.glob("*/SKILL.md"):
skills.append(skill_md.parent.name)
return sorted(set(skills)) return sorted(set(skills))
@ -185,6 +197,17 @@ def cleanup_directories(
not_found.append(dirname) not_found.append(dirname)
continue continue
# Validate directory name to prevent path traversal
if ".." in dirname or "/" in dirname or "\\" in dirname:
error_result = {
"status": "error",
"error": f"Invalid directory name (path traversal rejected): {dirname}",
"directories_removed": removed,
"directories_failed": dirname,
}
print(json.dumps(error_result, indent=2))
sys.exit(2)
# Preserve config.yaml if present (bmad-init needs per-module configs) # Preserve config.yaml if present (bmad-init needs per-module configs)
config_path = target / "config.yaml" config_path = target / "config.yaml"
config_backup = None config_backup = None
@ -194,7 +217,7 @@ def cleanup_directories(
print(f"Preserving config.yaml in {dirname}/", file=sys.stderr) print(f"Preserving config.yaml in {dirname}/", file=sys.stderr)
file_count = count_files(target) file_count = count_files(target)
if config_backup: if config_backup is not None:
file_count -= 1 # Don't count the preserved file file_count -= 1 # Don't count the preserved file
if verbose: if verbose:
print( print(
@ -204,7 +227,18 @@ def cleanup_directories(
try: try:
shutil.rmtree(target) shutil.rmtree(target)
# Restore preserved config.yaml
if config_backup is not None:
target.mkdir(parents=True, exist_ok=True)
config_path.write_bytes(config_backup)
if verbose:
print(
f"Restored config.yaml in {dirname}/",
file=sys.stderr,
)
except OSError as e: except OSError as e:
logger.error("Failed during cleanup of %s: %s", target, e)
error_result = { error_result = {
"status": "error", "status": "error",
"error": f"Failed to remove {target}: {e}", "error": f"Failed to remove {target}: {e}",
@ -214,13 +248,6 @@ def cleanup_directories(
print(json.dumps(error_result, indent=2)) print(json.dumps(error_result, indent=2))
sys.exit(2) sys.exit(2)
# Restore preserved config.yaml
if config_backup:
target.mkdir(parents=True, exist_ok=True)
config_path.write_bytes(config_backup)
if verbose:
print(f"Restored config.yaml in {dirname}/", file=sys.stderr)
removed.append(dirname) removed.append(dirname)
total_files += file_count total_files += file_count