verify_commit.py
python
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3
docs: docstring sprint contract→find-symbol — idiomatic run…
Sonnet 4.6
patch
138 days ago
| 1 | """``muse verify-commit <commit>...`` — verify Ed25519 signatures on commits. |
| 2 | |
| 3 | Reads the ``signature``, ``signer_public_key``, and ``signer_key_id`` fields |
| 4 | from one or more commit records, reconstructs the canonical provenance payload, |
| 5 | and verifies the Ed25519 signature. |
| 6 | |
| 7 | Output (per commit) |
| 8 | ------------------- |
| 9 | Text (default):: |
| 10 | |
| 11 | OK <short_id> signer=<agent_id> key=<key_id> |
| 12 | BAD <short_id> (no signature) |
| 13 | BAD <short_id> (invalid signature) |
| 14 | |
| 15 | JSON (``--json``):: |
| 16 | |
| 17 | {"commit_id": "...", "valid": true, "signer": "agent-abc", |
| 18 | "key_id": "...", "signed_at": "2026-04-14T17:00:00Z", |
| 19 | "key_status": "active|revoked|unknown"} |
| 20 | |
| 21 | For batch invocations each commit produces one JSON object per line. |
| 22 | |
| 23 | Flags |
| 24 | ----- |
| 25 | ``--strict`` |
| 26 | Exit non-zero if any commit is unsigned (no signature present). |
| 27 | Without ``--strict``, unsigned commits are reported as ``valid=false`` |
| 28 | but do not affect the exit code unless the signature is *invalid*. |
| 29 | |
| 30 | ``--check-key-status`` |
| 31 | Query MuseHub to check whether the signing key is still active. |
| 32 | Fails closed: returns ``"unknown"`` on timeout, network error, or when |
| 33 | no hub is configured. |
| 34 | |
| 35 | ``--json`` |
| 36 | Emit one JSON object per commit, one per line. |
| 37 | |
| 38 | Exit codes:: |
| 39 | |
| 40 | 0 — all commits valid (or unsigned without --strict) |
| 41 | 1 — at least one invalid signature, or unsigned with --strict |
| 42 | 2 — usage error (bad ref, ANSI injection) |
| 43 | |
| 44 | Examples:: |
| 45 | |
| 46 | muse verify-commit HEAD |
| 47 | muse verify-commit HEAD --json |
| 48 | muse verify-commit <commit_id> --strict |
| 49 | muse verify-commit <id1> <id2> <id3> --json |
| 50 | muse verify-commit HEAD --check-key-status |
| 51 | """ |
| 52 | |
| 53 | from __future__ import annotations |
| 54 | |
| 55 | import argparse |
| 56 | import json as _json |
| 57 | import logging |
| 58 | import re |
| 59 | import sys |
| 60 | from concurrent.futures import ThreadPoolExecutor, as_completed |
| 61 | from typing import Any |
| 62 | |
| 63 | from muse.core._types import decode_pubkey, long_id, short_id, sig_algo |
| 64 | from muse.core.errors import ExitCode |
| 65 | from muse.core.provenance import provenance_payload, verify_commit_ed25519 |
| 66 | from muse.core.refs import read_ref |
| 67 | from muse.core.repo import require_repo |
| 68 | from muse.core.store import get_head_commit_id, read_commit, read_current_branch |
| 69 | from muse.core.timing import start_timer |
| 70 | from muse.core.validation import sanitize_display |
| 71 | |
| 72 | logger = logging.getLogger(__name__) |
| 73 | |
| 74 | # Timeout for hub key-status network calls. |
| 75 | _KEY_STATUS_TIMEOUT = 5.0 |
| 76 | |
| 77 | # sha256:-prefixed commit IDs, HEAD, or branch names. |
| 78 | # Colon is required for the sha256: prefix; all other path chars are alphanumeric, _, /, ., -. |
| 79 | _SAFE_REF_RE = re.compile(r"^[a-zA-Z0-9_/:.\-]+$") |
| 80 | |
| 81 | |
| 82 | # --------------------------------------------------------------------------- |
| 83 | # Internal helpers |
| 84 | # --------------------------------------------------------------------------- |
| 85 | |
| 86 | |
| 87 | def _resolve_ref(root, treeish: str) -> str | None: |
| 88 | """Resolve HEAD or a commit ID / branch name to a full commit ID. |
| 89 | |
| 90 | Returns None when the ref cannot be resolved. |
| 91 | """ |
| 92 | if treeish.upper() == "HEAD": |
| 93 | try: |
| 94 | branch = read_current_branch(root) |
| 95 | return get_head_commit_id(root, branch) |
| 96 | except Exception: |
| 97 | return None |
| 98 | |
| 99 | # sha256:-prefixed or bare 64-char hex commit ID. |
| 100 | if re.fullmatch(r"sha256:[0-9a-f]{64}", treeish): |
| 101 | return treeish |
| 102 | if re.fullmatch(r"[0-9a-f]{64}", treeish): |
| 103 | return long_id(treeish) |
| 104 | |
| 105 | # Branch name → ref file. |
| 106 | ref_file = root / ".muse" / "refs" / "heads" / treeish |
| 107 | return read_ref(ref_file) |
| 108 | |
| 109 | |
| 110 | def _fetch_key_status(hub_url: str, key_id: str) -> str: |
| 111 | """Query MuseHub for the status of *key_id*. |
| 112 | |
| 113 | Returns ``"active"``, ``"revoked"``, or ``"unknown"`` (on any error). |
| 114 | """ |
| 115 | try: |
| 116 | import urllib.request |
| 117 | url = f"{hub_url.rstrip('/')}/api/keys/{key_id}/status" |
| 118 | req = urllib.request.Request(url, method="GET") |
| 119 | with urllib.request.urlopen(req, timeout=_KEY_STATUS_TIMEOUT) as resp: |
| 120 | body = _json.loads(resp.read().decode("utf-8")) |
| 121 | status = body.get("status", "unknown") |
| 122 | if status in ("active", "revoked"): |
| 123 | return status |
| 124 | return "unknown" |
| 125 | except Exception: |
| 126 | return "unknown" |
| 127 | |
| 128 | |
| 129 | def _verify_one( |
| 130 | root, |
| 131 | commit_id: str, |
| 132 | *, |
| 133 | check_key_status: bool = False, |
| 134 | hub_url: str | None = None, |
| 135 | key_status_cache: dict[str, str] | None = None, |
| 136 | ) -> dict[str, Any]: |
| 137 | """Verify the Ed25519 signature on a single commit. |
| 138 | |
| 139 | Args: |
| 140 | root: Repository root path. |
| 141 | commit_id: Full 64-char hex commit ID. |
| 142 | check_key_status: When True, query MuseHub for key revocation status. |
| 143 | hub_url: Hub URL for key-status queries. None → "unknown". |
| 144 | key_status_cache: Shared cache dict to deduplicate key-status lookups |
| 145 | within a batch invocation. |
| 146 | |
| 147 | Returns: |
| 148 | Dict with keys: commit_id, valid, signer, key_id, signed_at, key_status. |
| 149 | ``valid`` is False for unsigned commits, missing public keys, or failed |
| 150 | signature verification. |
| 151 | """ |
| 152 | result: dict[str, Any] = { |
| 153 | "commit_id": commit_id, |
| 154 | "valid": False, |
| 155 | "signer": "", |
| 156 | "key_id": "", |
| 157 | "signed_at": "", |
| 158 | "key_status": "unknown", |
| 159 | "error": None, |
| 160 | } |
| 161 | |
| 162 | commit = read_commit(root, commit_id) |
| 163 | if commit is None: |
| 164 | result["error"] = "commit not found" |
| 165 | return result |
| 166 | |
| 167 | result["signer"] = commit.agent_id |
| 168 | result["key_id"] = commit.signer_key_id |
| 169 | result["signed_at"] = commit.committed_at.isoformat() if commit.committed_at else "" |
| 170 | |
| 171 | # Unsigned commit — not an error unless --strict is applied by the caller. |
| 172 | if not commit.signature: |
| 173 | return result |
| 174 | |
| 175 | # format_version < 7 used HMAC — no longer verifiable with Ed25519 path. |
| 176 | if commit.format_version < 7: |
| 177 | return result |
| 178 | |
| 179 | # Dispatch on algorithm prefix — the prefix is the sole discriminator. |
| 180 | sig = commit.signature |
| 181 | pub_raw = commit.signer_public_key |
| 182 | |
| 183 | if sig_algo(sig) != "ed25519": |
| 184 | result["error"] = f"unrecognised signature algorithm {sig_algo(sig)!r} — re-sign to fix" |
| 185 | return result |
| 186 | |
| 187 | if sig_algo(pub_raw) != "ed25519": |
| 188 | result["error"] = f"unrecognised public key algorithm {sig_algo(pub_raw)!r} — re-sign to fix" |
| 189 | return result |
| 190 | |
| 191 | try: |
| 192 | _, pub_bytes = decode_pubkey(pub_raw) |
| 193 | except ValueError: |
| 194 | return result |
| 195 | |
| 196 | if not pub_bytes: |
| 197 | return result |
| 198 | |
| 199 | payload = provenance_payload( |
| 200 | commit_id, |
| 201 | author=commit.author, |
| 202 | agent_id=commit.agent_id, |
| 203 | model_id=commit.model_id, |
| 204 | toolchain_id=commit.toolchain_id, |
| 205 | prompt_hash=commit.prompt_hash, |
| 206 | committed_at=commit.committed_at.isoformat(), |
| 207 | ) |
| 208 | |
| 209 | result["valid"] = verify_commit_ed25519(payload, sig, pub_bytes) |
| 210 | |
| 211 | # Key status enrichment. |
| 212 | if check_key_status and commit.signer_key_id: |
| 213 | if hub_url is None: |
| 214 | result["key_status"] = "unknown" |
| 215 | else: |
| 216 | if key_status_cache is not None and commit.signer_key_id in key_status_cache: |
| 217 | result["key_status"] = key_status_cache[commit.signer_key_id] |
| 218 | else: |
| 219 | status = _fetch_key_status(hub_url, commit.signer_key_id) |
| 220 | result["key_status"] = status |
| 221 | if key_status_cache is not None: |
| 222 | key_status_cache[commit.signer_key_id] = status |
| 223 | |
| 224 | return result |
| 225 | |
| 226 | |
| 227 | # --------------------------------------------------------------------------- |
| 228 | # Registration |
| 229 | # --------------------------------------------------------------------------- |
| 230 | |
| 231 | |
| 232 | def register( |
| 233 | subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", |
| 234 | ) -> None: |
| 235 | """Register the ``muse verify-commit`` subcommand.""" |
| 236 | parser = subparsers.add_parser( |
| 237 | "verify-commit", |
| 238 | help="Verify Ed25519 signatures on commits.", |
| 239 | description=__doc__, |
| 240 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 241 | ) |
| 242 | parser.add_argument( |
| 243 | "commits", |
| 244 | metavar="COMMIT", |
| 245 | nargs="+", |
| 246 | help="Commit ID(s) or HEAD to verify.", |
| 247 | ) |
| 248 | parser.add_argument( |
| 249 | "--strict", |
| 250 | action="store_true", |
| 251 | help="Exit non-zero if any commit is unsigned.", |
| 252 | ) |
| 253 | parser.add_argument( |
| 254 | "--check-key-status", |
| 255 | action="store_true", |
| 256 | dest="check_key_status", |
| 257 | help="Query MuseHub to check key revocation status.", |
| 258 | ) |
| 259 | parser.add_argument( |
| 260 | "--json", |
| 261 | action="store_true", |
| 262 | dest="output_json", |
| 263 | help="Emit one JSON object per commit, one per line.", |
| 264 | ) |
| 265 | parser.set_defaults(func=run) |
| 266 | |
| 267 | |
| 268 | # --------------------------------------------------------------------------- |
| 269 | # Run |
| 270 | # --------------------------------------------------------------------------- |
| 271 | |
| 272 | |
| 273 | def run(args: argparse.Namespace) -> None: |
| 274 | """Verify Ed25519 signatures on one or more commits. |
| 275 | |
| 276 | Exit codes:: |
| 277 | |
| 278 | 0 — all commits valid (or unsigned without --strict) |
| 279 | 1 — at least one invalid or unsigned (with --strict) |
| 280 | 2 — usage error |
| 281 | """ |
| 282 | elapsed = start_timer() |
| 283 | raw_refs: list[str] = args.commits |
| 284 | strict: bool = args.strict |
| 285 | check_key_status: bool = args.check_key_status |
| 286 | output_json: bool = args.output_json |
| 287 | |
| 288 | # Validate all refs before doing any work. |
| 289 | _HEX_CHARS = frozenset("0123456789abcdef") |
| 290 | for ref in raw_refs: |
| 291 | if not _SAFE_REF_RE.match(ref): |
| 292 | print( |
| 293 | f"❌ Invalid ref: {sanitize_display(ref)}", |
| 294 | file=sys.stderr, |
| 295 | ) |
| 296 | raise SystemExit(ExitCode.USER_ERROR) |
| 297 | # Bare hex is rejected at the CLI boundary — sha256: prefix is required. |
| 298 | # HEAD and branch names contain non-hex characters and are never caught here. |
| 299 | if all(c in _HEX_CHARS for c in ref): |
| 300 | safe = sanitize_display(ref) |
| 301 | print( |
| 302 | f"❌ Bare hex IDs are not accepted — use 'sha256:{safe}' instead.\n" |
| 303 | f" Even a short prefix works: 'sha256:{safe[:12]}'", |
| 304 | file=sys.stderr, |
| 305 | ) |
| 306 | raise SystemExit(ExitCode.USER_ERROR) |
| 307 | |
| 308 | root = require_repo() |
| 309 | |
| 310 | # Resolve hub URL for key-status queries. |
| 311 | hub_url: str | None = None |
| 312 | if check_key_status: |
| 313 | try: |
| 314 | from muse.core.repo import read_hub_url |
| 315 | hub_url = read_hub_url(root) |
| 316 | except Exception: |
| 317 | hub_url = None |
| 318 | |
| 319 | # Resolve refs to commit IDs. |
| 320 | commit_ids: list[str] = [] |
| 321 | for ref in raw_refs: |
| 322 | cid = _resolve_ref(root, ref) |
| 323 | if cid is None: |
| 324 | print(f"❌ Cannot resolve ref: {sanitize_display(ref)}", file=sys.stderr) |
| 325 | raise SystemExit(ExitCode.USER_ERROR) |
| 326 | commit_ids.append(cid) |
| 327 | |
| 328 | key_status_cache: dict[str, str] = {} |
| 329 | any_failure = False |
| 330 | |
| 331 | # Verify commits (parallel for large batches). |
| 332 | results: list[dict] = [{}] * len(commit_ids) |
| 333 | |
| 334 | def _verify_indexed(idx_cid): |
| 335 | idx, cid = idx_cid |
| 336 | return idx, _verify_one( |
| 337 | root, cid, |
| 338 | check_key_status=check_key_status, |
| 339 | hub_url=hub_url, |
| 340 | key_status_cache=key_status_cache, |
| 341 | ) |
| 342 | |
| 343 | with ThreadPoolExecutor(max_workers=min(8, len(commit_ids))) as pool: |
| 344 | futures = {pool.submit(_verify_indexed, (i, cid)): i for i, cid in enumerate(commit_ids)} |
| 345 | for future in as_completed(futures): |
| 346 | idx, r = future.result() |
| 347 | results[idx] = r |
| 348 | |
| 349 | for r in results: |
| 350 | valid = r.get("valid", False) |
| 351 | error = r.get("error") |
| 352 | |
| 353 | # "commit not found" is always a hard failure. |
| 354 | if error: |
| 355 | any_failure = True |
| 356 | elif not valid: |
| 357 | is_signed = bool(r.get("key_id") or r.get("signer")) |
| 358 | # Invalid signature on a signed commit → always fail. |
| 359 | # Unsigned commit → only fail with --strict. |
| 360 | if is_signed or strict: |
| 361 | any_failure = True |
| 362 | |
| 363 | if output_json: |
| 364 | exit_code = int(ExitCode.USER_ERROR) if any_failure else 0 |
| 365 | emit = {k: v for k, v in r.items() if k != "error"} |
| 366 | emit["duration_ms"] = elapsed() |
| 367 | emit["exit_code"] = exit_code |
| 368 | print(_json.dumps(emit)) |
| 369 | else: |
| 370 | if error: |
| 371 | print(f"ERR {short_id(r['commit_id'])} ({error})") |
| 372 | else: |
| 373 | status = "OK " if valid else "BAD" |
| 374 | cid = short_id(r["commit_id"]) |
| 375 | signer = r["signer"] or "(unsigned)" |
| 376 | key = r["key_id"] or "" |
| 377 | key_part = f" key={key}" if key else "" |
| 378 | print(f"{status} {cid} signer={signer}{key_part}") |
| 379 | |
| 380 | if any_failure: |
| 381 | raise SystemExit(ExitCode.USER_ERROR) |
File History
2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3
docs: docstring sprint contract→find-symbol — idiomatic run…
Sonnet 4.6
patch
138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9
fix(cursorignore): remove git-ism (.git/worktrees)
Human
minor
⚠
141 days ago