* feat(security): add plugin content integrity verification (SHA256) SDK-side follow-up to molecule-core PR #1019 (pinned-ref supply-chain fix). Changes: - verify_plugin_sha256(plugin_dir, expected_sha) — content-addressed manifest hash over sorted (relpath, SHA256(content)) pairs; plugin.yaml excluded from its own hash to avoid circular dependency - _walk_files(root) / _sha256_file(path) — internal helpers - install_plugin() calls verify_sha256 after atomic rename; on mismatch deletes plugin dir and raises ValueError before setup.sh runs - PLUGIN_YAML_SCHEMA gains optional sha256 field (64-char lowercase hex) - validate_manifest() validates sha256 format when present Tests (12 new): - sha256_file correctness, walk_files ordering, verify_* (match/mismatch/invalid) - install_plugin sha256 verified: setup.sh runs - install_plugin sha256 mismatch: raises ValueError, setup.sh NOT run - install_plugin no sha256: backward-compat, skips verification - validate_manifest sha256: valid/invalid/non-hex/absent Pre-existing: 4 async tests in test_sdk.py fail without pytest-asyncio (not related to this change). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(tests): add pytest-asyncio markers to async adaptor tests The 4 tests using async def were failing because pytest-asyncio was not installed and pytest.ini set asyncio_mode=auto (which requires it). Add @pytest.mark.asyncio to each async test and add pytest-asyncio as a test optional dependency so CI gets the right extras when installing. Fixes: 4 FAILED tests in test_sdk.py * feat(cli): add verify-sha256 command to molecule_agent Add `python -m molecule_agent verify-sha256 <plugin-dir>` CLI that computes the content-integrity SHA256 for a plugin directory (the same manifest hash that verify_plugin_sha256() uses internally). Plugin authors can run this to generate the hash to put in plugin.yaml's sha256 field. Also: - Re-export verify_plugin_sha256 and compute_plugin_sha256 from the molecule_agent package root so `from molecule_agent import compute_plugin_sha256` works. - Update CLAUDE.md to document the CLI and content integrity flow. - Write pr-description-draft.md as a backup for when GH_TOKEN recovers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Molecule AI SDK-Dev <sdk-dev@agents.moleculesai.app> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
88 lines
2.7 KiB
Python
88 lines
2.7 KiB
Python
"""CLI for molecule_agent — python -m molecule_agent [command]
|
|
|
|
Commands:
|
|
verify-sha256 <plugin-dir> Compute the content-integrity SHA256 for a
|
|
plugin directory. The hash excludes
|
|
plugin.yaml (self-referential). Output the
|
|
hash so you can paste it into plugin.yaml
|
|
under the sha256 field.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def _walk_files(root: Path) -> list[str]:
|
|
"""Yield relative file paths under ``root`` (directories excluded)."""
|
|
rel: list[str] = []
|
|
for p in root.rglob("*"):
|
|
if p.is_file():
|
|
rel.append(p.relative_to(root).as_posix())
|
|
return rel
|
|
|
|
|
|
def _sha256_file(path: Path) -> str:
|
|
h = hashlib.sha256()
|
|
with path.open("rb") as f:
|
|
for chunk in iter(lambda: f.read(65536), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def compute_plugin_sha256(plugin_dir: Path) -> str:
|
|
"""Compute the content-integrity SHA256 for a plugin directory.
|
|
|
|
The manifest is the SHA256 of the canonical JSON of
|
|
``sorted((relative_path, SHA256(file_content)) for every file
|
|
EXCEPT plugin.yaml``.
|
|
|
|
``plugin.yaml`` is excluded from its own hash because it contains the
|
|
hash — otherwise the bootstrap is circular and convergence is impossible.
|
|
"""
|
|
file_hashes: list[tuple[str, str]] = []
|
|
for relpath in sorted(_walk_files(plugin_dir)):
|
|
if relpath == "plugin.yaml":
|
|
continue
|
|
file_hashes.append((relpath, _sha256_file(plugin_dir / relpath)))
|
|
manifest_bytes = json.dumps(file_hashes, sort_keys=True).encode()
|
|
return hashlib.sha256(manifest_bytes).hexdigest()
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
prog="molecule_agent",
|
|
description="Molecule AI remote-agent CLI utilities.",
|
|
)
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
vs = sub.add_parser(
|
|
"verify-sha256",
|
|
help="Compute the content-integrity SHA256 for a plugin directory.",
|
|
)
|
|
vs.add_argument(
|
|
"plugin_dir",
|
|
type=Path,
|
|
help="Path to the plugin directory (must contain plugin.yaml)",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.command == "verify-sha256":
|
|
plugin_dir = args.plugin_dir.resolve()
|
|
if not plugin_dir.is_dir():
|
|
sys.exit(f"error: {plugin_dir} is not a directory")
|
|
try:
|
|
h = compute_plugin_sha256(plugin_dir)
|
|
print(f"Computed SHA256: {h}")
|
|
except Exception as exc:
|
|
sys.exit(f"error: {exc}")
|
|
else:
|
|
parser.print_help()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |