verify_commit.py
python
sha256:84df9126d09aeec0b8f1b908f0b06c10913feec28f3514b382efb1ba6d619385
refactor: rename StructuredMergePlugin to AddressedMergePlu…
Sonnet 4.6
minor
⚠ breaking
23 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 <commit_id> signer=<agent_id> key=<key_id> |
| 12 | BAD <commit_id> (no signature) |
| 13 | BAD <commit_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 | import argparse |
| 54 | import json as _json |
| 55 | import logging |
| 56 | import pathlib |
| 57 | import re |
| 58 | import sys |
| 59 | from concurrent.futures import ThreadPoolExecutor, as_completed |
| 60 | from typing import TypedDict |
| 61 | |
| 62 | from muse.core.types import DEFAULT_SIGN_ALGO, decode_pubkey, long_id, short_id, sig_algo |
| 63 | from muse.core.paths import ref_path as _ref_path |
| 64 | from muse.core.envelope import make_envelope |
| 65 | from muse.core.errors import ExitCode |
| 66 | from muse.core.provenance import provenance_payload, verify_commit_ed25519 |
| 67 | from muse.core.refs import read_ref |
| 68 | from muse.core.repo import require_repo |
| 69 | from muse.core.refs import ( |
| 70 | get_head_commit_id, |
| 71 | read_current_branch, |
| 72 | ) |
| 73 | from muse.core.commits import read_commit |
| 74 | from muse.core.timing import start_timer |
| 75 | from muse.core.validation import sanitize_display |
| 76 | |
| 77 | class _VerifyResult(TypedDict, total=False): |
| 78 | commit_id: str |
| 79 | valid: bool |
| 80 | signer: str |
| 81 | key_id: str |
| 82 | signed_at: str |
| 83 | key_status: str |
| 84 | |
| 85 | logger = logging.getLogger(__name__) |
| 86 | |
| 87 | # Timeout for hub key-status network calls. |
| 88 | _KEY_STATUS_TIMEOUT = 5.0 |
| 89 | |
| 90 | # sha256:-prefixed commit IDs, HEAD, or branch names. |
| 91 | # Colon is required for the sha256: prefix; all other path chars are alphanumeric, _, /, ., -. |
| 92 | _SAFE_REF_RE = re.compile(r"^[a-zA-Z0-9_/:.\-]+$") |
| 93 | |
| 94 | # --------------------------------------------------------------------------- |
| 95 | # Internal helpers |
| 96 | # --------------------------------------------------------------------------- |
| 97 | |
| 98 | def _resolve_ref(root: pathlib.Path, treeish: str) -> str | None: |
| 99 | """Resolve HEAD or a commit ID / branch name to a full commit ID. |
| 100 | |
| 101 | Returns None when the ref cannot be resolved. |
| 102 | """ |
| 103 | if treeish.upper() == "HEAD": |
| 104 | try: |
| 105 | branch = read_current_branch(root) |
| 106 | return get_head_commit_id(root, branch) |
| 107 | except Exception: |
| 108 | return None |
| 109 | |
| 110 | # sha256:-prefixed or bare 64-char hex commit ID. |
| 111 | if re.fullmatch(r"sha256:[0-9a-f]{64}", treeish): |
| 112 | return treeish |
| 113 | if re.fullmatch(r"[0-9a-f]{64}", treeish): |
| 114 | return long_id(treeish) |
| 115 | |
| 116 | # Branch name → ref file. |
| 117 | ref_file = _ref_path(root, treeish) |
| 118 | return read_ref(ref_file) |
| 119 | |
| 120 | def _fetch_key_status(hub_url: str, key_id: str) -> str: |
| 121 | """Query MuseHub for the status of *key_id*. |
| 122 | |
| 123 | Returns ``"active"``, ``"revoked"``, or ``"unknown"`` (on any error). |
| 124 | """ |
| 125 | try: |
| 126 | import urllib.request |
| 127 | url = f"{hub_url.rstrip('/')}/api/keys/{key_id}/status" |
| 128 | req = urllib.request.Request(url, method="GET") |
| 129 | with urllib.request.urlopen(req, timeout=_KEY_STATUS_TIMEOUT) as resp: |
| 130 | body = _json.loads(resp.read().decode("utf-8")) |
| 131 | status = body.get("status", "unknown") |
| 132 | if status in ("active", "revoked"): |
| 133 | return status |
| 134 | return "unknown" |
| 135 | except Exception: |
| 136 | return "unknown" |
| 137 | |
| 138 | def _verify_one( |
| 139 | root: pathlib.Path, |
| 140 | commit_id: str, |
| 141 | *, |
| 142 | check_key_status: bool = False, |
| 143 | hub_url: str | None = None, |
| 144 | key_status_cache: dict[str, str] | None = None, |
| 145 | ) -> _VerifyResult: |
| 146 | """Verify the Ed25519 signature on a single commit. |
| 147 | |
| 148 | Args: |
| 149 | root: Repository root path. |
| 150 | commit_id: Full 64-char hex commit ID. |
| 151 | check_key_status: When True, query MuseHub for key revocation status. |
| 152 | hub_url: Hub URL for key-status queries. None → "unknown". |
| 153 | key_status_cache: Shared cache dict to deduplicate key-status lookups |
| 154 | within a batch invocation. |
| 155 | |
| 156 | Returns: |
| 157 | Dict with keys: commit_id, valid, signer, key_id, signed_at, key_status. |
| 158 | ``valid`` is False for unsigned commits, missing public keys, or failed |
| 159 | signature verification. |
| 160 | """ |
| 161 | result: _VerifyResult = { |
| 162 | "commit_id": commit_id, |
| 163 | "valid": False, |
| 164 | "signer": "", |
| 165 | "key_id": "", |
| 166 | "signed_at": "", |
| 167 | "key_status": "unknown", |
| 168 | "error": None, |
| 169 | } |
| 170 | |
| 171 | commit = read_commit(root, commit_id) |
| 172 | if commit is None: |
| 173 | result["error"] = "commit not found" |
| 174 | return result |
| 175 | |
| 176 | result["signer"] = commit.agent_id |
| 177 | result["key_id"] = commit.signer_key_id |
| 178 | result["signed_at"] = commit.committed_at.isoformat() if commit.committed_at else "" |
| 179 | |
| 180 | # Unsigned commit — not an error unless --strict is applied by the caller. |
| 181 | if not commit.signature: |
| 182 | return result |
| 183 | |
| 184 | # Dispatch on algorithm prefix — the prefix is the sole discriminator. |
| 185 | sig = commit.signature |
| 186 | pub_raw = commit.signer_public_key |
| 187 | |
| 188 | if sig_algo(sig) != DEFAULT_SIGN_ALGO: |
| 189 | result["error"] = f"unrecognised signature algorithm {sig_algo(sig)!r} — re-sign to fix" |
| 190 | return result |
| 191 | |
| 192 | if sig_algo(pub_raw) != DEFAULT_SIGN_ALGO: |
| 193 | result["error"] = f"unrecognised public key algorithm {sig_algo(pub_raw)!r} — re-sign to fix" |
| 194 | return result |
| 195 | |
| 196 | try: |
| 197 | _, pub_bytes = decode_pubkey(pub_raw) |
| 198 | except ValueError: |
| 199 | return result |
| 200 | |
| 201 | if not pub_bytes: |
| 202 | return result |
| 203 | |
| 204 | payload = provenance_payload( |
| 205 | commit_id, |
| 206 | author=commit.author, |
| 207 | agent_id=commit.agent_id, |
| 208 | model_id=commit.model_id, |
| 209 | toolchain_id=commit.toolchain_id, |
| 210 | prompt_hash=commit.prompt_hash, |
| 211 | committed_at=commit.committed_at.isoformat(), |
| 212 | ) |
| 213 | |
| 214 | result["valid"] = verify_commit_ed25519(payload, sig, pub_bytes) |
| 215 | |
| 216 | # Key status enrichment. |
| 217 | if check_key_status and commit.signer_key_id: |
| 218 | if hub_url is None: |
| 219 | result["key_status"] = "unknown" |
| 220 | else: |
| 221 | if key_status_cache is not None and commit.signer_key_id in key_status_cache: |
| 222 | result["key_status"] = key_status_cache[commit.signer_key_id] |
| 223 | else: |
| 224 | status = _fetch_key_status(hub_url, commit.signer_key_id) |
| 225 | result["key_status"] = status |
| 226 | if key_status_cache is not None: |
| 227 | key_status_cache[commit.signer_key_id] = status |
| 228 | |
| 229 | return result |
| 230 | |
| 231 | # --------------------------------------------------------------------------- |
| 232 | # Registration |
| 233 | # --------------------------------------------------------------------------- |
| 234 | |
| 235 | def register( |
| 236 | subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", |
| 237 | ) -> None: |
| 238 | """Register the ``muse verify-commit`` subcommand.""" |
| 239 | parser = subparsers.add_parser( |
| 240 | "verify-commit", |
| 241 | help="Verify Ed25519 signatures on commits.", |
| 242 | description=__doc__, |
| 243 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 244 | ) |
| 245 | parser.add_argument( |
| 246 | "commits", |
| 247 | metavar="COMMIT", |
| 248 | nargs="+", |
| 249 | help="Commit ID(s) or HEAD to verify.", |
| 250 | ) |
| 251 | parser.add_argument( |
| 252 | "--strict", |
| 253 | action="store_true", |
| 254 | help="Exit non-zero if any commit is unsigned.", |
| 255 | ) |
| 256 | parser.add_argument( |
| 257 | "--check-key-status", |
| 258 | action="store_true", |
| 259 | dest="check_key_status", |
| 260 | help="Query MuseHub to check key revocation status.", |
| 261 | ) |
| 262 | parser.add_argument( |
| 263 | "--json", "-j", |
| 264 | action="store_true", |
| 265 | dest="json_out", |
| 266 | help="Emit one JSON object per commit, one per line.", |
| 267 | ) |
| 268 | parser.set_defaults(func=run, json_out=False) |
| 269 | |
| 270 | # --------------------------------------------------------------------------- |
| 271 | # Run |
| 272 | # --------------------------------------------------------------------------- |
| 273 | |
| 274 | def run(args: argparse.Namespace) -> None: |
| 275 | """Verify Ed25519 signatures on one or more commits. |
| 276 | |
| 277 | Resolves each ref to a full commit ID, then verifies the embedded Ed25519 |
| 278 | signature against the signer public key stored in the commit record. |
| 279 | Per-commit JSON objects are emitted one per line; ``duration_ms`` and |
| 280 | ``exit_code`` are included in each line so streaming consumers can act |
| 281 | without buffering the full output. |
| 282 | |
| 283 | Agent quickstart:: |
| 284 | |
| 285 | muse verify-commit HEAD --json |
| 286 | muse verify-commit sha256:<id> --json |
| 287 | muse verify-commit sha256:<id1> sha256:<id2> --strict --json |
| 288 | muse verify-commit HEAD --check-key-status --json |
| 289 | |
| 290 | JSON fields (one object per commit, one per line):: |
| 291 | |
| 292 | commit_id Full sha256-prefixed commit ID. |
| 293 | valid true if the signature verified successfully. |
| 294 | signer agent_id string from the commit record. |
| 295 | key_id Signer key fingerprint (first 16 hex chars of SHA-256(pubkey)). |
| 296 | signed_at ISO-8601 timestamp from the commit record. |
| 297 | key_status "active", "revoked", or "unknown" (requires --check-key-status). |
| 298 | muse_version Muse release that produced this output. |
| 299 | schema Envelope schema version (int). |
| 300 | exit_code 0 if all commits valid so far, 1 if any failure. |
| 301 | duration_ms Wall-clock milliseconds elapsed so far. |
| 302 | timestamp ISO-8601 UTC timestamp of command completion. |
| 303 | warnings List of non-fatal advisory messages. |
| 304 | |
| 305 | Exit codes:: |
| 306 | |
| 307 | 0 All commits valid (or unsigned without --strict). |
| 308 | 1 At least one invalid signature, or unsigned with --strict. |
| 309 | 2 Usage error (bad ref, ANSI injection attempt). |
| 310 | """ |
| 311 | elapsed = start_timer() |
| 312 | raw_refs: list[str] = args.commits |
| 313 | strict: bool = args.strict |
| 314 | check_key_status: bool = args.check_key_status |
| 315 | json_out: bool = args.json_out |
| 316 | |
| 317 | # Validate all refs before doing any work. |
| 318 | _HEX_CHARS = frozenset("0123456789abcdef") |
| 319 | for ref in raw_refs: |
| 320 | if not _SAFE_REF_RE.match(ref): |
| 321 | print( |
| 322 | f"❌ Invalid ref: {sanitize_display(ref)}", |
| 323 | file=sys.stderr, |
| 324 | ) |
| 325 | raise SystemExit(ExitCode.USER_ERROR) |
| 326 | # Bare hex is rejected at the CLI boundary — sha256: prefix is required. |
| 327 | # HEAD and branch names contain non-hex characters and are never caught here. |
| 328 | if all(c in _HEX_CHARS for c in ref): |
| 329 | safe = sanitize_display(ref) |
| 330 | print( |
| 331 | f"❌ Bare hex IDs are not accepted — use 'sha256:{safe}' instead.\n" |
| 332 | f" Even a short prefix works: 'sha256:{safe[:12]}'", |
| 333 | file=sys.stderr, |
| 334 | ) |
| 335 | raise SystemExit(ExitCode.USER_ERROR) |
| 336 | |
| 337 | root = require_repo() |
| 338 | |
| 339 | # Resolve hub URL for key-status queries. |
| 340 | hub_url: str | None = None |
| 341 | if check_key_status: |
| 342 | try: |
| 343 | from muse.core.repo import read_hub_url |
| 344 | hub_url = read_hub_url(root) |
| 345 | except Exception: |
| 346 | hub_url = None |
| 347 | |
| 348 | # Resolve refs to commit IDs. |
| 349 | commit_ids: list[str] = [] |
| 350 | for ref in raw_refs: |
| 351 | cid = _resolve_ref(root, ref) |
| 352 | if cid is None: |
| 353 | print(f"❌ Cannot resolve ref: {sanitize_display(ref)}", file=sys.stderr) |
| 354 | raise SystemExit(ExitCode.USER_ERROR) |
| 355 | commit_ids.append(cid) |
| 356 | |
| 357 | key_status_cache: dict[str, str] = {} |
| 358 | any_failure = False |
| 359 | |
| 360 | # Verify commits (parallel for large batches). |
| 361 | results: list[dict] = [{}] * len(commit_ids) |
| 362 | |
| 363 | def _verify_indexed(idx_cid: tuple[int, str]) -> tuple[int, _VerifyResult]: |
| 364 | idx, cid = idx_cid |
| 365 | return idx, _verify_one( |
| 366 | root, cid, |
| 367 | check_key_status=check_key_status, |
| 368 | hub_url=hub_url, |
| 369 | key_status_cache=key_status_cache, |
| 370 | ) |
| 371 | |
| 372 | with ThreadPoolExecutor(max_workers=min(8, len(commit_ids))) as pool: |
| 373 | futures = {pool.submit(_verify_indexed, (i, cid)): i for i, cid in enumerate(commit_ids)} |
| 374 | for future in as_completed(futures): |
| 375 | idx, r = future.result() |
| 376 | results[idx] = r |
| 377 | |
| 378 | for r in results: |
| 379 | valid = r.get("valid", False) |
| 380 | error = r.get("error") |
| 381 | |
| 382 | # "commit not found" is always a hard failure. |
| 383 | if error: |
| 384 | any_failure = True |
| 385 | elif not valid: |
| 386 | is_signed = bool(r.get("key_id") or r.get("signer")) |
| 387 | # Invalid signature on a signed commit → always fail. |
| 388 | # Unsigned commit → only fail with --strict. |
| 389 | if is_signed or strict: |
| 390 | any_failure = True |
| 391 | |
| 392 | if json_out: |
| 393 | exit_code = int(ExitCode.USER_ERROR) if any_failure else 0 |
| 394 | emit = {k: v for k, v in r.items() if k != "error"} |
| 395 | print(_json.dumps({**make_envelope(elapsed, exit_code=exit_code), **emit})) |
| 396 | else: |
| 397 | if error: |
| 398 | print(f"ERR {r['commit_id']} ({error})") |
| 399 | else: |
| 400 | status = "OK " if valid else "BAD" |
| 401 | cid = short_id(r["commit_id"]) |
| 402 | signer = r["signer"] or "(unsigned)" |
| 403 | key = r["key_id"] or "" |
| 404 | key_part = f" key={key}" if key else "" |
| 405 | print(f"{status} {cid} signer={signer}{key_part}") |
| 406 | |
| 407 | if any_failure: |
| 408 | raise SystemExit(ExitCode.USER_ERROR) |
File History
1 commit
sha256:84df9126d09aeec0b8f1b908f0b06c10913feec28f3514b382efb1ba6d619385
refactor: rename StructuredMergePlugin to AddressedMergePlu…
Sonnet 4.6
minor
⚠
23 days ago