trust.py
python
sha256:08c083095bcaffb4c43ce947668fac93bf5d261e13d321776170c4019dd1d77d
fix: migration must never update identity.toml on a failed …
Sonnet 5
minor
⚠ breaking
9 hours ago
| 1 | """muse trust — manage trusted repository paths and pinned hub fingerprints. |
| 2 | |
| 3 | Security model |
| 4 | -------------- |
| 5 | Muse performs an ownership check on every discovered ``.muse/`` directory |
| 6 | (CVE-2022-24765 equivalent). If the ``.muse/`` directory is owned by a |
| 7 | different UID, Muse refuses to operate on it and raises |
| 8 | :class:`~muse.core.errors.UntrustedRepositoryError`. |
| 9 | |
| 10 | For shared-filesystem environments (Docker bind-mounts, CI agents, multi-user |
| 11 | workstations), specific paths can be added to the trust list so Muse operates |
| 12 | normally without re-checking ownership. |
| 13 | |
| 14 | Trust sources (in evaluation order) |
| 15 | ------------------------------------ |
| 16 | 1. ``MUSE_SAFE_DIRS`` environment variable — colon-separated absolute paths. |
| 17 | Useful for ephemeral CI containers. |
| 18 | 2. ``~/.muse/config.toml`` ``[security] safe_dirs`` — persistent trust list. |
| 19 | Managed by ``muse trust add`` / ``muse trust remove``. |
| 20 | |
| 21 | Hub fingerprint pinning (TOFU) |
| 22 | ------------------------------- |
| 23 | ``muse trust hub-list`` and ``muse trust hub-reset`` manage the TOFU |
| 24 | (Trust On First Use) fingerprint store at ``~/.muse/hub_trust.toml``. |
| 25 | |
| 26 | JSON output contract |
| 27 | -------------------- |
| 28 | Every ``--json`` response includes ``duration_ms`` (float, milliseconds) and |
| 29 | ``exit_code`` (int). Errors are emitted to **stdout** (not stderr) in JSON |
| 30 | mode so agents can always parse a structured response:: |
| 31 | |
| 32 | {"error": "<key>", "message": "<description>", "duration_ms": 0.3, "exit_code": 3} |
| 33 | |
| 34 | Exit codes:: |
| 35 | |
| 36 | 0 — success |
| 37 | 3 — I/O error reading or writing the trust store |
| 38 | """ |
| 39 | |
| 40 | import argparse |
| 41 | import json as _json |
| 42 | import sys |
| 43 | from typing import TYPE_CHECKING, TypedDict |
| 44 | |
| 45 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 46 | from muse.core.timing import start_timer |
| 47 | |
| 48 | if TYPE_CHECKING: |
| 49 | from muse.core.hub_trust import HubTrustStore |
| 50 | |
| 51 | class _TrustAddJson(EnvelopeJson): |
| 52 | added: bool |
| 53 | path: str |
| 54 | |
| 55 | class _TrustRemoveJson(EnvelopeJson): |
| 56 | removed: bool |
| 57 | path: str |
| 58 | |
| 59 | class _TrustListJson(EnvelopeJson): |
| 60 | trusted_dirs: list[str] |
| 61 | count: int |
| 62 | |
| 63 | class _TrustHubListJson(EnvelopeJson): |
| 64 | fingerprints: HubTrustStore |
| 65 | count: int |
| 66 | |
| 67 | class _TrustHubResetJson(EnvelopeJson): |
| 68 | reset: bool |
| 69 | hostname: str |
| 70 | |
| 71 | def _add_json_flag(parser: argparse.ArgumentParser) -> None: |
| 72 | parser.add_argument( |
| 73 | "--json", "-j", |
| 74 | action="store_true", |
| 75 | default=False, |
| 76 | dest="json_out", |
| 77 | help="Emit machine-readable JSON instead of human-readable text.", |
| 78 | ) |
| 79 | parser.set_defaults(json_out=False) |
| 80 | |
| 81 | def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: |
| 82 | """Register the ``muse trust`` namespace and its subcommands.""" |
| 83 | trust_parser = subparsers.add_parser( |
| 84 | "trust", |
| 85 | help="Manage trusted repository paths and pinned hub fingerprints.", |
| 86 | description=__doc__, |
| 87 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 88 | ) |
| 89 | trust_subs = trust_parser.add_subparsers(dest="trust_command", metavar="TRUST_COMMAND") |
| 90 | trust_subs.required = True |
| 91 | |
| 92 | # muse trust add <path> |
| 93 | add_p = trust_subs.add_parser( |
| 94 | "add", |
| 95 | help="Add a path to the trusted directory list (~/.muse/config.toml).", |
| 96 | description=( |
| 97 | "Add PATH to the [security] safe_dirs list in ~/.muse/config.toml.\n" |
| 98 | "Muse will skip the ownership check for this repository.\n\n" |
| 99 | "Example:\n" |
| 100 | " muse trust add /shared/workspaces/myrepo" |
| 101 | ), |
| 102 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 103 | ) |
| 104 | add_p.add_argument("path", help="Repository path to trust (absolute or relative).") |
| 105 | _add_json_flag(add_p) |
| 106 | add_p.set_defaults(func=_run_add) |
| 107 | |
| 108 | # muse trust remove <path> |
| 109 | remove_p = trust_subs.add_parser( |
| 110 | "remove", |
| 111 | help="Remove a path from the trusted directory list.", |
| 112 | description=( |
| 113 | "Remove PATH from the [security] safe_dirs list in ~/.muse/config.toml." |
| 114 | ), |
| 115 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 116 | ) |
| 117 | remove_p.add_argument("path", help="Repository path to remove from the trust list.") |
| 118 | _add_json_flag(remove_p) |
| 119 | remove_p.set_defaults(func=_run_remove) |
| 120 | |
| 121 | # muse trust list |
| 122 | list_p = trust_subs.add_parser( |
| 123 | "list", |
| 124 | help="Print all currently trusted paths.", |
| 125 | description="Print all paths in the [security] safe_dirs list.", |
| 126 | ) |
| 127 | _add_json_flag(list_p) |
| 128 | list_p.set_defaults(func=_run_list) |
| 129 | |
| 130 | # muse trust hub-list |
| 131 | hub_list_p = trust_subs.add_parser( |
| 132 | "hub-list", |
| 133 | help="List all pinned hub fingerprints (TOFU store).", |
| 134 | description=( |
| 135 | "Print all hub fingerprints stored in ~/.muse/hub_trust.toml.\n" |
| 136 | "Each entry shows: hostname, SHA-256 fingerprint, first seen, verified count." |
| 137 | ), |
| 138 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 139 | ) |
| 140 | _add_json_flag(hub_list_p) |
| 141 | hub_list_p.set_defaults(func=_run_hub_list) |
| 142 | |
| 143 | # muse trust hub-reset <hostname> |
| 144 | hub_reset_p = trust_subs.add_parser( |
| 145 | "hub-reset", |
| 146 | help="Remove a pinned hub fingerprint so the next connection re-pins (TOFU reset).", |
| 147 | description=( |
| 148 | "Remove the stored fingerprint for HOSTNAME from ~/.muse/hub_trust.toml.\n" |
| 149 | "The next connection to this hub will re-pin with TOFU.\n\n" |
| 150 | "Use this after a legitimate certificate rotation to acknowledge the change." |
| 151 | ), |
| 152 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 153 | ) |
| 154 | hub_reset_p.add_argument("hostname", help="Hub hostname (e.g. 'musehub.ai' or 'localhost:1337').") |
| 155 | _add_json_flag(hub_reset_p) |
| 156 | hub_reset_p.set_defaults(func=_run_hub_reset) |
| 157 | |
| 158 | trust_parser.set_defaults(func=lambda args: trust_parser.print_help() or sys.exit(0)) |
| 159 | |
| 160 | def _run_add(args: argparse.Namespace) -> None: |
| 161 | """Add a path to the global trusted-directory list. |
| 162 | |
| 163 | Normalises *path* to an absolute path before storing. Adding the same |
| 164 | path twice is idempotent — no duplicate entry is created. |
| 165 | |
| 166 | Agent quickstart:: |
| 167 | |
| 168 | muse trust add /shared/workspaces/myrepo --json |
| 169 | muse trust add . --json |
| 170 | |
| 171 | JSON fields:: |
| 172 | |
| 173 | added true if the path was newly added (false if already trusted). |
| 174 | path Absolute path that was added. |
| 175 | muse_version Muse release that produced this output. |
| 176 | schema Envelope schema version (int). |
| 177 | exit_code 0 on success, 3 on I/O error. |
| 178 | duration_ms Wall-clock milliseconds for the command. |
| 179 | timestamp ISO-8601 UTC timestamp of command completion. |
| 180 | warnings List of non-fatal advisory messages. |
| 181 | |
| 182 | Exit codes:: |
| 183 | |
| 184 | 0 Path added (or already trusted). |
| 185 | 3 I/O error reading or writing ~/.muse/config.toml. |
| 186 | """ |
| 187 | elapsed = start_timer() |
| 188 | from muse.cli.config import add_global_safe_dir |
| 189 | import os |
| 190 | abs_path = os.path.abspath(args.path) |
| 191 | json_out: bool = getattr(args, "json_out", False) |
| 192 | |
| 193 | def _emit_error(msg: str, code: int, error_key: str = "error") -> None: |
| 194 | if json_out: |
| 195 | print(_json.dumps({**make_envelope(elapsed, exit_code=code), "error": error_key, "message": msg})) |
| 196 | else: |
| 197 | print(f"❌ {msg}", file=sys.stderr) |
| 198 | raise SystemExit(code) |
| 199 | |
| 200 | try: |
| 201 | add_global_safe_dir(abs_path) |
| 202 | except OSError as exc: |
| 203 | _emit_error(str(exc), 3, "io_error") |
| 204 | |
| 205 | if json_out: |
| 206 | print(_json.dumps(_TrustAddJson(**make_envelope(elapsed), added=True, path=abs_path))) |
| 207 | else: |
| 208 | print(f"Trusted path added: {abs_path}") |
| 209 | print(f"Muse will skip the ownership check for: {abs_path}") |
| 210 | |
| 211 | def _run_remove(args: argparse.Namespace) -> None: |
| 212 | """Remove a path from the global trusted-directory list. |
| 213 | |
| 214 | Normalises *path* to an absolute path before matching. Removal is |
| 215 | idempotent — removing a path that is not trusted exits 0 with ``removed=false``. |
| 216 | |
| 217 | Agent quickstart:: |
| 218 | |
| 219 | muse trust remove /shared/workspaces/myrepo --json |
| 220 | |
| 221 | JSON fields:: |
| 222 | |
| 223 | removed true if the path was found and removed; false if not trusted. |
| 224 | path Absolute path that was targeted. |
| 225 | muse_version Muse release that produced this output. |
| 226 | schema Envelope schema version (int). |
| 227 | exit_code 0 on success, 3 on I/O error. |
| 228 | duration_ms Wall-clock milliseconds for the command. |
| 229 | timestamp ISO-8601 UTC timestamp of command completion. |
| 230 | warnings List of non-fatal advisory messages. |
| 231 | |
| 232 | Exit codes:: |
| 233 | |
| 234 | 0 Completed (whether or not the path was in the list). |
| 235 | 3 I/O error reading or writing ~/.muse/config.toml. |
| 236 | """ |
| 237 | elapsed = start_timer() |
| 238 | from muse.cli.config import remove_global_safe_dir, get_global_safe_dirs |
| 239 | import os |
| 240 | abs_path = os.path.abspath(args.path) |
| 241 | json_out: bool = getattr(args, "json_out", False) |
| 242 | |
| 243 | def _emit_error(msg: str, code: int, error_key: str = "error") -> None: |
| 244 | if json_out: |
| 245 | print(_json.dumps({**make_envelope(elapsed, exit_code=code), "error": error_key, "message": msg})) |
| 246 | else: |
| 247 | print(f"❌ {msg}", file=sys.stderr) |
| 248 | raise SystemExit(code) |
| 249 | |
| 250 | try: |
| 251 | before = get_global_safe_dirs() |
| 252 | remove_global_safe_dir(abs_path) |
| 253 | after = get_global_safe_dirs() |
| 254 | except OSError as exc: |
| 255 | _emit_error(str(exc), 3, "io_error") |
| 256 | |
| 257 | removed = abs_path in before and abs_path not in after |
| 258 | if json_out: |
| 259 | print(_json.dumps(_TrustRemoveJson(**make_envelope(elapsed), removed=removed, path=abs_path))) |
| 260 | elif removed: |
| 261 | print(f"Trusted path removed: {abs_path}") |
| 262 | else: |
| 263 | print(f"Path not in trust list: {abs_path}") |
| 264 | |
| 265 | def _run_list(args: argparse.Namespace) -> None: |
| 266 | """List all trusted paths from ``~/.muse/config.toml``. |
| 267 | |
| 268 | An empty list is a valid result — exits 0 with ``count=0``. |
| 269 | |
| 270 | Agent quickstart:: |
| 271 | |
| 272 | muse trust list --json |
| 273 | |
| 274 | JSON fields:: |
| 275 | |
| 276 | trusted_dirs List of absolute trusted-directory paths. |
| 277 | count Number of trusted paths. |
| 278 | muse_version Muse release that produced this output. |
| 279 | schema Envelope schema version (int). |
| 280 | exit_code Always 0. |
| 281 | duration_ms Wall-clock milliseconds for the command. |
| 282 | timestamp ISO-8601 UTC timestamp of command completion. |
| 283 | warnings List of non-fatal advisory messages. |
| 284 | |
| 285 | Exit codes:: |
| 286 | |
| 287 | 0 Success (empty list is still success). |
| 288 | """ |
| 289 | elapsed = start_timer() |
| 290 | from muse.cli.config import get_global_safe_dirs |
| 291 | dirs = get_global_safe_dirs() |
| 292 | if getattr(args, "json_out", False): |
| 293 | print(_json.dumps(_TrustListJson(**make_envelope(elapsed), trusted_dirs=dirs, count=len(dirs)))) |
| 294 | return |
| 295 | if not dirs: |
| 296 | print("No trusted directories configured.") |
| 297 | print("Add paths with: muse trust add <path>") |
| 298 | return |
| 299 | print("Trusted directories (~/.muse/config.toml):") |
| 300 | for d in dirs: |
| 301 | print(f" {d}") |
| 302 | |
| 303 | def _run_hub_list(args: argparse.Namespace) -> None: |
| 304 | """List all pinned hub TLS fingerprints from ``~/.muse/hub_trust.toml``. |
| 305 | |
| 306 | Each entry is keyed by hostname and includes the full ``HubTrustRecord`` |
| 307 | (``hub_url``, ``fingerprint``, ``first_seen``, ``verified_count``). |
| 308 | |
| 309 | Agent quickstart:: |
| 310 | |
| 311 | muse trust hub-list --json |
| 312 | |
| 313 | JSON fields:: |
| 314 | |
| 315 | fingerprints Map of hostname → HubTrustRecord (hub_url, fingerprint, first_seen, verified_count). |
| 316 | count Number of pinned fingerprints. |
| 317 | muse_version Muse release that produced this output. |
| 318 | schema Envelope schema version (int). |
| 319 | exit_code Always 0. |
| 320 | duration_ms Wall-clock milliseconds for the command. |
| 321 | timestamp ISO-8601 UTC timestamp of command completion. |
| 322 | warnings List of non-fatal advisory messages. |
| 323 | |
| 324 | Exit codes:: |
| 325 | |
| 326 | 0 Success (empty map is still success). |
| 327 | """ |
| 328 | elapsed = start_timer() |
| 329 | from muse.core.hub_trust import load_hub_trust_store |
| 330 | store = load_hub_trust_store() |
| 331 | if getattr(args, "json_out", False): |
| 332 | print(_json.dumps(_TrustHubListJson(**make_envelope(elapsed), fingerprints=dict(store), count=len(store)))) |
| 333 | return |
| 334 | if not store: |
| 335 | print("No hub fingerprints pinned.") |
| 336 | print("Fingerprints are pinned automatically on first connection.") |
| 337 | return |
| 338 | print("Pinned hub fingerprints (~/.muse/hub_trust.toml):") |
| 339 | for hostname, record in sorted(store.items()): |
| 340 | print( |
| 341 | f" {hostname}\n" |
| 342 | f" fingerprint: {record['fingerprint']}\n" |
| 343 | f" first_seen: {record['first_seen']}\n" |
| 344 | f" verified_count: {record['verified_count']}" |
| 345 | ) |
| 346 | |
| 347 | def _run_hub_reset(args: argparse.Namespace) -> None: |
| 348 | """Remove a hub fingerprint so the next connection re-pins (TOFU reset). |
| 349 | |
| 350 | Idempotent — resetting an unknown hostname exits 0 with ``reset=false``. |
| 351 | Use after a legitimate TLS certificate rotation to acknowledge the change. |
| 352 | |
| 353 | Agent quickstart:: |
| 354 | |
| 355 | muse trust hub-reset musehub.ai --json |
| 356 | muse trust hub-reset localhost:1337 --json |
| 357 | |
| 358 | JSON fields:: |
| 359 | |
| 360 | reset true if a fingerprint was found and removed; false if unknown hostname. |
| 361 | hostname Hub hostname that was targeted. |
| 362 | muse_version Muse release that produced this output. |
| 363 | schema Envelope schema version (int). |
| 364 | exit_code Always 0. |
| 365 | duration_ms Wall-clock milliseconds for the command. |
| 366 | timestamp ISO-8601 UTC timestamp of command completion. |
| 367 | warnings List of non-fatal advisory messages. |
| 368 | |
| 369 | Exit codes:: |
| 370 | |
| 371 | 0 Success (whether or not a fingerprint existed for the hostname). |
| 372 | """ |
| 373 | elapsed = start_timer() |
| 374 | from muse.core.hub_trust import remove_hub_record |
| 375 | hostname = args.hostname |
| 376 | removed = remove_hub_record(hostname) |
| 377 | if getattr(args, "json_out", False): |
| 378 | print(_json.dumps(_TrustHubResetJson(**make_envelope(elapsed), reset=removed, hostname=hostname))) |
| 379 | elif removed: |
| 380 | print(f"Hub fingerprint reset for: {hostname}") |
| 381 | print("The next connection will re-pin with TOFU.") |
| 382 | else: |
| 383 | print(f"No pinned fingerprint found for: {hostname}") |
File History
1 commit
sha256:08c083095bcaffb4c43ce947668fac93bf5d261e13d321776170c4019dd1d77d
fix: migration must never update identity.toml on a failed …
Sonnet 5
minor
⚠
9 hours ago