mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 21:48:03 +03:00
1e48a955cc
* 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>
201 lines
6.1 KiB
Python
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()
|