commit.py file-level

at sha256:6 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
1 """``muse commit`` β€” record the current workspace state as a new version.
2
3 Algorithm
4 ---------
5 1. Resolve repo root (walk up for ``.muse/``).
6 2. Read ``repo_id`` from ``.muse/repo.json`` and the current branch from
7 ``.muse/HEAD``.
8 3. Invoke ``plugin.snapshot(root)`` to collect the workspace manifest
9 (domain-specific; the code plugin walks tracked source files).
10 4. If the computed ``snapshot_id`` matches HEAD β†’ "nothing to commit".
11 5. Compute a deterministic ``commit_id`` = SHA-256 of (parents | snapshot |
12 message | timestamp).
13 6. Write content-addressed blob objects to ``.muse/objects/``.
14 7. Write snapshot JSON to ``.muse/snapshots/<snapshot_id>.json``.
15 8. Write commit JSON to ``.muse/commits/<commit_id>.json``.
16 9. Advance ``.muse/refs/heads/<branch>`` to the new ``commit_id``.
17
18 ``--dry-run``
19 Perform steps 1–5 (compute snapshot and commit_id) without writing
20 anything. Exits 0 when changes are present, 1 when the tree is clean.
21 Combine with ``--json`` for structured preflight output in agent pipelines.
22
23 ``--meta``
24 Attach an arbitrary JSON object to the commit as structured metadata.
25 Accepts a JSON-encoded dict string. The payload is validated (must be a
26 dict, no sensitive key patterns, ≀ 64 KiB canonical, no NaN/Infinity)
27 and stored verbatim under the ``"meta"`` key of the commit's metadata
28 dict. This is a *permanent* schema decision: once written to the commit
29 graph the shape is read by all downstream tooling that walks
30 ``commit.metadata["meta"]``.
31
32 Reserved top-level keys: ``meta`` and ``event_type`` inside the ``--meta``
33 payload conflict with the CLI-controlled metadata slots of the same name
34 and would create two sources of truth for the same logical field in the
35 permanent commit graph. Including either as a top-level key in ``--meta``
36 is a hard error β€” use the dedicated ``--event-type`` flag instead, and
37 do not nest ``"meta"`` at the top level of your payload.
38
39 ``--event-type``
40 Tag the commit with a knowtation memory-event kind string. Validated
41 against the canonical 15-kind taxonomy defined in
42 ``muse/plugins/knowtation/events.py`` (falls back to a built-in frozenset
43 when that module is not yet installed, e.g. during bootstrapping).
44 Stored as ``commit.metadata["event_type"]``.
45
46 Exit codes::
47
48 0 β€” commit created, OR nothing to commit (clean tree)
49 1 β€” validation error (no message, unresolved conflicts, clean tree with --dry-run)
50 3 β€” I/O error
51 """
52
53 from __future__ import annotations
54
55 import argparse
56 import datetime
57 import json
58 import logging
59 import os
60 import pathlib
61 import re
62 import sys
63 from typing import Any
64
65 from muse.core.attestation import (
66 AttestationError,
67 AttestationRequiredError,
68 get_attestation_provider,
69 )
70 from muse.core.errors import ExitCode
71 from muse.core.merge_engine import clear_merge_state, read_merge_state
72 from muse.core.object_store import write_object_from_path
73 from muse.core.provenance import (
74 make_agent_identity,
75 provenance_payload,
76 sign_commit_record,
77 )
78 from muse.core.reflog import append_reflog
79 from muse.core.repo import read_repo_id, require_repo
80 from muse.core.rerere import record_resolutions as rerere_record_resolutions
81 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
82 from muse.core.store import (
83 Metadata,
84 Manifest,
85 CommitRecord,
86 SnapshotRecord,
87 get_head_commit_id,
88 get_head_snapshot_id,
89 read_commit,
90 read_current_branch,
91 read_snapshot,
92 write_branch_ref,
93 write_commit,
94 write_snapshot,
95 )
96 from muse.core.validation import sanitize_display, sanitize_provenance, validate_branch_name
97 from muse.core.semver_classifier import classify_delta
98 from muse.domain import SemVerBump, SnapshotManifest, StagePlugin, StructuredDelta
99 from muse.plugins.registry import read_domain, resolve_plugin
100
101 logger = logging.getLogger(__name__)
102
103 # Maximum length for author and agent-provenance fields.
104 # Prevents DoS via arbitrarily long values and keeps commit records bounded.
105 _MAX_FIELD_LEN = 256
106
107 # ── --meta payload validation constants ──────────────────────────────────────
108
109 #: Maximum size in bytes of the serialised ``--meta`` JSON payload. 64 KiB is
110 #: generous for commit annotations while bounding memory and storage growth.
111 _MAX_META_BYTES: int = 65_536
112
113 #: Pre-parse hard cap on the *raw* ``--meta`` argument string (in characters,
114 #: which is a lower bound on UTF-8 byte length). Set to twice the canonical
115 #: byte cap so that pretty-printed (whitespace-padded) JSON whose canonical
116 #: form fits ≀ 64 KiB is still accepted, while bounding the worst-case
117 #: ``json.loads`` and ``_has_sensitive_keys`` cost on adversarial input. This
118 #: defends future ``--meta @file`` or stdin paths even though the current CLI
119 #: route is already bounded by the OS argv limit (~256 KiB on Darwin/Linux).
120 _MAX_META_RAW_BYTES: int = _MAX_META_BYTES * 2
121
122 #: Top-level keys in the ``--meta`` payload that conflict with CLI-controlled
123 #: metadata slots of the same name. Using either as a top-level key in
124 #: ``--meta`` would create two sources of truth for the same logical field in
125 #: the permanent commit graph (e.g. ``metadata["event_type"]`` *and*
126 #: ``json.loads(metadata["meta"])["event_type"]``), so it is rejected as a
127 #: hard error rather than silently shadowed with a warning.
128 _RESERVED_META_KEYS: frozenset[str] = frozenset({"event_type", "meta"})
129
130 #: Compiled regex matching sensitive-data key patterns. Mirrors the
131 #: ``SENSITIVE_VALUE`` pattern in ``knowtation/lib/memory-event.mjs`` so both
132 #: sides of the Muse/Knowtation boundary reject the same class of key names.
133 _SENSITIVE_KEY_RE: re.Pattern[str] = re.compile(
134 r"(api[_-]?key|secret|password|token|credential|authorization|bearer|private[_-]?key)",
135 re.IGNORECASE,
136 )
137
138 # ── --event-type validation ───────────────────────────────────────────────────
139
140 #: The 15 canonical memory-event kinds mirrored from
141 #: ``knowtation/lib/memory-event.mjs``. Used as a fallback when
142 #: ``muse.plugins.knowtation.events`` is not yet installed.
143 _FALLBACK_EVENT_KINDS: frozenset[str] = frozenset({
144 "search", "export", "write", "import", "index", "propose",
145 "agent_interaction", "capture", "error", "session_summary",
146 "user", "consolidation", "consolidation_pass", "maintenance", "insight",
147 })
148
149 # Try to import the canonical validator from the events module (Phase 4.1).
150 # Fall back to the inline frozenset during bootstrapping / before that module
151 # is committed, so that Phase 4.2 tests pass independently of Phase 4.1.
152 try:
153 from muse.plugins.knowtation.events import is_valid_event_kind as _is_valid_event_kind # type: ignore[import-not-found]
154 except ImportError: # pragma: no cover β€” removed once Phase 4.1 is committed
155 def _is_valid_event_kind(kind: str) -> bool: # type: ignore[misc]
156 """Return True iff *kind* is a valid memory-event kind (fallback path)."""
157 return kind in _FALLBACK_EVENT_KINDS
158
159
160 def _has_sensitive_keys(obj: Any, depth: int = 0) -> bool:
161 """Return True if *obj* contains any key matching :data:`_SENSITIVE_KEY_RE`.
162
163 Mirrors the ``hasSensitiveKeys`` function in ``knowtation/lib/memory-event.mjs``:
164 recursive scan up to depth 8; beyond that depth the scan stops and returns
165 False (consistent with the JS implementation).
166
167 Args:
168 obj: Any Python value (dict, list, scalar, …).
169 depth: Current recursion depth (callers pass 0).
170
171 Returns:
172 ``True`` if a sensitive key pattern is found at or below *depth* ≀ 8;
173 ``False`` otherwise.
174 """
175 if depth > 8 or obj is None:
176 return False
177 if isinstance(obj, list):
178 return any(_has_sensitive_keys(v, depth + 1) for v in obj)
179 if isinstance(obj, dict):
180 for k, v in obj.items():
181 if isinstance(k, str) and _SENSITIVE_KEY_RE.search(k):
182 return True
183 if _has_sensitive_keys(v, depth + 1):
184 return True
185 return False
186
187
188 def _reject_nonjson_constant(token: str) -> Any:
189 """``json.loads(parse_constant=…)`` callback that rejects ``NaN``/``Infinity``.
190
191 CPython's ``json`` module accepts ``NaN``, ``Infinity``, and ``-Infinity``
192 as a non-spec extension by default, and ``json.dumps`` would re-emit them
193 as bare tokens β€” producing stored ``--meta`` blobs that strict JSON
194 parsers (e.g. the future Rust port, MuseHub UI) would reject. Rejecting
195 here keeps the v1 stored form spec-compliant forever.
196 """
197 raise ValueError(
198 f"--meta value {token!r} is not valid JSON "
199 "(NaN/Infinity/-Infinity are not part of the JSON spec and would "
200 "produce stored payloads that strict readers reject)."
201 )
202
203
204 def _validate_meta_payload(raw: str) -> str:
205 """Parse and validate a ``--meta`` JSON string, returning canonical bytes.
206
207 The returned value is a canonical compact JSON string (``sort_keys=True``,
208 no extra whitespace, ``allow_nan=False``) suitable for storage in
209 ``commit.metadata["meta"]``.
210
211 Validation rules (all permanent β€” part of the v1 schema contract):
212
213 1. Raw input length ≀ :data:`_MAX_META_RAW_BYTES` characters (cheap
214 pre-parse cap that bounds ``json.loads`` and recursive-scan cost).
215 2. Must be valid JSON; ``NaN``/``Infinity``/``-Infinity`` are rejected.
216 3. Top-level value must be a JSON object (dict), not an array or scalar.
217 4. All keys must be strings.
218 5. No key at any level may match :data:`_SENSITIVE_KEY_RE` (depth ≀ 8).
219 6. No top-level key may appear in :data:`_RESERVED_META_KEYS` β€” those
220 conflict with CLI-controlled metadata slots and would create two
221 sources of truth for the same logical field in the permanent commit
222 graph.
223 7. Serialised canonical form must be ≀ :data:`_MAX_META_BYTES` bytes.
224
225 Args:
226 raw: The raw string passed to ``--meta`` on the CLI.
227
228 Returns:
229 Canonical compact JSON string ready for storage.
230
231 Raises:
232 ValueError: With a human-readable message describing the first
233 validation failure encountered. Error messages never echo
234 user-supplied values that match the sensitive-key pattern.
235 """
236 if len(raw) > _MAX_META_RAW_BYTES:
237 raise ValueError(
238 f"--meta raw input exceeds the {_MAX_META_RAW_BYTES // 1024} KiB "
239 f"pre-parse cap (length: {len(raw)} chars). "
240 "Reduce whitespace or split the payload."
241 )
242
243 try:
244 payload = json.loads(raw, parse_constant=_reject_nonjson_constant)
245 except json.JSONDecodeError as exc:
246 raise ValueError(
247 f"--meta must be valid JSON: {exc}"
248 ) from exc
249
250 if not isinstance(payload, dict):
251 raise ValueError(
252 f"--meta must be a JSON object (got {type(payload).__name__}). "
253 "Example: --meta '{{\"topic\": \"agents\", \"confidence\": 0.9}}'"
254 )
255
256 for k in payload:
257 if not isinstance(k, str):
258 raise ValueError(
259 f"--meta JSON object keys must be strings (got key of type "
260 f"{type(k).__name__}: {k!r})"
261 )
262
263 reserved_present = sorted(k for k in payload if k in _RESERVED_META_KEYS)
264 if reserved_present:
265 raise ValueError(
266 f"--meta payload contains reserved top-level key(s) "
267 f"{reserved_present!r} that conflict with CLI-controlled "
268 "metadata slot(s) of the same name. Use the dedicated "
269 "--event-type flag for event_type, and do not place 'meta' at "
270 "the top level of your --meta payload."
271 )
272
273 if _has_sensitive_keys(payload):
274 raise ValueError(
275 "--meta payload contains a key matching a sensitive-data pattern "
276 "(api_key, secret, password, token, credential, authorization, "
277 "bearer, private_key). Remove secrets before committing."
278 )
279
280 canonical = json.dumps(
281 payload,
282 sort_keys=True,
283 separators=(",", ":"),
284 ensure_ascii=False,
285 allow_nan=False,
286 )
287 if len(canonical.encode("utf-8")) > _MAX_META_BYTES:
288 raise ValueError(
289 f"--meta payload exceeds the {_MAX_META_BYTES // 1024} KiB size limit "
290 f"(serialised size: {len(canonical.encode('utf-8'))} bytes)."
291 )
292
293 return canonical
294
295
296 def _attestation_path_for_commit(
297 manifest: Manifest,
298 parent_manifest: Manifest,
299 ) -> str:
300 """Pick a representative path to bind into the commit's attestation.
301
302 The ``MuseAttestationProvider.compute_attestation`` interface requires a
303 ``path`` field in ``commit_meta``. For a multi-file commit there is no
304 single canonical path, so we pick deterministically:
305
306 1. The first added or modified path in canonical (sorted) order, OR
307 2. The first path in the new manifest if the commit is purely additive
308 and the parent manifest is empty, OR
309 3. ``""`` (repo-scoped attestation) when the commit changed nothing
310 resolvable β€” used by the inbox-bypass path to short-circuit.
311
312 Determinism matters: this function is part of the attestation ID input
313 via :func:`muse.plugins.knowtation.attestation.compute_attestation_id`,
314 so the same commit on a fresh node MUST produce the same path so the
315 canister rejects the duplicate write with the documented "already exists"
316 error.
317
318 Args:
319 manifest: The new snapshot's manifest (path β†’ object_id).
320 parent_manifest: The parent snapshot's manifest, possibly empty.
321
322 Returns:
323 A vault-relative path string suitable for the provider's
324 ``commit_meta["path"]`` field. Empty string is a valid result for
325 repo-scoped attestations.
326 """
327 added = sorted(set(manifest) - set(parent_manifest))
328 modified = sorted(
329 p for p in set(manifest) & set(parent_manifest)
330 if manifest[p] != parent_manifest[p]
331 )
332 changed = sorted(set(added) | set(modified))
333 if changed:
334 return changed[0]
335 if manifest:
336 return sorted(manifest)[0]
337 return ""
338
339
340 def _resolve_quorum_member_key(
341 handle: str,
342 overrides: dict[str, pathlib.Path],
343 ) -> "Any | None":
344 """Load the Ed25519 private key for a quorum member *handle*.
345
346 Resolution order (first hit wins):
347
348 1. ``overrides[handle]`` (from ``--quorum-key handle=path``).
349 2. ``$MUSE_QUORUM_KEY_<HANDLE>`` env var holding raw PEM bytes.
350 3. ``$MUSE_QUORUM_KEY_PATH_<HANDLE>`` env var holding a PEM path.
351 4. ``~/.muse/keys/<handle>.pem``.
352
353 The handle is uppercased and ``@`` / hyphens are translated to ``_``
354 for env-var lookup so org and member handles map to valid identifier
355 names (env vars cannot start with ``@`` or contain ``-``).
356
357 Args:
358 handle: Member handle (validated by the caller).
359 overrides: Mapping of handle β†’ explicit PEM path from the
360 ``--quorum-key`` flag.
361
362 Returns:
363 The :class:`cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey`
364 for the member, or ``None`` if no key was found.
365
366 Raises:
367 ValueError: If a configured PEM file or env var contains malformed
368 key material. Distinct from "key not found" so the CLI can
369 abort the commit (signature stripping prevention).
370 """
371 from muse.core.keypair import load_private_key_from_pem
372
373 env_key_name = (
374 "MUSE_QUORUM_KEY_"
375 + handle.lstrip("@").upper().replace("-", "_").replace(".", "_")
376 )
377 env_path_name = env_key_name + "_PATH"
378
379 if handle in overrides:
380 path = overrides[handle]
381 try:
382 data = path.read_bytes()
383 except OSError as exc:
384 raise ValueError(
385 f"--quorum-key {handle}={path}: cannot read PEM ({exc})"
386 ) from exc
387 key = load_private_key_from_pem(data)
388 if key is None:
389 raise ValueError(
390 f"--quorum-key {handle}={path}: PEM did not parse as an "
391 "Ed25519 private key"
392 )
393 return key
394
395 pem_env = os.environ.get(env_key_name, "").strip()
396 if pem_env:
397 key = load_private_key_from_pem(pem_env.encode())
398 if key is None:
399 raise ValueError(
400 f"${env_key_name} did not parse as an Ed25519 private key"
401 )
402 return key
403
404 path_env = os.environ.get(env_path_name, "").strip()
405 if path_env:
406 path = pathlib.Path(path_env)
407 try:
408 data = path.read_bytes()
409 except OSError as exc:
410 raise ValueError(
411 f"${env_path_name}={path}: cannot read PEM ({exc})"
412 ) from exc
413 key = load_private_key_from_pem(data)
414 if key is None:
415 raise ValueError(
416 f"${env_path_name}={path}: PEM did not parse as an Ed25519 "
417 "private key"
418 )
419 return key
420
421 default_path = pathlib.Path.home() / ".muse" / "keys" / f"{handle}.pem"
422 if default_path.exists():
423 try:
424 data = default_path.read_bytes()
425 except OSError as exc:
426 raise ValueError(
427 f"~/.muse/keys/{handle}.pem: cannot read PEM ({exc})"
428 ) from exc
429 key = load_private_key_from_pem(data)
430 if key is None:
431 raise ValueError(
432 f"~/.muse/keys/{handle}.pem did not parse as an Ed25519 "
433 "private key"
434 )
435 return key
436
437 return None
438
439
440 def _parse_quorum_key_overrides(raw: list[str] | None) -> dict[str, pathlib.Path]:
441 """Parse ``--quorum-key handle=path`` repeats into a dict.
442
443 Args:
444 raw: List of ``handle=path`` strings, or ``None``.
445
446 Returns:
447 Mapping of handle β†’ path. Empty dict when *raw* is None/empty.
448
449 Raises:
450 ValueError: For malformed entries.
451 """
452 out: dict[str, pathlib.Path] = {}
453 if not raw:
454 return out
455 for entry in raw:
456 if "=" not in entry:
457 raise ValueError(
458 f"--quorum-key entry {entry!r} must be 'handle=/path/to/key.pem'"
459 )
460 handle, path = entry.split("=", 1)
461 handle = handle.strip()
462 path_str = path.strip()
463 if not handle or not path_str:
464 raise ValueError(
465 f"--quorum-key entry {entry!r}: handle and path must be non-empty"
466 )
467 if handle in out:
468 raise ValueError(
469 f"--quorum-key handle {handle!r} specified more than once"
470 )
471 out[handle] = pathlib.Path(path_str).expanduser()
472 return out
473
474
475 def _validate_event_type(kind: str) -> str:
476 """Validate a ``--event-type`` string against the canonical taxonomy.
477
478 Delegates to :func:`muse.plugins.knowtation.events.is_valid_event_kind`
479 when that module is available, otherwise uses the built-in fallback
480 frozenset :data:`_FALLBACK_EVENT_KINDS`.
481
482 Args:
483 kind: The raw event-type string supplied on the CLI.
484
485 Returns:
486 The validated kind string (unchanged).
487
488 Raises:
489 ValueError: If *kind* is not a recognised memory-event kind.
490 """
491 if not _is_valid_event_kind(kind):
492 valid = sorted(_FALLBACK_EVENT_KINDS)
493 raise ValueError(
494 f"--event-type {kind!r} is not a valid memory-event kind. "
495 f"Valid kinds: {', '.join(valid)}"
496 )
497 return kind
498
499
500 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
501 """Register the ``muse commit`` subcommand and its flags."""
502 parser = subparsers.add_parser(
503 "commit",
504 help="Record the current state as a new version.",
505 description=__doc__,
506 formatter_class=argparse.RawDescriptionHelpFormatter,
507 )
508 parser.add_argument(
509 "-m", "--message", default=None,
510 help="Commit message (required unless --allow-empty is set).",
511 )
512 parser.add_argument(
513 "--allow-empty", action="store_true",
514 help="Allow committing with no changes (empty-message commits still warn).",
515 )
516 parser.add_argument(
517 "--dry-run", "-n", action="store_true", dest="dry_run",
518 help=(
519 "Compute snapshot and commit_id without writing anything. "
520 "Exits 0 when changes exist, 1 when the working tree is clean. "
521 "Combine with --json for structured preflight output in agent pipelines."
522 ),
523 )
524 parser.add_argument(
525 "--section", default=None,
526 help="Tag this commit with a section label (verse, chorus, bridge…).",
527 )
528 parser.add_argument(
529 "--track", default=None,
530 help="Tag this commit with an instrument track (drums, bass, keys…).",
531 )
532 parser.add_argument(
533 "--emotion", default=None,
534 help="Attach an emotion label (joyful, melancholic, tense…).",
535 )
536 parser.add_argument(
537 "--author", default=None,
538 help="Override the commit author.",
539 )
540 parser.add_argument(
541 "--agent-id", default=None, dest="agent_id",
542 help="Agent identity string (overrides MUSE_AGENT_ID env var).",
543 )
544 parser.add_argument(
545 "--model-id", default=None, dest="model_id",
546 help="Model identifier for AI agents (overrides MUSE_MODEL_ID env var).",
547 )
548 parser.add_argument(
549 "--toolchain-id", default=None, dest="toolchain_id",
550 help="Toolchain string (overrides MUSE_TOOLCHAIN_ID env var).",
551 )
552 parser.add_argument(
553 "--event-type", default=None, dest="event_type",
554 help=(
555 "Tag this commit with a knowtation memory-event kind "
556 "(search, export, write, import, index, propose, agent_interaction, "
557 "capture, error, session_summary, user, consolidation, "
558 "consolidation_pass, maintenance, insight). "
559 "Stored as metadata[\"event_type\"] on the commit record."
560 ),
561 )
562 parser.add_argument(
563 "--meta", default=None, dest="meta",
564 help=(
565 "Attach structured metadata to this commit as a JSON object string. "
566 "Example: --meta '{\"topic\": \"agents\", \"confidence\": 0.9}'. "
567 "The payload is validated (must be a dict, no sensitive keys, ≀ 64 KiB) "
568 "and stored as metadata[\"meta\"] on the commit record. "
569 "This is a permanent schema decision: the stored shape is part of the "
570 "v1 commit-graph contract."
571 ),
572 )
573 parser.add_argument(
574 "--sign", action="store_true",
575 help="HMAC-sign the commit using the agent's stored key (requires --agent-id or MUSE_AGENT_ID).",
576 )
577 parser.add_argument(
578 "--attest", action="store_true",
579 help=(
580 "Attach an attestation record to this commit using the domain's "
581 "registered MuseAttestationProvider (Phase 7.1). Stored under "
582 "commit.metadata['attestation']. Silently skipped (DEBUG log) "
583 "when no provider is registered, UNLESS the provider's "
584 "air.required=True β€” in which case the commit is aborted with "
585 "exit code 4 (ATTESTATION_REQUIRED)."
586 ),
587 )
588 parser.add_argument(
589 "--attest-config", default=None, dest="attest_config",
590 help=(
591 "Optional path to a YAML config file for the attestation provider. "
592 "Defaults: $MUSE_AIR_CONFIG, then .muse/air.yaml (relative to repo "
593 "root). Ignored when --attest is not set."
594 ),
595 )
596 parser.add_argument(
597 "--quorum-signers", default=None, dest="quorum_signers",
598 help=(
599 "Comma-separated list of additional org member handles that must "
600 "co-sign this commit (Phase 7.7 org quorum). Each member's key "
601 "is loaded from MUSE_QUORUM_KEY_<HANDLE> (PEM bytes) or "
602 "~/.muse/keys/<handle>.pem. Each member signs the SAME "
603 "provenance payload as the primary signer. Use --quorum-org to "
604 "name the org (defaults to --agent-id). Aborts the commit if "
605 "any listed signer's key cannot be loaded β€” signature stripping "
606 "is detected at commit time, not just verify time."
607 ),
608 )
609 parser.add_argument(
610 "--quorum-org", default=None, dest="quorum_org",
611 help=(
612 "Org handle (must start with '@') the quorum signs on behalf of. "
613 "Required when --quorum-signers is set. Stored verbatim in "
614 "commit.metadata['quorum']['org_handle']."
615 ),
616 )
617 parser.add_argument(
618 "--quorum-threshold", default=None, dest="quorum_threshold", type=int,
619 help=(
620 "Quorum threshold to embed in commit metadata (audit only β€” "
621 "verifiers re-fetch the live threshold from the org record). "
622 "Defaults to len(--quorum-signers)."
623 ),
624 )
625 parser.add_argument(
626 "--quorum-key", action="append", default=None, dest="quorum_keys",
627 help=(
628 "Repeatable: explicit override for a member key path "
629 "(format: handle=/path/to/handle.pem). Bypasses both the "
630 "MUSE_QUORUM_KEY_<HANDLE> env var and the ~/.muse/keys/ "
631 "default lookup. Useful in tests and CI."
632 ),
633 )
634 parser.add_argument(
635 "--format", "-f", default="text", dest="fmt",
636 help="Output format: text (default) or json.",
637 )
638 parser.add_argument(
639 "--json", action="store_const", const="json", dest="fmt",
640 help="Shorthand for --format json.",
641 )
642 parser.set_defaults(func=run)
643
644
645 def run(args: argparse.Namespace) -> None:
646 """Record the current state as a new version.
647
648 Agents should pass ``--json`` to receive a machine-readable result::
649
650 {
651 "commit_id": "<sha256>",
652 "branch": "main",
653 "snapshot_id": "<sha256>",
654 "message": "Add verse melody",
655 "parent_commit_id": "<sha256> | null",
656 "parent2_commit_id": "<sha256> | null",
657 "committed_at": "2026-03-21T12:00:00+00:00",
658 "author": "gabriel",
659 "agent_id": "",
660 "sem_ver_bump": "minor",
661 "breaking_changes": [],
662 "files_changed": {"added": 1, "modified": 0, "deleted": 0},
663 "dry_run": false
664 }
665
666 ``--dry-run`` output has the same schema but ``dry_run`` is ``true``
667 and no data is written to disk. The ``commit_id`` is the deterministic
668 ID that *would* be created.
669
670 Exit codes:
671 0 β€” commit created successfully.
672 0 β€” nothing to commit (clean tree, no ``--dry-run``).
673 1 β€” dry-run: working tree is clean (no changes to commit).
674 1 β€” validation error (missing message, unresolved conflicts, empty tree).
675 3 β€” I/O error or repository not found.
676 """
677 message: str | None = args.message
678 allow_empty: bool = args.allow_empty
679 dry_run: bool = args.dry_run
680 section: str | None = args.section
681 track: str | None = args.track
682 emotion: str | None = args.emotion
683 raw_author: str | None = args.author
684 agent_id: str | None = args.agent_id
685 model_id: str | None = args.model_id
686 toolchain_id: str | None = args.toolchain_id
687 sign: bool = args.sign
688 attest: bool = bool(getattr(args, "attest", False))
689 attest_config_path: str | None = getattr(args, "attest_config", None)
690 quorum_signers_raw: str | None = getattr(args, "quorum_signers", None)
691 quorum_org: str | None = getattr(args, "quorum_org", None)
692 quorum_threshold: int | None = getattr(args, "quorum_threshold", None)
693 quorum_key_overrides: list[str] | None = getattr(args, "quorum_keys", None)
694 fmt: str = args.fmt
695 raw_meta: str | None = args.meta
696 raw_event_type: str | None = args.event_type
697
698 if fmt not in ("text", "json"):
699 print(
700 f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.",
701 file=sys.stderr,
702 )
703 raise SystemExit(ExitCode.USER_ERROR)
704
705 # Validate --meta payload early so we surface errors before doing any I/O.
706 validated_meta: str | None = None
707 if raw_meta is not None:
708 try:
709 validated_meta = _validate_meta_payload(raw_meta)
710 except ValueError as exc:
711 print(f"❌ {exc}", file=sys.stderr)
712 raise SystemExit(ExitCode.USER_ERROR) from exc
713
714 # Validate --event-type early for the same reason.
715 validated_event_type: str | None = None
716 if raw_event_type is not None:
717 try:
718 validated_event_type = _validate_event_type(raw_event_type)
719 except ValueError as exc:
720 print(f"❌ {exc}", file=sys.stderr)
721 raise SystemExit(ExitCode.USER_ERROR) from exc
722
723 if message is None and not allow_empty:
724 print("❌ Provide a commit message with -m MESSAGE.", file=sys.stderr)
725 raise SystemExit(ExitCode.USER_ERROR)
726
727 if message is None and allow_empty:
728 logger.warning(
729 "⚠️ --allow-empty used without -m: commit will have an empty message."
730 )
731
732 # Sanitize and cap the author field. An explicit --author override is a
733 # potential impersonation vector (an agent could supply a human's name).
734 # We strip all C0/DEL/C1 control chars and cap at _MAX_FIELD_LEN.
735 # A warning is emitted when the caller explicitly passes --author so the
736 # act is always visible in logs.
737 author: str | None = (
738 sanitize_provenance(raw_author[:_MAX_FIELD_LEN]) if raw_author else None
739 )
740 if raw_author is not None:
741 logger.warning(
742 "⚠️ --author override supplied: %r β€” this is not verified against "
743 "the stored identity and may allow impersonation.",
744 author,
745 )
746
747 root = require_repo()
748
749 # Read merge state before any writes β€” needed for conflict check and
750 # rerere recording later.
751 merge_state = read_merge_state(root)
752 if merge_state is not None and merge_state.conflict_paths:
753 print(
754 "❌ You have unresolved merge conflicts. Resolve them before committing.",
755 file=sys.stderr,
756 )
757 for p in sorted(merge_state.conflict_paths):
758 print(f" both modified: {sanitize_display(p)}", file=sys.stderr)
759 raise SystemExit(ExitCode.USER_ERROR)
760
761 repo_id = read_repo_id(root)
762 branch = read_current_branch(root)
763 parent_id = get_head_commit_id(root, branch)
764
765 plugin = resolve_plugin(root)
766 snap = plugin.snapshot(root)
767 manifest = snap["files"]
768 directories = list(snap.get("directories") or [])
769 if not manifest and not allow_empty:
770 print("⚠️ Nothing tracked β€” working tree is empty.", file=sys.stderr)
771 raise SystemExit(ExitCode.USER_ERROR)
772
773 snapshot_id = compute_snapshot_id(manifest, directories)
774
775 if not allow_empty:
776 head_snapshot = get_head_snapshot_id(root, repo_id, branch)
777 if head_snapshot == snapshot_id:
778 if dry_run:
779 if fmt == "json":
780 print(json.dumps({"dry_run": True, "clean": True, "message": "Nothing to commit, working tree clean"}))
781 else:
782 print("Nothing to commit, working tree clean")
783 raise SystemExit(1)
784 print("Nothing to commit, working tree clean")
785 raise SystemExit(ExitCode.SUCCESS)
786
787 committed_at = datetime.datetime.now(datetime.timezone.utc)
788 parent_ids = [parent_id] if parent_id else []
789
790 # When completing a conflicted merge, include the second parent so that
791 # the merge is recorded as a true two-parent merge commit. This ensures
792 # subsequent merge --dry-run correctly computes the LCA as the resolved
793 # merge commit rather than the pre-merge common ancestor, preventing the
794 # same conflicts from re-appearing on the next merge attempt.
795 merge_parent2: str | None = None
796 if merge_state is not None and merge_state.theirs_commit:
797 merge_parent2 = merge_state.theirs_commit
798 if merge_parent2 not in parent_ids:
799 parent_ids = parent_ids + [merge_parent2]
800
801 commit_id = compute_commit_id(
802 parent_ids=parent_ids,
803 snapshot_id=snapshot_id,
804 message=message or "",
805 committed_at_iso=committed_at.isoformat(),
806 )
807
808 metadata: Metadata = {}
809 if section:
810 metadata["section"] = section
811 if track:
812 metadata["track"] = track
813 if emotion:
814 metadata["emotion"] = emotion
815 if validated_event_type is not None:
816 metadata["event_type"] = validated_event_type
817 if validated_meta is not None:
818 metadata["meta"] = validated_meta
819
820 # Load the parent snapshot manifest once and reuse it for both
821 # structured_delta computation and file-count output. Previously the
822 # manifest was loaded independently in each section β€” two separate
823 # read_snapshot() calls per commit.
824 parent_manifest: Manifest = {}
825 parent_directories: list[str] = []
826 if parent_id is not None:
827 parent_commit_rec = read_commit(root, parent_id)
828 if parent_commit_rec is not None:
829 parent_snap_record = read_snapshot(root, parent_commit_rec.snapshot_id)
830 if parent_snap_record is not None:
831 parent_manifest = dict(parent_snap_record.manifest)
832 parent_directories = list(parent_snap_record.directories)
833
834 # Compute a structured delta against the parent snapshot so muse show
835 # can display note-level changes without reloading blobs.
836 structured_delta: StructuredDelta | None = None
837 sem_ver_bump: SemVerBump = "none"
838 breaking_changes: list[str] = []
839 if parent_id is not None and parent_manifest:
840 domain = read_domain(root)
841 base_snap = SnapshotManifest(files=parent_manifest, domain=domain, directories=parent_directories)
842 try:
843 structured_delta = plugin.diff(base_snap, snap, repo_root=root)
844 except Exception as exc:
845 # plugin.diff() is domain-specific and may fail on unsupported
846 # file types. The commit proceeds without a structured delta;
847 # sem_ver_bump defaults to "none".
848 logger.debug("plugin.diff() failed β€” structured delta omitted: %s", exc)
849 structured_delta = None
850
851 # Classify the structured delta into a semver bump and breaking-change list.
852 if structured_delta is not None:
853 classification = classify_delta(structured_delta, repo_root=root)
854 sem_ver_bump = classification.bump
855 breaking_changes = classification.breaking_addresses
856 structured_delta["sem_ver_bump"] = sem_ver_bump
857 structured_delta["breaking_changes"] = breaking_changes
858
859 # Resolve agent provenance: CLI flags take priority over environment vars.
860 # 1. Truncate to _MAX_FIELD_LEN chars β€” prevents DoS via arbitrarily long values.
861 # 2. Strip all C0/DEL/C1 control characters β€” prevents terminal injection
862 # when provenance fields are rendered in display paths (muse log, muse show,
863 # agent dashboards), log-line splitting, and visual spoofing.
864 resolved_agent_id = sanitize_provenance(
865 (agent_id or os.environ.get("MUSE_AGENT_ID", ""))[:_MAX_FIELD_LEN]
866 )
867 resolved_model_id = sanitize_provenance(
868 (model_id or os.environ.get("MUSE_MODEL_ID", ""))[:_MAX_FIELD_LEN]
869 )
870 resolved_toolchain_id = sanitize_provenance(
871 (toolchain_id or os.environ.get("MUSE_TOOLCHAIN_ID", ""))[:_MAX_FIELD_LEN]
872 )
873 resolved_prompt_hash = sanitize_provenance(
874 os.environ.get("MUSE_PROMPT_HASH", "")[:_MAX_FIELD_LEN]
875 )
876
877 # Compute file-level change counts from the (now single-read) parent manifest.
878 files_added = len(set(manifest) - set(parent_manifest))
879 files_deleted = len(set(parent_manifest) - set(manifest))
880 files_modified = sum(
881 1 for p in set(manifest) & set(parent_manifest)
882 if manifest[p] != parent_manifest[p]
883 )
884
885 # ── Dry-run path β€” no writes beyond this point ────────────────────────────
886 if dry_run:
887 if fmt == "json":
888 print(json.dumps({
889 "dry_run": True,
890 "clean": False,
891 "commit_id": commit_id,
892 "branch": branch,
893 "snapshot_id": snapshot_id,
894 "message": message or "",
895 "parent_commit_id": parent_id,
896 "parent2_commit_id": merge_parent2,
897 "committed_at": committed_at.isoformat(),
898 "author": author or "",
899 "agent_id": resolved_agent_id,
900 "sem_ver_bump": sem_ver_bump,
901 "breaking_changes": breaking_changes,
902 "files_changed": {
903 "added": files_added,
904 "modified": files_modified,
905 "deleted": files_deleted,
906 },
907 }))
908 else:
909 total = files_added + files_deleted + files_modified
910 print(f"[dry-run] [{sanitize_display(branch)} {commit_id[:8]}] {sanitize_display(message or '')}")
911 if total:
912 parts: list[str] = []
913 if files_modified:
914 parts.append(f"{files_modified} modified")
915 if files_added:
916 parts.append(f"{files_added} added")
917 if files_deleted:
918 parts.append(f"{files_deleted} removed")
919 print(f" {total} file{'s' if total != 1 else ''} changed ({', '.join(parts)})")
920 print(" (dry-run: nothing written)")
921 return
922
923 # ── Actual writes ─────────────────────────────────────────────────────────
924 for rel_path, object_id in manifest.items():
925 write_object_from_path(root, object_id, root / rel_path)
926
927 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=manifest, directories=directories))
928
929 signature = ""
930 signer_public_key = ""
931 signer_key_id = ""
932 if sign and resolved_agent_id:
933 from muse.cli.config import get_signing_identity
934 signing = get_signing_identity(root, agent_id=resolved_agent_id)
935 if signing is not None:
936 result = sign_commit_record(
937 commit_id,
938 resolved_agent_id,
939 signing.private_key,
940 author=author or "",
941 model_id=resolved_model_id,
942 toolchain_id=resolved_toolchain_id,
943 prompt_hash=resolved_prompt_hash,
944 committed_at=committed_at.isoformat(),
945 )
946 if result is not None:
947 signature, signer_public_key, signer_key_id = result
948 else:
949 logger.warning(
950 "No signing identity found for agent %r β€” commit will be unsigned. "
951 "Run `muse auth keygen && muse auth register` to set up a keypair.",
952 resolved_agent_id,
953 )
954
955 # ── Attestation (Phase 7.3) ───────────────────────────────────────────────
956 # Runs AFTER commit_id is computed and BEFORE write_commit so the
957 # attestation record can be embedded in commit.metadata["attestation"].
958 # Failures fall into two buckets:
959 # 1. Provider not registered for this domain β†’ silent DEBUG-level skip.
960 # 2. Provider raises AttestationRequiredError β†’ abort the commit before
961 # writing anything to disk; surface ExitCode.ATTESTATION_REQUIRED.
962 if attest:
963 domain = read_domain(root)
964 provider = get_attestation_provider(domain)
965 if provider is None:
966 logger.debug(
967 "commit --attest: no attestation provider registered for "
968 "domain %r β€” skipping (no record attached).",
969 domain,
970 )
971 else:
972 attestation_commit_meta: dict[str, object] = {
973 "action": "write",
974 "path": _attestation_path_for_commit(manifest, parent_manifest),
975 "content_hash": snapshot_id,
976 "timestamp": committed_at.isoformat(),
977 "agent_id": resolved_agent_id,
978 }
979 try:
980 attestation_record = provider.compute_attestation(
981 snapshot_id, attestation_commit_meta
982 )
983 except AttestationRequiredError as exc:
984 print(
985 f"❌ Attestation required but unavailable: {exc}",
986 file=sys.stderr,
987 )
988 raise SystemExit(ExitCode.ATTESTATION_REQUIRED) from exc
989 except AttestationError as exc:
990 logger.warning(
991 "commit --attest: provider raised %s: %s β€” commit proceeding "
992 "without attestation record.",
993 type(exc).__name__, exc,
994 )
995 attestation_record = None
996 if attestation_record is not None:
997 metadata["attestation"] = json.dumps(
998 attestation_record,
999 sort_keys=True,
1000 separators=(",", ":"),
1001 ensure_ascii=False,
1002 )
1003
1004 # ── Quorum co-signing (Phase 7.7) ─────────────────────────────────────────
1005 # Each member signs the SAME provenance_payload that the primary signer
1006 # signed. Failure to load a listed signer's key aborts the commit with
1007 # ExitCode.USER_ERROR β€” this is the "signature stripping" defence: an
1008 # attacker cannot drop a member from the list at sign time and pretend
1009 # the missing sig was "optional".
1010 if quorum_signers_raw:
1011 from muse.core.provenance import (
1012 encode_public_key as _qencode_pubkey,
1013 sign_commit_ed25519 as _qsign,
1014 )
1015 from muse.plugins.knowtation.quorum import (
1016 build_quorum_metadata,
1017 is_valid_handle as _is_valid_quorum_handle,
1018 )
1019
1020 signer_handles = [h.strip() for h in quorum_signers_raw.split(",") if h.strip()]
1021 if not signer_handles:
1022 print(
1023 "❌ --quorum-signers was empty after parsing; supply at least one handle.",
1024 file=sys.stderr,
1025 )
1026 raise SystemExit(ExitCode.USER_ERROR)
1027 for h in signer_handles:
1028 if not _is_valid_quorum_handle(h):
1029 print(
1030 f"❌ --quorum-signers: {h!r} is not a valid handle.",
1031 file=sys.stderr,
1032 )
1033 raise SystemExit(ExitCode.USER_ERROR)
1034 if len(set(signer_handles)) != len(signer_handles):
1035 print(
1036 "❌ --quorum-signers contains duplicates; each handle must appear once.",
1037 file=sys.stderr,
1038 )
1039 raise SystemExit(ExitCode.USER_ERROR)
1040
1041 if not quorum_org or not quorum_org.startswith("@"):
1042 print(
1043 "❌ --quorum-signers requires --quorum-org @<org-handle>.",
1044 file=sys.stderr,
1045 )
1046 raise SystemExit(ExitCode.USER_ERROR)
1047 if not _is_valid_quorum_handle(quorum_org):
1048 print(
1049 f"❌ --quorum-org {quorum_org!r} is not a valid handle.",
1050 file=sys.stderr,
1051 )
1052 raise SystemExit(ExitCode.USER_ERROR)
1053
1054 embedded_threshold = (
1055 quorum_threshold if quorum_threshold is not None else len(signer_handles)
1056 )
1057 if embedded_threshold < 1 or embedded_threshold > len(signer_handles):
1058 print(
1059 f"❌ --quorum-threshold {embedded_threshold} must be between 1 and "
1060 f"{len(signer_handles)} (signer count).",
1061 file=sys.stderr,
1062 )
1063 raise SystemExit(ExitCode.USER_ERROR)
1064
1065 try:
1066 overrides = _parse_quorum_key_overrides(quorum_key_overrides)
1067 except ValueError as exc:
1068 print(f"❌ {exc}", file=sys.stderr)
1069 raise SystemExit(ExitCode.USER_ERROR) from exc
1070
1071 # Recompute the SAME payload the primary signer signs β€” guarantees
1072 # member sigs cover the identical bytes (replay defence).
1073 from muse.core.provenance import provenance_payload as _qpayload
1074 quorum_payload = _qpayload(
1075 commit_id,
1076 author=author or "",
1077 agent_id=resolved_agent_id,
1078 model_id=resolved_model_id,
1079 toolchain_id=resolved_toolchain_id,
1080 prompt_hash=resolved_prompt_hash,
1081 committed_at=committed_at.isoformat(),
1082 )
1083
1084 member_entries: list[dict[str, str]] = []
1085 for h in signer_handles:
1086 try:
1087 priv_key = _resolve_quorum_member_key(h, overrides)
1088 except ValueError as exc:
1089 print(
1090 f"❌ Quorum signer {h!r} key invalid: {exc}",
1091 file=sys.stderr,
1092 )
1093 raise SystemExit(ExitCode.USER_ERROR) from exc
1094 if priv_key is None:
1095 print(
1096 f"❌ No key found for quorum signer {h!r}. Set "
1097 f"MUSE_QUORUM_KEY_<HANDLE>, --quorum-key {h}=path, or "
1098 f"place ~/.muse/keys/{h}.pem. Aborting commit "
1099 "(signature stripping prevention).",
1100 file=sys.stderr,
1101 )
1102 raise SystemExit(ExitCode.USER_ERROR)
1103 sig = _qsign(quorum_payload, priv_key)
1104 _, pub_b64 = _qencode_pubkey(priv_key)
1105 member_entries.append({
1106 "handle": h,
1107 "public_key": pub_b64,
1108 "signature": sig,
1109 })
1110
1111 try:
1112 quorum_blob = build_quorum_metadata(
1113 quorum_org, embedded_threshold, member_entries
1114 )
1115 except ValueError as exc:
1116 print(f"❌ Failed to build quorum metadata: {exc}", file=sys.stderr)
1117 raise SystemExit(ExitCode.USER_ERROR) from exc
1118 metadata["quorum"] = json.dumps(
1119 quorum_blob, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
1120 )
1121
1122 write_commit(root, CommitRecord(
1123 commit_id=commit_id,
1124 repo_id=repo_id,
1125 branch=branch,
1126 snapshot_id=snapshot_id,
1127 message=message or "",
1128 committed_at=committed_at,
1129 parent_commit_id=parent_id,
1130 parent2_commit_id=merge_parent2,
1131 author=author or "",
1132 metadata=metadata,
1133 structured_delta=structured_delta,
1134 sem_ver_bump=sem_ver_bump,
1135 breaking_changes=breaking_changes,
1136 agent_id=resolved_agent_id,
1137 model_id=resolved_model_id,
1138 toolchain_id=resolved_toolchain_id,
1139 prompt_hash=resolved_prompt_hash,
1140 signature=signature,
1141 signer_public_key=signer_public_key,
1142 signer_key_id=signer_key_id,
1143 ))
1144
1145 write_branch_ref(root, branch, commit_id)
1146
1147 # Clear the stage after a successful commit so the next muse commit
1148 # returns to full-snapshot mode unless the user runs muse code add again.
1149 if isinstance(plugin, StagePlugin):
1150 plugin.clear_stage(root)
1151
1152 append_reflog(
1153 root,
1154 branch,
1155 old_id=parent_id,
1156 new_id=commit_id,
1157 author=author or "unknown",
1158 operation=f"commit: {sanitize_display(message or '(no message)')}",
1159 )
1160
1161 # If this commit completed a conflicted merge, record how each conflict
1162 # was resolved so rerere can replay it on future identical conflicts.
1163 if merge_state is not None and merge_state.ours_commit and merge_state.theirs_commit:
1164 def _manifest_for(cid: str) -> Manifest:
1165 cr = read_commit(root, cid)
1166 if cr is None:
1167 return {}
1168 snap_rec = read_snapshot(root, cr.snapshot_id)
1169 return snap_rec.manifest if snap_rec else {}
1170
1171 ours_manifest = _manifest_for(merge_state.ours_commit)
1172 theirs_manifest = _manifest_for(merge_state.theirs_commit)
1173 domain = read_domain(root)
1174 rerere_record_resolutions(
1175 root,
1176 list(merge_state.conflict_paths),
1177 ours_manifest,
1178 theirs_manifest,
1179 manifest,
1180 domain,
1181 plugin,
1182 )
1183 clear_merge_state(root)
1184
1185 # ── Output ────────────────────────────────────────────────────────────────
1186 if fmt == "json":
1187 print(json.dumps({
1188 "dry_run": False,
1189 "commit_id": commit_id,
1190 "branch": branch,
1191 "snapshot_id": snapshot_id,
1192 "message": message or "",
1193 "parent_commit_id": parent_id,
1194 "parent2_commit_id": merge_parent2,
1195 "committed_at": committed_at.isoformat(),
1196 "author": author or "",
1197 "agent_id": resolved_agent_id,
1198 "sem_ver_bump": sem_ver_bump,
1199 "breaking_changes": breaking_changes,
1200 "files_changed": {
1201 "added": files_added,
1202 "modified": files_modified,
1203 "deleted": files_deleted,
1204 },
1205 }))
1206 else:
1207 print(f"[{sanitize_display(branch)} {commit_id[:8]}] {sanitize_display(message or '')}")
1208 total_files = files_added + files_deleted + files_modified
1209 if total_files:
1210 stat_parts: list[str] = []
1211 if files_modified:
1212 stat_parts.append(f"{files_modified} modified")
1213 if files_added:
1214 stat_parts.append(f"{files_added} added")
1215 if files_deleted:
1216 stat_parts.append(f"{files_deleted} removed")
1217 print(f" {total_files} file{'s' if total_files != 1 else ''} changed ({', '.join(stat_parts)})")