gabriel / muse public
attestations.py python
541 lines 21.1 KB
Raw
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f fix: show full cryptographic IDs in all human-readable CLI output Sonnet 4.6 patch 36 days ago
1 """muse hub attestation — issue, list, and revoke Ed25519 attestations.
2
3 Attestations are cryptographically signed claims one identity makes about
4 another. They are content-addressed (SHA-256 of the canonical message),
5 verified server-side, and auditable forever.
6
7 Subcommands
8 -----------
9 ::
10
11 muse hub attestation create --subject <handle> --type <claim_type> [flags]
12 muse hub attestation list [--subject <handle>] [--attester <handle>] [--type <type>]
13 muse hub attestation revoke <attestation_id>
14
15 Agent quickstart
16 ----------------
17 ::
18
19 # Attest that claude-code is a trustworthy agent
20 muse hub attestation create --subject claude-code --type agent --json
21
22 # Attest that you reviewed a specific commit
23 muse hub attestation create \
24 --subject gabriel/musehub --type code:reviewed \
25 --scope commit \
26 --scope-ref gabriel/musehub@sha256:abc123... \
27 --commit-id sha256:abc123... \
28 --json
29
30 # List all attestations about a subject
31 muse hub attestation list --subject gabriel --json
32
33 # Revoke an attestation by ID
34 muse hub attestation revoke <attestation_id> --json
35
36 Exit codes
37 ----------
38 0 Success.
39 1 Validation error or not authenticated.
40 2 Not inside a Muse repository.
41 3 API error.
42
43 Canonical ATTEST message (identity scope)::
44
45 ATTEST\\n{attester}\\n{subject}\\n{claim_json}\\n{issued_at_iso}
46
47 For repo/commit scope, scope_ref is appended::
48
49 ATTEST\\n{attester}\\n{subject}\\n{claim_json}\\n{issued_at_iso}\\n{scope_ref}
50
51 The signature is computed over this canonical message with Ed25519,
52 using the private key from ``~/.muse/identity.toml``.
53 """
54
55 import argparse
56 import base64
57 import json
58 import sys
59 import urllib.parse
60 from datetime import datetime, timezone
61
62 from ._core import (
63 ExitCode,
64 _get_hub_and_identity,
65 _hub_api,
66 _resolve_hub_override,
67 make_envelope,
68 sanitize_display,
69 start_timer,
70 )
71
72 _MAX_METADATA_LEN = 4096 # guard against excessively large --metadata blobs
73
74
75 def _b64url(b: bytes) -> str:
76 return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
77
78
79 def _attestation_canonical(
80 attester: str,
81 subject: str,
82 claim_json: str,
83 issued_at_iso: str,
84 scope: str = "identity",
85 scope_ref: str | None = None,
86 ) -> str:
87 parts = ["ATTEST", attester, subject, claim_json, issued_at_iso]
88 if scope != "identity" and scope_ref:
89 parts.append(scope_ref)
90 return "\n".join(parts)
91
92
93 def run_attestation_create(args: argparse.Namespace) -> None:
94 """Issue a signed attestation from the authenticated identity.
95
96 The private key is loaded from ``~/.muse/identity.toml``. The public key
97 and signature are computed locally and sent to the hub for verification and
98 storage. The attestation_id is deterministic (SHA-256 of canonical
99 message) so running the same command twice with the same --issued-at is
100 idempotent.
101
102 Agent quickstart
103 ----------------
104 ::
105
106 muse hub attestation create --subject gabriel --type human --json
107 muse hub attestation create --subject claude-code --type agent --json
108 muse hub attestation create \\
109 --subject gabriel/musehub --type code:reviewed \\
110 --scope repo --scope-ref gabriel/musehub \\
111 --json
112
113 Exit codes
114 ----------
115 0 Attestation created (or already exists — idempotent).
116 1 Validation error or not authenticated.
117 2 Not inside a Muse repository.
118 3 API error.
119 """
120 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
121 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
122 from muse.cli.config import get_signing_identity
123
124 subject: str = args.subject.strip()
125 claim_type: str = args.claim_type.strip()
126 scope: str = args.scope
127 scope_ref: str | None = (args.scope_ref or "").strip() or None
128 commit_id: str | None = (args.commit_id or "").strip() or None
129 metadata_str: str = (args.metadata or "").strip()
130 expires_in: str | None = (args.expires_in or "").strip() or None
131 json_output: bool = args.json_output
132
133 # ── Validate locally before any network I/O ─────────────────────────────
134 if not subject:
135 print("❌ --subject must not be empty.", file=sys.stderr)
136 raise SystemExit(ExitCode.USER_ERROR)
137 if not claim_type:
138 print("❌ --type must not be empty.", file=sys.stderr)
139 raise SystemExit(ExitCode.USER_ERROR)
140 if scope not in ("identity", "repo", "commit"):
141 print("❌ --scope must be 'identity', 'repo', or 'commit'.", file=sys.stderr)
142 raise SystemExit(ExitCode.USER_ERROR)
143 if scope in ("repo", "commit") and not scope_ref:
144 print(f"❌ --scope-ref is required when --scope is '{scope}'.", file=sys.stderr)
145 raise SystemExit(ExitCode.USER_ERROR)
146 if scope == "commit" and not commit_id:
147 print("❌ --commit-id is required when --scope is 'commit'.", file=sys.stderr)
148 raise SystemExit(ExitCode.USER_ERROR)
149
150 # Parse optional --metadata JSON
151 extra_claim = {}
152 if metadata_str:
153 if len(metadata_str) > _MAX_METADATA_LEN:
154 print(f"❌ --metadata exceeds {_MAX_METADATA_LEN} character limit.", file=sys.stderr)
155 raise SystemExit(ExitCode.USER_ERROR)
156 try:
157 extra_claim = json.loads(metadata_str)
158 if not isinstance(extra_claim, dict):
159 raise ValueError("must be a JSON object")
160 except (json.JSONDecodeError, ValueError) as exc:
161 print(f"❌ --metadata is not valid JSON: {exc}", file=sys.stderr)
162 raise SystemExit(ExitCode.USER_ERROR) from exc
163
164 # ── Get hub URL and resolve signing identity ─────────────────────────────
165 elapsed = start_timer()
166 hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args))
167
168 signing = get_signing_identity(remote_url=hub_url)
169 if signing is None:
170 print("❌ No signing identity for this hub. Run:", file=sys.stderr)
171 print(" muse auth keygen --hub <url>", file=sys.stderr)
172 print(" muse auth register --hub <url> --handle <your-handle>", file=sys.stderr)
173 raise SystemExit(ExitCode.USER_ERROR)
174
175 private_key = signing.private_key
176 if not isinstance(private_key, Ed25519PrivateKey):
177 print("❌ Signing key is not an Ed25519PrivateKey.", file=sys.stderr)
178 raise SystemExit(ExitCode.INTERNAL_ERROR)
179
180 attester: str = signing.handle
181
182 # ── Build claim JSON and canonical message ───────────────────────────────
183 claim_dict = {"type": claim_type}
184 claim_dict.update(extra_claim)
185 claim_json: str = json.dumps(claim_dict, separators=(",", ":"), sort_keys=True)
186
187 issued_at_iso: str = datetime.now(timezone.utc).isoformat(timespec="seconds")
188
189 canonical = _attestation_canonical(attester, subject, claim_json, issued_at_iso, scope, scope_ref)
190
191 # ── Sign ─────────────────────────────────────────────────────────────────
192 sig_bytes = private_key.sign(canonical.encode("utf-8"))
193 signature = f"ed25519:{_b64url(sig_bytes)}"
194
195 pub_bytes = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
196 attester_public_key = f"ed25519:{_b64url(pub_bytes)}"
197
198 # ── Resolve path handle from subject (strip repo/commit suffix) ──────────
199 path_handle = subject.split("/")[0]
200
201 # ── Build request body ───────────────────────────────────────────────────
202 payload = {
203 "attester": attester,
204 "subject": subject,
205 "claim": claim_json,
206 "signature": signature,
207 "attesterPublicKey": attester_public_key,
208 "issuedAt": issued_at_iso,
209 "scope": scope,
210 }
211 if scope_ref:
212 payload["scopeRef"] = scope_ref
213 if commit_id:
214 payload["commitId"] = commit_id
215
216 # Optional expiry — parse simple duration like "90d", "7d", "1y"
217 if expires_in:
218 try:
219 expiry_dt = _parse_expiry(expires_in, issued_at_iso)
220 payload["expiresAt"] = expiry_dt
221 except ValueError as exc:
222 print(f"❌ --expires-in: {exc}", file=sys.stderr)
223 raise SystemExit(ExitCode.USER_ERROR) from exc
224
225 data = _hub_api(hub_url, identity, "POST", f"/api/profiles/{path_handle}/attestations", body=payload)
226
227 if json_output:
228 print(json.dumps({**make_envelope(elapsed), **data}))
229 return
230
231 attest_id = str(data.get("attestationId", data.get("attestation_id", "")))
232 print(
233 f"✅ Attestation issued.\n"
234 f" id: {sanitize_display(attest_id)}\n"
235 f" type: {sanitize_display(claim_type)}\n"
236 f" from: {sanitize_display(attester)}\n"
237 f" about: {sanitize_display(subject)}\n"
238 f" scope: {sanitize_display(scope)}",
239 file=sys.stderr,
240 )
241
242
243 def run_attestation_list(args: argparse.Namespace) -> None:
244 """List attestations for a subject from MuseHub.
245
246 Client-side filtering is applied for --attester and --type since the
247 MuseHub API filters by subject only.
248
249 Agent quickstart
250 ----------------
251 ::
252
253 muse hub attestation list --subject gabriel --json
254 muse hub attestation list --subject gabriel --type agent --json
255 muse hub attestation list --attester musehub --subject gabriel --json
256
257 Exit codes
258 ----------
259 0 Success (including empty list).
260 1 Validation or auth error.
261 2 Not inside a Muse repository.
262 3 API error.
263 """
264 subject: str = (args.subject or "").strip()
265 attester_filter: str = (args.attester or "").strip()
266 type_filter: str = (args.type_filter or "").strip()
267 include_revoked: bool = args.include_revoked
268 json_output: bool = args.json_output
269
270 if not subject:
271 print("❌ --subject is required for attestation list.", file=sys.stderr)
272 raise SystemExit(ExitCode.USER_ERROR)
273
274 elapsed = start_timer()
275 hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args))
276
277 params = ""
278 if include_revoked:
279 params = "?include_revoked=true"
280
281 data = _hub_api(hub_url, identity, "GET", f"/api/profiles/{urllib.parse.quote(subject)}/attestations{params}")
282
283 attestations = data.get("attestations", [])
284
285 # Client-side filtering
286 if attester_filter:
287 attestations = [a for a in attestations if a.get("attester") == attester_filter]
288 if type_filter:
289 attestations = [
290 a for a in attestations
291 if _claim_type(a.get("claim", "{}")) == type_filter
292 ]
293
294 result = {**data, "attestations": attestations, "total": len(attestations)}
295
296 if json_output:
297 print(json.dumps({**make_envelope(elapsed), **result}))
298 return
299
300 if not attestations:
301 print(f" No attestations found for {sanitize_display(subject)}.", file=sys.stderr)
302 return
303
304 print(f"\n Attestations for {sanitize_display(subject)} ({len(attestations)} shown)", file=sys.stderr)
305 print(f" {'─' * 60}", file=sys.stderr)
306 for a in attestations:
307 ctype = _claim_type(a.get("claim", "{}"))
308 attest_id = sanitize_display(str(a.get("attestationId", a.get("attestation_id", ""))))
309 attester = sanitize_display(str(a.get("attester", "")))
310 issued = sanitize_display(str(a.get("issuedAt", a.get("issued_at", "")))[:10])
311 revoked = " [revoked]" if a.get("revokedAt") or a.get("revoked_at") else ""
312 print(
313 f" {attest_id} {ctype:<20} from {attester:<20} {issued}{revoked}",
314 file=sys.stderr,
315 )
316
317
318 def run_attestation_revoke(args: argparse.Namespace) -> None:
319 """Revoke an attestation by ID.
320
321 Only the original attester can revoke their claim. The authenticated
322 identity must match the attester on the attestation record.
323
324 Agent quickstart
325 ----------------
326 ::
327
328 muse hub attestation revoke <attestation_id> --json
329
330 Exit codes
331 ----------
332 0 Attestation revoked.
333 1 Validation or auth error (including wrong attester).
334 2 Not inside a Muse repository.
335 3 API error (404 if not found, 403 if wrong attester).
336 """
337 from muse.cli.config import get_signing_identity
338
339 attestation_id: str = args.attestation_id.strip()
340 subject: str = (args.subject or "").strip()
341 json_output: bool = args.json_output
342
343 if not attestation_id:
344 print("❌ attestation_id must not be empty.", file=sys.stderr)
345 raise SystemExit(ExitCode.USER_ERROR)
346 if not subject:
347 print("❌ --subject is required (the handle the attestation is about).", file=sys.stderr)
348 raise SystemExit(ExitCode.USER_ERROR)
349
350 elapsed = start_timer()
351 hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args))
352
353 signing = get_signing_identity(remote_url=hub_url)
354 if signing is None:
355 print("❌ No signing identity for this hub.", file=sys.stderr)
356 raise SystemExit(ExitCode.USER_ERROR)
357
358 revoker = signing.handle
359
360 # DELETE /api/profiles/{handle}/attestations/{id}?revoker={handle}
361 path = (
362 f"/api/profiles/{urllib.parse.quote(subject)}"
363 f"/attestations/{urllib.parse.quote(attestation_id)}"
364 f"?revoker={urllib.parse.quote(revoker)}"
365 )
366 data = _hub_api(hub_url, identity, "DELETE", path)
367
368 if json_output:
369 print(json.dumps({**make_envelope(elapsed), **data}))
370 return
371
372 attest_id = sanitize_display(str(data.get("attestationId", data.get("attestation_id", attestation_id))))
373 print(f"✅ Attestation {attest_id} revoked.", file=sys.stderr)
374
375
376 # ── Helpers ───────────────────────────────────────────────────────────────────
377
378 def _claim_type(claim_json: str) -> str:
379 """Extract the 'type' field from a claim JSON string, or return '?'."""
380 try:
381 return json.loads(claim_json).get("type", "?")
382 except Exception:
383 return "?"
384
385
386 def _parse_expiry(expires_in: str, from_iso: str) -> str:
387 """Parse a duration string like '90d', '7d', '1y' into an ISO-8601 timestamp.
388
389 Supported units: d (days), w (weeks), y (years, 365d).
390
391 Returns an ISO-8601 UTC timestamp string.
392
393 Raises:
394 ValueError: If the format is unrecognised.
395 """
396 import re
397 m = re.fullmatch(r"(\d+)([dwmy])", expires_in)
398 if not m:
399 raise ValueError(
400 f"Unrecognised duration '{expires_in}'. Use <N>d (days), <N>w (weeks), or <N>y (years)."
401 )
402 n = int(m.group(1))
403 unit = m.group(2)
404 days = n if unit == "d" else n * 7 if unit == "w" else n * 30 if unit == "m" else n * 365
405 from datetime import timedelta
406 base = datetime.fromisoformat(from_iso)
407 expiry = base + timedelta(days=days)
408 return expiry.isoformat(timespec="seconds")
409
410
411 # ── argparse registration ─────────────────────────────────────────────────────
412
413 def register(subs: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
414 """Register attestation subcommands."""
415 attest_p = subs.add_parser(
416 "attestation",
417 help="Issue, list, and revoke Ed25519 attestations.",
418 description=__doc__,
419 formatter_class=argparse.RawDescriptionHelpFormatter,
420 )
421 attest_subs = attest_p.add_subparsers(dest="attestation_subcommand", metavar="ATTESTATION_COMMAND")
422 attest_subs.required = True
423
424 def _common_args(p: argparse.ArgumentParser) -> None:
425 p.add_argument("--hub", dest="hub", default=None, metavar="URL",
426 help="Override the hub URL from config.")
427 p.add_argument("--repo", dest="repo", default=None, metavar="OWNER/REPO",
428 help="Specify repo as owner/repo.")
429 p.add_argument("--json", "-j", action="store_true", dest="json_output",
430 help="Emit JSON output.")
431
432 # ── attestation create ────────────────────────────────────────────────────
433 create_p = attest_subs.add_parser(
434 "create",
435 help="Issue a signed attestation.",
436 description=(
437 "Issue a cryptographic attestation from the authenticated identity.\n\n"
438 " muse hub attestation create --subject gabriel --type human\n"
439 " muse hub attestation create --subject claude-code --type agent --json\n"
440 " muse hub attestation create \\\n"
441 " --subject gabriel/musehub --type code:reviewed \\\n"
442 " --scope commit \\\n"
443 " --scope-ref gabriel/musehub@sha256:abc123... \\\n"
444 " --commit-id sha256:abc123... \\\n"
445 " --json\n\n"
446 "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error."
447 ),
448 formatter_class=argparse.RawDescriptionHelpFormatter,
449 )
450 _common_args(create_p)
451 create_p.add_argument(
452 "--subject", required=True, metavar="HANDLE",
453 help="Handle being attested (for identity scope) or 'handle/repo' for repo/commit scope.",
454 )
455 create_p.add_argument(
456 "--type", required=True, dest="claim_type", metavar="CLAIM_TYPE",
457 help=(
458 "Claim type key. Valid types include: "
459 "human, org, agent, spawned-by, delegate, trusted, collab, co-author, "
460 "contractor, skill:verified, code:reviewed, code:approved, deploy:approved, "
461 "audit:passed, audit:failed, identity:verified, music:released."
462 ),
463 )
464 create_p.add_argument(
465 "--scope", default="identity", choices=["identity", "repo", "commit"],
466 help="Attestation scope (default: identity).",
467 )
468 create_p.add_argument(
469 "--scope-ref", dest="scope_ref", default=None, metavar="REF",
470 help=(
471 "Full subject reference for repo/commit scope. "
472 "E.g. 'gabriel/musehub' or 'gabriel/musehub@sha256:abc...'."
473 ),
474 )
475 create_p.add_argument(
476 "--commit-id", dest="commit_id", default=None, metavar="SHA256",
477 help="Commit ID ('sha256:...') — required when --scope is 'commit'.",
478 )
479 create_p.add_argument(
480 "--metadata", default=None, metavar="JSON",
481 help="Optional JSON object of extra claim fields, e.g. '{\"skill\":\"python\"}'.",
482 )
483 create_p.add_argument(
484 "--expires-in", dest="expires_in", default=None, metavar="DURATION",
485 help="Optional expiry duration, e.g. '90d', '1y'.",
486 )
487 create_p.set_defaults(func=run_attestation_create)
488
489 # ── attestation list ──────────────────────────────────────────────────────
490 list_p = attest_subs.add_parser(
491 "list",
492 help="List attestations for a subject.",
493 description=(
494 "List active attestations about a subject identity.\n\n"
495 " muse hub attestation list --subject gabriel\n"
496 " muse hub attestation list --subject gabriel --type agent --json\n"
497 " muse hub attestation list --subject gabriel --attester musehub --json\n\n"
498 "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error."
499 ),
500 formatter_class=argparse.RawDescriptionHelpFormatter,
501 )
502 _common_args(list_p)
503 list_p.add_argument(
504 "--subject", required=True, metavar="HANDLE",
505 help="Handle or slug to list attestations for.",
506 )
507 list_p.add_argument(
508 "--attester", default=None, metavar="HANDLE",
509 help="Filter by attester handle (client-side).",
510 )
511 list_p.add_argument(
512 "--type", dest="type_filter", default=None, metavar="CLAIM_TYPE",
513 help="Filter by claim type (client-side).",
514 )
515 list_p.add_argument(
516 "--include-revoked", dest="include_revoked", action="store_true",
517 help="Include revoked attestations.",
518 )
519 list_p.set_defaults(func=run_attestation_list)
520
521 # ── attestation revoke ────────────────────────────────────────────────────
522 revoke_p = attest_subs.add_parser(
523 "revoke",
524 help="Revoke an attestation.",
525 description=(
526 "Revoke an attestation by its ID. Only the original attester can revoke.\n\n"
527 " muse hub attestation revoke <attestation_id> --subject gabriel --json\n\n"
528 "Exit codes: 0 success, 1 auth/permission error, 2 not in repo, 3 API error."
529 ),
530 formatter_class=argparse.RawDescriptionHelpFormatter,
531 )
532 _common_args(revoke_p)
533 revoke_p.add_argument(
534 "attestation_id", metavar="ATTESTATION_ID",
535 help="Full attestation ID to revoke.",
536 )
537 revoke_p.add_argument(
538 "--subject", required=True, metavar="HANDLE",
539 help="Handle the attestation is about (path handle).",
540 )
541 revoke_p.set_defaults(func=run_attestation_revoke)
File History 1 commit
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f fix: show full cryptographic IDs in all human-readable CLI output Sonnet 4.6 patch 36 days ago