From 370416b87b85705c11ada28bc923bd5cc04480fd Mon Sep 17 00:00:00 2001 From: Kyle Merritt Date: Mon, 5 Oct 2026 12:39:10 -0400 Subject: [PATCH] Align skill package checks and include notices in archives --- README.md | 2 +- tests/test_packages.py | 26 ++++++++++++++++++++++++++ tools/packages.py | 21 +++++++++++++++------ 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d02f2dc..63277f2 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ python -m tools.packages check examples/starter-planner python -m tools.packages pack examples/starter-planner dist/starter-planner.zip ``` -Replace `examples/starter-planner` in the last two commands with your package directory. The `pack` command writes a reproducible ZIP and prints its SHA-256. The check returns the owner's package identity digest. Both use only Python 3's standard library and this repository's source. Include `LICENSE` and `NOTICE.md` when redistributing the package. +Replace `examples/starter-planner` in the last two commands with your package directory. The `pack` command writes a reproducible ZIP containing the package, `LICENSE`, and `NOTICE.md`, then prints its SHA-256. The check returns the owner's package identity digest. Both use only Python 3's standard library and this repository's source. Preserve the license and notice when redistributing the package or fork. To import a user fork, place its closed package directory under the owner's configured trusted root, use Settings → Skills to import the relative `SKILL.md` path under a new unique ID, and assign it to the matching future role step. Saving a new assignment takes effect after owner restart; existing sessions retain their captured bytes. The Settings catalog displays source, version, digest, role, step, and pending versus effective assignment. The owner's admission remains authoritative even when these local checks pass. diff --git a/tests/test_packages.py b/tests/test_packages.py index ee99706..ab16c27 100644 --- a/tests/test_packages.py +++ b/tests/test_packages.py @@ -3,6 +3,7 @@ import hashlib import shutil import tempfile import unittest +import zipfile from pathlib import Path from tools.packages import PackageError, check_package, package_archive @@ -70,6 +71,31 @@ class PackageTests(unittest.TestCase): with self.assertRaises(PackageError): check_package(package) + def test_rejects_owner_incompatible_frontmatter_values(self): + for old, new in ( + ("metadata:\n", "disable-model-invocation: yes\nmetadata:\n"), + ("description: Check a Planner's issue scope and evidence before drafting a Plan.", "description: "), + ): + with self.subTest(new=new), tempfile.TemporaryDirectory() as directory: + package = Path(directory) / "planner-scope" + shutil.copytree(ROOT / "packages/planner-scope", package) + skill = package / "SKILL.md" + skill.write_text(skill.read_text().replace(old, new)) + manifest = package / "relayview-skill.json" + data = json.loads(manifest.read_text()) + next(item for item in data["files"] if item["path"] == "SKILL.md")["sha256"] = hashlib.sha256(skill.read_bytes()).hexdigest() + manifest.write_text(json.dumps(data)) + with self.assertRaises(PackageError): + check_package(package) + + def test_archive_carries_license_and_notice(self): + with tempfile.TemporaryDirectory() as directory: + archive_path = Path(directory) / "planner-scope.zip" + package_archive(ROOT / "packages/planner-scope", archive_path) + with zipfile.ZipFile(archive_path) as archive: + self.assertEqual(archive.read("LICENSE"), (ROOT / "LICENSE").read_bytes()) + self.assertEqual(archive.read("NOTICE.md"), (ROOT / "NOTICE.md").read_bytes()) + if __name__ == "__main__": unittest.main() diff --git a/tools/packages.py b/tools/packages.py index bca1d46..fc42c7c 100644 --- a/tools/packages.py +++ b/tools/packages.py @@ -117,9 +117,17 @@ def check_package(path): _fail(f"{path}: unsupported skill metadata") if len(top_keys) != len(set(top_keys)) or set(top_keys) - {"name", "description", "metadata", "disable-model-invocation"} or len(nested_keys) != len(set(nested_keys)) or set(nested_keys) != {"relayview-display-name", "relayview-version"}: _fail(f"{path}: unsupported skill metadata") + values = dict(line.split(": ", 1) for line in frontmatter.splitlines() if not line.startswith(" ") and ": " in line) + if "disable-model-invocation" in values and values["disable-model-invocation"] not in ("true", "false"): + _fail(f"{path}: disable-model-invocation must be a boolean") expected_name = path.name if not re.search(rf"(?m)^name: {re.escape(expected_name)}$", frontmatter) or not re.search(r"(?m)^description: .+$", frontmatter) or not re.search(r"(?m)^ relayview-display-name: .+$", frontmatter) or not re.search(r"(?m)^ relayview-version: [0-9]+\.[0-9]+\.[0-9]+$", frontmatter): _fail(f"{path}: missing or incompatible skill metadata") + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", expected_name) or len(expected_name) > 64 or len(values["description"]) > 1024: + _fail(f"{path}: skill name or description exceeds owner bounds") + display_name = next(line.split(": ", 1)[1] for line in frontmatter.splitlines() if line.startswith(" relayview-display-name: ")) + if len(display_name) > 128: + _fail(f"{path}: display name exceeds owner bounds") digest = hashlib.sha256(b"relayview-instruction-package-v1\0" + _frame(raw_manifest)) for name in sorted(declared): digest.update(_frame(name.encode("utf-8"))) @@ -132,12 +140,13 @@ def package_archive(path, destination): check_package(path) destination.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_STORED) as archive: - for file in sorted(path.rglob("*")): - if file.is_file(): - name = f"{path.name}/{file.relative_to(path).as_posix()}" - info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) - info.external_attr = 0o100644 << 16 - archive.writestr(info, file.read_bytes()) + root = Path(__file__).resolve().parents[1] + files = [(name, root / name) for name in ("LICENSE", "NOTICE.md")] + files += [(f"{path.name}/{file.relative_to(path).as_posix()}", file) for file in sorted(path.rglob("*")) if file.is_file()] + for name, file in files: + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.external_attr = 0o100644 << 16 + archive.writestr(info, file.read_bytes()) return hashlib.sha256(destination.read_bytes()).hexdigest()