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