#!/usr/bin/env python3 """ Skill Checksums Generator - Generates checksums.json for a skill Usage: python utils/package_skill.py [output-directory] Example: python utils/package_skill.py skills/prompt-agent python utils/package_skill.py skills/prompt-agent ./dist """ import hashlib import json import re import sys from datetime import datetime, timezone from pathlib import Path, PurePosixPath, PureWindowsPath from validate_skill import validate_skill _TEST_PATH_RE = re.compile(r"(^|/)(test|tests)/", re.IGNORECASE) def normalize_release_path(path: str) -> str: """Normalize a skill SBOM path for release packaging. Paths must remain relative POSIX paths inside the skill directory. Test filtering and checksum keys use this normalized form so local packaging and the GitHub release workflow apply the same policy. """ raw_path = str(path) windows_path = PureWindowsPath(raw_path) if windows_path.is_absolute() or windows_path.drive: raise ValueError(f"unsafe SBOM path: {path}") normalized = raw_path.replace("\\", "/") while normalized.startswith("./"): normalized = normalized[2:] while "//" in normalized: normalized = normalized.replace("//", "/") pure = PurePosixPath(normalized) if ( not normalized or pure.is_absolute() or normalized == "." or ".." in pure.parts ): raise ValueError(f"unsafe SBOM path: {path}") return pure.as_posix() def is_test_release_path(path: str) -> bool: """Return True for root or nested test/test(s) release paths.""" return bool(_TEST_PATH_RE.search(path)) def calculate_sha256(file_path: Path) -> str: """Calculate SHA256 hash of a file.""" sha256_hash = hashlib.sha256() with open(file_path, "rb") as f: for byte_block in iter(lambda: f.read(4096), b""): sha256_hash.update(byte_block) return sha256_hash.hexdigest() def package_skill(skill_path: str, output_dir: str = None) -> tuple[Path | None, Path | None]: """ Generate checksums for a skill folder. Args: skill_path: Path to the skill folder output_dir: Optional output directory (defaults to current directory) Returns: Tuple of (None, checksums_file_path) or (None, None) on error """ skill_path = Path(skill_path).resolve() # Validate skill first print("Validating skill...") valid, message = validate_skill(skill_path) if not valid: print(f"[ERROR] Validation failed:\n{message}") print(" Please fix validation errors before packaging.") return None, None print(f"[OK] {message}\n") # Load skill.json skill_json_path = skill_path / "skill.json" with open(skill_json_path) as f: skill_data = json.load(f) skill_name = skill_data["name"] version = skill_data["version"] # Determine output location if output_dir: output_path = Path(output_dir).resolve() output_path.mkdir(parents=True, exist_ok=True) else: output_path = Path.cwd() checksums_filename = output_path / "checksums.json" # Collect files from SBOM files_to_checksum = [] sbom_files = skill_data.get("sbom", {}).get("files", []) for file_entry in sbom_files: try: file_rel_path = normalize_release_path(file_entry["path"]) except ValueError as exc: print(f"[ERROR] {exc}") return None, None if is_test_release_path(file_rel_path): print(f" Skipping test-only release file: {file_rel_path}") continue full_path = skill_path / file_rel_path if full_path.exists(): resolved_full_path = full_path.resolve() try: resolved_full_path.relative_to(skill_path) except ValueError: print(f"[ERROR] SBOM file escapes skill directory: {file_rel_path}") return None, None files_to_checksum.append((file_rel_path, resolved_full_path)) # Always include skill.json files_to_checksum.append(("skill.json", skill_json_path)) # Include README.md if it exists readme_path = skill_path / "README.md" if readme_path.exists(): files_to_checksum.append(("README.md", readme_path)) # Generate checksums print("Generating checksums...") checksums_data = { "skill": skill_name, "version": version, "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "repository": "prompt-security/ClawSec", "tag": f"{skill_name}-v{version}", "files": {}, } for rel_path, full_path in files_to_checksum: filename = Path(rel_path).name sha256 = calculate_sha256(full_path) size = full_path.stat().st_size checksums_data["files"][filename] = { "sha256": sha256, "size": size, "path": rel_path, "url": f"https://clawsec.prompt.security/releases/download/{skill_name}-v{version}/{filename}", } print(f" {filename}: {sha256[:16]}...") # Write checksums.json with open(checksums_filename, "w") as f: json.dump(checksums_data, f, indent=2) print(f"\n[OK] Checksums written to: {checksums_filename}") return None, checksums_filename def main(): if len(sys.argv) < 2: print("Usage: python utils/package_skill.py [output-directory]") print("\nExample:") print(" python utils/package_skill.py skills/prompt-agent") print(" python utils/package_skill.py skills/prompt-agent ./dist") sys.exit(1) skill_path = sys.argv[1] output_dir = sys.argv[2] if len(sys.argv) > 2 else None print(f"Generating checksums for: {skill_path}") if output_dir: print(f" Output directory: {output_dir}") print() _, checksums_file = package_skill(skill_path, output_dir) if checksums_file: print("\n" + "=" * 50) print("Checksums generation complete!") print(f" Checksums: {checksums_file}") print("=" * 50) sys.exit(0) else: sys.exit(1) if __name__ == "__main__": main()