Files
clawsec/utils/package_skill.py
T
David Abutbul 1e48a955cc fix(release): exclude tests from skill payloads (#230)
* fix(release): exclude tests from skill payloads

* fix(release): normalize test path filtering

* fix(release): prefer GitHub artifacts for non-OpenClaw installs

* fix(release): keep legacy ClawHub publishing

* fix(release): address skill packaging review feedback

* chore(skills): bump release versions

* feat(skills): surface recommended platforms

* docs(skills): add signed release verification

* fix(skills): normalize PR version bumps

---------

Co-authored-by: David Abutbul <David.a@prompt.security>
2026-05-14 14:38:58 +03:00

201 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""
Skill Checksums Generator - Generates checksums.json for a skill
Usage:
python utils/package_skill.py <path/to/skill-folder> [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 <path/to/skill-folder> [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()