attestations.py
python
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