gabriel / muse public
verify_commit.py python
381 lines 12.4 KB
Raw
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