"""``muse commit`` — record the current workspace state as a new version. Algorithm --------- 1. Resolve repo root (walk up for ``.muse/``). 2. Read ``repo_id`` from ``.muse/repo.json`` and the current branch from ``.muse/HEAD``. 3. Invoke ``plugin.snapshot(root)`` to collect the workspace manifest (domain-specific; the code plugin walks tracked source files). 4. If the computed ``snapshot_id`` matches HEAD → "nothing to commit". 5. Compute a deterministic ``commit_id`` = SHA-256 of (parents | snapshot | message | timestamp). 6. Write content-addressed blob objects to ``.muse/objects/``. 7. Write snapshot JSON to ``.muse/snapshots/.json``. 8. Write commit JSON to ``.muse/commits/.json``. 9. Advance ``.muse/refs/heads/`` to the new ``commit_id``. ``--dry-run`` Perform steps 1–5 (compute snapshot and commit_id) without writing anything. Exits 0 when changes are present, 1 when the tree is clean. Combine with ``--json`` for structured preflight output in agent pipelines. ``--meta`` Attach an arbitrary JSON object to the commit as structured metadata. Accepts a JSON-encoded dict string. The payload is validated (must be a dict, no sensitive key patterns, ≤ 64 KiB canonical, no NaN/Infinity) and stored verbatim under the ``"meta"`` key of the commit's metadata dict. This is a *permanent* schema decision: once written to the commit graph the shape is read by all downstream tooling that walks ``commit.metadata["meta"]``. Reserved top-level keys: ``meta`` and ``event_type`` inside the ``--meta`` payload conflict with the CLI-controlled metadata slots of the same name and would create two sources of truth for the same logical field in the permanent commit graph. Including either as a top-level key in ``--meta`` is a hard error — use the dedicated ``--event-type`` flag instead, and do not nest ``"meta"`` at the top level of your payload. ``--event-type`` Tag the commit with a knowtation memory-event kind string. Validated against the canonical 15-kind taxonomy defined in ``muse/plugins/knowtation/events.py`` (falls back to a built-in frozenset when that module is not yet installed, e.g. during bootstrapping). Stored as ``commit.metadata["event_type"]``. Exit codes:: 0 — commit created, OR nothing to commit (clean tree) 1 — validation error (no message, unresolved conflicts, clean tree with --dry-run) 3 — I/O error """ from __future__ import annotations import argparse import datetime import json import logging import os import pathlib import re import sys from typing import Any from muse.core.attestation import ( AttestationError, AttestationRequiredError, get_attestation_provider, ) from muse.core.errors import ExitCode from muse.core.merge_engine import clear_merge_state, read_merge_state from muse.core.object_store import write_object_from_path from muse.core.provenance import ( make_agent_identity, provenance_payload, sign_commit_record, ) from muse.core.reflog import append_reflog from muse.core.repo import read_repo_id, require_repo from muse.core.rerere import record_resolutions as rerere_record_resolutions from muse.core.snapshot import compute_commit_id, compute_snapshot_id from muse.core.store import ( Metadata, Manifest, CommitRecord, SnapshotRecord, get_head_commit_id, get_head_snapshot_id, read_commit, read_current_branch, read_snapshot, write_branch_ref, write_commit, write_snapshot, ) from muse.core.validation import sanitize_display, sanitize_provenance, validate_branch_name from muse.core.semver_classifier import classify_delta from muse.domain import SemVerBump, SnapshotManifest, StagePlugin, StructuredDelta from muse.plugins.registry import read_domain, resolve_plugin logger = logging.getLogger(__name__) # Maximum length for author and agent-provenance fields. # Prevents DoS via arbitrarily long values and keeps commit records bounded. _MAX_FIELD_LEN = 256 # ── --meta payload validation constants ────────────────────────────────────── #: Maximum size in bytes of the serialised ``--meta`` JSON payload. 64 KiB is #: generous for commit annotations while bounding memory and storage growth. _MAX_META_BYTES: int = 65_536 #: Pre-parse hard cap on the *raw* ``--meta`` argument string (in characters, #: which is a lower bound on UTF-8 byte length). Set to twice the canonical #: byte cap so that pretty-printed (whitespace-padded) JSON whose canonical #: form fits ≤ 64 KiB is still accepted, while bounding the worst-case #: ``json.loads`` and ``_has_sensitive_keys`` cost on adversarial input. This #: defends future ``--meta @file`` or stdin paths even though the current CLI #: route is already bounded by the OS argv limit (~256 KiB on Darwin/Linux). _MAX_META_RAW_BYTES: int = _MAX_META_BYTES * 2 #: Top-level keys in the ``--meta`` payload that conflict with CLI-controlled #: metadata slots of the same name. Using either as a top-level key in #: ``--meta`` would create two sources of truth for the same logical field in #: the permanent commit graph (e.g. ``metadata["event_type"]`` *and* #: ``json.loads(metadata["meta"])["event_type"]``), so it is rejected as a #: hard error rather than silently shadowed with a warning. _RESERVED_META_KEYS: frozenset[str] = frozenset({"event_type", "meta"}) #: Compiled regex matching sensitive-data key patterns. Mirrors the #: ``SENSITIVE_VALUE`` pattern in ``knowtation/lib/memory-event.mjs`` so both #: sides of the Muse/Knowtation boundary reject the same class of key names. _SENSITIVE_KEY_RE: re.Pattern[str] = re.compile( r"(api[_-]?key|secret|password|token|credential|authorization|bearer|private[_-]?key)", re.IGNORECASE, ) # ── --event-type validation ─────────────────────────────────────────────────── #: The 15 canonical memory-event kinds mirrored from #: ``knowtation/lib/memory-event.mjs``. Used as a fallback when #: ``muse.plugins.knowtation.events`` is not yet installed. _FALLBACK_EVENT_KINDS: frozenset[str] = frozenset({ "search", "export", "write", "import", "index", "propose", "agent_interaction", "capture", "error", "session_summary", "user", "consolidation", "consolidation_pass", "maintenance", "insight", }) # Try to import the canonical validator from the events module (Phase 4.1). # Fall back to the inline frozenset during bootstrapping / before that module # is committed, so that Phase 4.2 tests pass independently of Phase 4.1. try: from muse.plugins.knowtation.events import is_valid_event_kind as _is_valid_event_kind # type: ignore[import-not-found] except ImportError: # pragma: no cover — removed once Phase 4.1 is committed def _is_valid_event_kind(kind: str) -> bool: # type: ignore[misc] """Return True iff *kind* is a valid memory-event kind (fallback path).""" return kind in _FALLBACK_EVENT_KINDS def _has_sensitive_keys(obj: Any, depth: int = 0) -> bool: """Return True if *obj* contains any key matching :data:`_SENSITIVE_KEY_RE`. Mirrors the ``hasSensitiveKeys`` function in ``knowtation/lib/memory-event.mjs``: recursive scan up to depth 8; beyond that depth the scan stops and returns False (consistent with the JS implementation). Args: obj: Any Python value (dict, list, scalar, …). depth: Current recursion depth (callers pass 0). Returns: ``True`` if a sensitive key pattern is found at or below *depth* ≤ 8; ``False`` otherwise. """ if depth > 8 or obj is None: return False if isinstance(obj, list): return any(_has_sensitive_keys(v, depth + 1) for v in obj) if isinstance(obj, dict): for k, v in obj.items(): if isinstance(k, str) and _SENSITIVE_KEY_RE.search(k): return True if _has_sensitive_keys(v, depth + 1): return True return False def _reject_nonjson_constant(token: str) -> Any: """``json.loads(parse_constant=…)`` callback that rejects ``NaN``/``Infinity``. CPython's ``json`` module accepts ``NaN``, ``Infinity``, and ``-Infinity`` as a non-spec extension by default, and ``json.dumps`` would re-emit them as bare tokens — producing stored ``--meta`` blobs that strict JSON parsers (e.g. the future Rust port, MuseHub UI) would reject. Rejecting here keeps the v1 stored form spec-compliant forever. """ raise ValueError( f"--meta value {token!r} is not valid JSON " "(NaN/Infinity/-Infinity are not part of the JSON spec and would " "produce stored payloads that strict readers reject)." ) def _validate_meta_payload(raw: str) -> str: """Parse and validate a ``--meta`` JSON string, returning canonical bytes. The returned value is a canonical compact JSON string (``sort_keys=True``, no extra whitespace, ``allow_nan=False``) suitable for storage in ``commit.metadata["meta"]``. Validation rules (all permanent — part of the v1 schema contract): 1. Raw input length ≤ :data:`_MAX_META_RAW_BYTES` characters (cheap pre-parse cap that bounds ``json.loads`` and recursive-scan cost). 2. Must be valid JSON; ``NaN``/``Infinity``/``-Infinity`` are rejected. 3. Top-level value must be a JSON object (dict), not an array or scalar. 4. All keys must be strings. 5. No key at any level may match :data:`_SENSITIVE_KEY_RE` (depth ≤ 8). 6. No top-level key may appear in :data:`_RESERVED_META_KEYS` — those conflict with CLI-controlled metadata slots and would create two sources of truth for the same logical field in the permanent commit graph. 7. Serialised canonical form must be ≤ :data:`_MAX_META_BYTES` bytes. Args: raw: The raw string passed to ``--meta`` on the CLI. Returns: Canonical compact JSON string ready for storage. Raises: ValueError: With a human-readable message describing the first validation failure encountered. Error messages never echo user-supplied values that match the sensitive-key pattern. """ if len(raw) > _MAX_META_RAW_BYTES: raise ValueError( f"--meta raw input exceeds the {_MAX_META_RAW_BYTES // 1024} KiB " f"pre-parse cap (length: {len(raw)} chars). " "Reduce whitespace or split the payload." ) try: payload = json.loads(raw, parse_constant=_reject_nonjson_constant) except json.JSONDecodeError as exc: raise ValueError( f"--meta must be valid JSON: {exc}" ) from exc if not isinstance(payload, dict): raise ValueError( f"--meta must be a JSON object (got {type(payload).__name__}). " "Example: --meta '{{\"topic\": \"agents\", \"confidence\": 0.9}}'" ) for k in payload: if not isinstance(k, str): raise ValueError( f"--meta JSON object keys must be strings (got key of type " f"{type(k).__name__}: {k!r})" ) reserved_present = sorted(k for k in payload if k in _RESERVED_META_KEYS) if reserved_present: raise ValueError( f"--meta payload contains reserved top-level key(s) " f"{reserved_present!r} that conflict with CLI-controlled " "metadata slot(s) of the same name. Use the dedicated " "--event-type flag for event_type, and do not place 'meta' at " "the top level of your --meta payload." ) if _has_sensitive_keys(payload): raise ValueError( "--meta payload contains a key matching a sensitive-data pattern " "(api_key, secret, password, token, credential, authorization, " "bearer, private_key). Remove secrets before committing." ) canonical = json.dumps( payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False, ) if len(canonical.encode("utf-8")) > _MAX_META_BYTES: raise ValueError( f"--meta payload exceeds the {_MAX_META_BYTES // 1024} KiB size limit " f"(serialised size: {len(canonical.encode('utf-8'))} bytes)." ) return canonical def _attestation_path_for_commit( manifest: Manifest, parent_manifest: Manifest, ) -> str: """Pick a representative path to bind into the commit's attestation. The ``MuseAttestationProvider.compute_attestation`` interface requires a ``path`` field in ``commit_meta``. For a multi-file commit there is no single canonical path, so we pick deterministically: 1. The first added or modified path in canonical (sorted) order, OR 2. The first path in the new manifest if the commit is purely additive and the parent manifest is empty, OR 3. ``""`` (repo-scoped attestation) when the commit changed nothing resolvable — used by the inbox-bypass path to short-circuit. Determinism matters: this function is part of the attestation ID input via :func:`muse.plugins.knowtation.attestation.compute_attestation_id`, so the same commit on a fresh node MUST produce the same path so the canister rejects the duplicate write with the documented "already exists" error. Args: manifest: The new snapshot's manifest (path → object_id). parent_manifest: The parent snapshot's manifest, possibly empty. Returns: A vault-relative path string suitable for the provider's ``commit_meta["path"]`` field. Empty string is a valid result for repo-scoped attestations. """ added = sorted(set(manifest) - set(parent_manifest)) modified = sorted( p for p in set(manifest) & set(parent_manifest) if manifest[p] != parent_manifest[p] ) changed = sorted(set(added) | set(modified)) if changed: return changed[0] if manifest: return sorted(manifest)[0] return "" def _resolve_quorum_member_key( handle: str, overrides: dict[str, pathlib.Path], ) -> "Any | None": """Load the Ed25519 private key for a quorum member *handle*. Resolution order (first hit wins): 1. ``overrides[handle]`` (from ``--quorum-key handle=path``). 2. ``$MUSE_QUORUM_KEY_`` env var holding raw PEM bytes. 3. ``$MUSE_QUORUM_KEY_PATH_`` env var holding a PEM path. 4. ``~/.muse/keys/.pem``. The handle is uppercased and ``@`` / hyphens are translated to ``_`` for env-var lookup so org and member handles map to valid identifier names (env vars cannot start with ``@`` or contain ``-``). Args: handle: Member handle (validated by the caller). overrides: Mapping of handle → explicit PEM path from the ``--quorum-key`` flag. Returns: The :class:`cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey` for the member, or ``None`` if no key was found. Raises: ValueError: If a configured PEM file or env var contains malformed key material. Distinct from "key not found" so the CLI can abort the commit (signature stripping prevention). """ from muse.core.keypair import load_private_key_from_pem env_key_name = ( "MUSE_QUORUM_KEY_" + handle.lstrip("@").upper().replace("-", "_").replace(".", "_") ) env_path_name = env_key_name + "_PATH" if handle in overrides: path = overrides[handle] try: data = path.read_bytes() except OSError as exc: raise ValueError( f"--quorum-key {handle}={path}: cannot read PEM ({exc})" ) from exc key = load_private_key_from_pem(data) if key is None: raise ValueError( f"--quorum-key {handle}={path}: PEM did not parse as an " "Ed25519 private key" ) return key pem_env = os.environ.get(env_key_name, "").strip() if pem_env: key = load_private_key_from_pem(pem_env.encode()) if key is None: raise ValueError( f"${env_key_name} did not parse as an Ed25519 private key" ) return key path_env = os.environ.get(env_path_name, "").strip() if path_env: path = pathlib.Path(path_env) try: data = path.read_bytes() except OSError as exc: raise ValueError( f"${env_path_name}={path}: cannot read PEM ({exc})" ) from exc key = load_private_key_from_pem(data) if key is None: raise ValueError( f"${env_path_name}={path}: PEM did not parse as an Ed25519 " "private key" ) return key default_path = pathlib.Path.home() / ".muse" / "keys" / f"{handle}.pem" if default_path.exists(): try: data = default_path.read_bytes() except OSError as exc: raise ValueError( f"~/.muse/keys/{handle}.pem: cannot read PEM ({exc})" ) from exc key = load_private_key_from_pem(data) if key is None: raise ValueError( f"~/.muse/keys/{handle}.pem did not parse as an Ed25519 " "private key" ) return key return None def _parse_quorum_key_overrides(raw: list[str] | None) -> dict[str, pathlib.Path]: """Parse ``--quorum-key handle=path`` repeats into a dict. Args: raw: List of ``handle=path`` strings, or ``None``. Returns: Mapping of handle → path. Empty dict when *raw* is None/empty. Raises: ValueError: For malformed entries. """ out: dict[str, pathlib.Path] = {} if not raw: return out for entry in raw: if "=" not in entry: raise ValueError( f"--quorum-key entry {entry!r} must be 'handle=/path/to/key.pem'" ) handle, path = entry.split("=", 1) handle = handle.strip() path_str = path.strip() if not handle or not path_str: raise ValueError( f"--quorum-key entry {entry!r}: handle and path must be non-empty" ) if handle in out: raise ValueError( f"--quorum-key handle {handle!r} specified more than once" ) out[handle] = pathlib.Path(path_str).expanduser() return out def _validate_event_type(kind: str) -> str: """Validate a ``--event-type`` string against the canonical taxonomy. Delegates to :func:`muse.plugins.knowtation.events.is_valid_event_kind` when that module is available, otherwise uses the built-in fallback frozenset :data:`_FALLBACK_EVENT_KINDS`. Args: kind: The raw event-type string supplied on the CLI. Returns: The validated kind string (unchanged). Raises: ValueError: If *kind* is not a recognised memory-event kind. """ if not _is_valid_event_kind(kind): valid = sorted(_FALLBACK_EVENT_KINDS) raise ValueError( f"--event-type {kind!r} is not a valid memory-event kind. " f"Valid kinds: {', '.join(valid)}" ) return kind def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the ``muse commit`` subcommand and its flags.""" parser = subparsers.add_parser( "commit", help="Record the current state as a new version.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "-m", "--message", default=None, help="Commit message (required unless --allow-empty is set).", ) parser.add_argument( "--allow-empty", action="store_true", help="Allow committing with no changes (empty-message commits still warn).", ) parser.add_argument( "--dry-run", "-n", action="store_true", dest="dry_run", help=( "Compute snapshot and commit_id without writing anything. " "Exits 0 when changes exist, 1 when the working tree is clean. " "Combine with --json for structured preflight output in agent pipelines." ), ) parser.add_argument( "--section", default=None, help="Tag this commit with a section label (verse, chorus, bridge…).", ) parser.add_argument( "--track", default=None, help="Tag this commit with an instrument track (drums, bass, keys…).", ) parser.add_argument( "--emotion", default=None, help="Attach an emotion label (joyful, melancholic, tense…).", ) parser.add_argument( "--author", default=None, help="Override the commit author.", ) parser.add_argument( "--agent-id", default=None, dest="agent_id", help="Agent identity string (overrides MUSE_AGENT_ID env var).", ) parser.add_argument( "--model-id", default=None, dest="model_id", help="Model identifier for AI agents (overrides MUSE_MODEL_ID env var).", ) parser.add_argument( "--toolchain-id", default=None, dest="toolchain_id", help="Toolchain string (overrides MUSE_TOOLCHAIN_ID env var).", ) parser.add_argument( "--event-type", default=None, dest="event_type", help=( "Tag this commit with a knowtation memory-event kind " "(search, export, write, import, index, propose, agent_interaction, " "capture, error, session_summary, user, consolidation, " "consolidation_pass, maintenance, insight). " "Stored as metadata[\"event_type\"] on the commit record." ), ) parser.add_argument( "--meta", default=None, dest="meta", help=( "Attach structured metadata to this commit as a JSON object string. " "Example: --meta '{\"topic\": \"agents\", \"confidence\": 0.9}'. " "The payload is validated (must be a dict, no sensitive keys, ≤ 64 KiB) " "and stored as metadata[\"meta\"] on the commit record. " "This is a permanent schema decision: the stored shape is part of the " "v1 commit-graph contract." ), ) parser.add_argument( "--sign", action="store_true", help="HMAC-sign the commit using the agent's stored key (requires --agent-id or MUSE_AGENT_ID).", ) parser.add_argument( "--attest", action="store_true", help=( "Attach an attestation record to this commit using the domain's " "registered MuseAttestationProvider (Phase 7.1). Stored under " "commit.metadata['attestation']. Silently skipped (DEBUG log) " "when no provider is registered, UNLESS the provider's " "air.required=True — in which case the commit is aborted with " "exit code 4 (ATTESTATION_REQUIRED)." ), ) parser.add_argument( "--attest-config", default=None, dest="attest_config", help=( "Optional path to a YAML config file for the attestation provider. " "Defaults: $MUSE_AIR_CONFIG, then .muse/air.yaml (relative to repo " "root). Ignored when --attest is not set." ), ) parser.add_argument( "--quorum-signers", default=None, dest="quorum_signers", help=( "Comma-separated list of additional org member handles that must " "co-sign this commit (Phase 7.7 org quorum). Each member's key " "is loaded from MUSE_QUORUM_KEY_ (PEM bytes) or " "~/.muse/keys/.pem. Each member signs the SAME " "provenance payload as the primary signer. Use --quorum-org to " "name the org (defaults to --agent-id). Aborts the commit if " "any listed signer's key cannot be loaded — signature stripping " "is detected at commit time, not just verify time." ), ) parser.add_argument( "--quorum-org", default=None, dest="quorum_org", help=( "Org handle (must start with '@') the quorum signs on behalf of. " "Required when --quorum-signers is set. Stored verbatim in " "commit.metadata['quorum']['org_handle']." ), ) parser.add_argument( "--quorum-threshold", default=None, dest="quorum_threshold", type=int, help=( "Quorum threshold to embed in commit metadata (audit only — " "verifiers re-fetch the live threshold from the org record). " "Defaults to len(--quorum-signers)." ), ) parser.add_argument( "--quorum-key", action="append", default=None, dest="quorum_keys", help=( "Repeatable: explicit override for a member key path " "(format: handle=/path/to/handle.pem). Bypasses both the " "MUSE_QUORUM_KEY_ env var and the ~/.muse/keys/ " "default lookup. Useful in tests and CI." ), ) parser.add_argument( "--format", "-f", default="text", dest="fmt", help="Output format: text (default) or json.", ) parser.add_argument( "--json", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.", ) parser.set_defaults(func=run) def run(args: argparse.Namespace) -> None: """Record the current state as a new version. Agents should pass ``--json`` to receive a machine-readable result:: { "commit_id": "", "branch": "main", "snapshot_id": "", "message": "Add verse melody", "parent_commit_id": " | null", "parent2_commit_id": " | null", "committed_at": "2026-03-21T12:00:00+00:00", "author": "gabriel", "agent_id": "", "sem_ver_bump": "minor", "breaking_changes": [], "files_changed": {"added": 1, "modified": 0, "deleted": 0}, "dry_run": false } ``--dry-run`` output has the same schema but ``dry_run`` is ``true`` and no data is written to disk. The ``commit_id`` is the deterministic ID that *would* be created. Exit codes: 0 — commit created successfully. 0 — nothing to commit (clean tree, no ``--dry-run``). 1 — dry-run: working tree is clean (no changes to commit). 1 — validation error (missing message, unresolved conflicts, empty tree). 3 — I/O error or repository not found. """ message: str | None = args.message allow_empty: bool = args.allow_empty dry_run: bool = args.dry_run section: str | None = args.section track: str | None = args.track emotion: str | None = args.emotion raw_author: str | None = args.author agent_id: str | None = args.agent_id model_id: str | None = args.model_id toolchain_id: str | None = args.toolchain_id sign: bool = args.sign attest: bool = bool(getattr(args, "attest", False)) attest_config_path: str | None = getattr(args, "attest_config", None) quorum_signers_raw: str | None = getattr(args, "quorum_signers", None) quorum_org: str | None = getattr(args, "quorum_org", None) quorum_threshold: int | None = getattr(args, "quorum_threshold", None) quorum_key_overrides: list[str] | None = getattr(args, "quorum_keys", None) fmt: str = args.fmt raw_meta: str | None = args.meta raw_event_type: str | None = args.event_type if fmt not in ("text", "json"): print( f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) # Validate --meta payload early so we surface errors before doing any I/O. validated_meta: str | None = None if raw_meta is not None: try: validated_meta = _validate_meta_payload(raw_meta) except ValueError as exc: print(f"❌ {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) from exc # Validate --event-type early for the same reason. validated_event_type: str | None = None if raw_event_type is not None: try: validated_event_type = _validate_event_type(raw_event_type) except ValueError as exc: print(f"❌ {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) from exc if message is None and not allow_empty: print("❌ Provide a commit message with -m MESSAGE.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if message is None and allow_empty: logger.warning( "⚠️ --allow-empty used without -m: commit will have an empty message." ) # Sanitize and cap the author field. An explicit --author override is a # potential impersonation vector (an agent could supply a human's name). # We strip all C0/DEL/C1 control chars and cap at _MAX_FIELD_LEN. # A warning is emitted when the caller explicitly passes --author so the # act is always visible in logs. author: str | None = ( sanitize_provenance(raw_author[:_MAX_FIELD_LEN]) if raw_author else None ) if raw_author is not None: logger.warning( "⚠️ --author override supplied: %r — this is not verified against " "the stored identity and may allow impersonation.", author, ) root = require_repo() # Read merge state before any writes — needed for conflict check and # rerere recording later. merge_state = read_merge_state(root) if merge_state is not None and merge_state.conflict_paths: print( "❌ You have unresolved merge conflicts. Resolve them before committing.", file=sys.stderr, ) for p in sorted(merge_state.conflict_paths): print(f" both modified: {sanitize_display(p)}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) repo_id = read_repo_id(root) branch = read_current_branch(root) parent_id = get_head_commit_id(root, branch) plugin = resolve_plugin(root) snap = plugin.snapshot(root) manifest = snap["files"] directories = list(snap.get("directories") or []) if not manifest and not allow_empty: print("⚠️ Nothing tracked — working tree is empty.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) snapshot_id = compute_snapshot_id(manifest, directories) if not allow_empty: head_snapshot = get_head_snapshot_id(root, repo_id, branch) if head_snapshot == snapshot_id: if dry_run: if fmt == "json": print(json.dumps({"dry_run": True, "clean": True, "message": "Nothing to commit, working tree clean"})) else: print("Nothing to commit, working tree clean") raise SystemExit(1) print("Nothing to commit, working tree clean") raise SystemExit(ExitCode.SUCCESS) committed_at = datetime.datetime.now(datetime.timezone.utc) parent_ids = [parent_id] if parent_id else [] # When completing a conflicted merge, include the second parent so that # the merge is recorded as a true two-parent merge commit. This ensures # subsequent merge --dry-run correctly computes the LCA as the resolved # merge commit rather than the pre-merge common ancestor, preventing the # same conflicts from re-appearing on the next merge attempt. merge_parent2: str | None = None if merge_state is not None and merge_state.theirs_commit: merge_parent2 = merge_state.theirs_commit if merge_parent2 not in parent_ids: parent_ids = parent_ids + [merge_parent2] commit_id = compute_commit_id( parent_ids=parent_ids, snapshot_id=snapshot_id, message=message or "", committed_at_iso=committed_at.isoformat(), ) metadata: Metadata = {} if section: metadata["section"] = section if track: metadata["track"] = track if emotion: metadata["emotion"] = emotion if validated_event_type is not None: metadata["event_type"] = validated_event_type if validated_meta is not None: metadata["meta"] = validated_meta # Load the parent snapshot manifest once and reuse it for both # structured_delta computation and file-count output. Previously the # manifest was loaded independently in each section — two separate # read_snapshot() calls per commit. parent_manifest: Manifest = {} parent_directories: list[str] = [] if parent_id is not None: parent_commit_rec = read_commit(root, parent_id) if parent_commit_rec is not None: parent_snap_record = read_snapshot(root, parent_commit_rec.snapshot_id) if parent_snap_record is not None: parent_manifest = dict(parent_snap_record.manifest) parent_directories = list(parent_snap_record.directories) # Compute a structured delta against the parent snapshot so muse show # can display note-level changes without reloading blobs. structured_delta: StructuredDelta | None = None sem_ver_bump: SemVerBump = "none" breaking_changes: list[str] = [] if parent_id is not None and parent_manifest: domain = read_domain(root) base_snap = SnapshotManifest(files=parent_manifest, domain=domain, directories=parent_directories) try: structured_delta = plugin.diff(base_snap, snap, repo_root=root) except Exception as exc: # plugin.diff() is domain-specific and may fail on unsupported # file types. The commit proceeds without a structured delta; # sem_ver_bump defaults to "none". logger.debug("plugin.diff() failed — structured delta omitted: %s", exc) structured_delta = None # Classify the structured delta into a semver bump and breaking-change list. if structured_delta is not None: classification = classify_delta(structured_delta, repo_root=root) sem_ver_bump = classification.bump breaking_changes = classification.breaking_addresses structured_delta["sem_ver_bump"] = sem_ver_bump structured_delta["breaking_changes"] = breaking_changes # Resolve agent provenance: CLI flags take priority over environment vars. # 1. Truncate to _MAX_FIELD_LEN chars — prevents DoS via arbitrarily long values. # 2. Strip all C0/DEL/C1 control characters — prevents terminal injection # when provenance fields are rendered in display paths (muse log, muse show, # agent dashboards), log-line splitting, and visual spoofing. resolved_agent_id = sanitize_provenance( (agent_id or os.environ.get("MUSE_AGENT_ID", ""))[:_MAX_FIELD_LEN] ) resolved_model_id = sanitize_provenance( (model_id or os.environ.get("MUSE_MODEL_ID", ""))[:_MAX_FIELD_LEN] ) resolved_toolchain_id = sanitize_provenance( (toolchain_id or os.environ.get("MUSE_TOOLCHAIN_ID", ""))[:_MAX_FIELD_LEN] ) resolved_prompt_hash = sanitize_provenance( os.environ.get("MUSE_PROMPT_HASH", "")[:_MAX_FIELD_LEN] ) # Compute file-level change counts from the (now single-read) parent manifest. files_added = len(set(manifest) - set(parent_manifest)) files_deleted = len(set(parent_manifest) - set(manifest)) files_modified = sum( 1 for p in set(manifest) & set(parent_manifest) if manifest[p] != parent_manifest[p] ) # ── Dry-run path — no writes beyond this point ──────────────────────────── if dry_run: if fmt == "json": print(json.dumps({ "dry_run": True, "clean": False, "commit_id": commit_id, "branch": branch, "snapshot_id": snapshot_id, "message": message or "", "parent_commit_id": parent_id, "parent2_commit_id": merge_parent2, "committed_at": committed_at.isoformat(), "author": author or "", "agent_id": resolved_agent_id, "sem_ver_bump": sem_ver_bump, "breaking_changes": breaking_changes, "files_changed": { "added": files_added, "modified": files_modified, "deleted": files_deleted, }, })) else: total = files_added + files_deleted + files_modified print(f"[dry-run] [{sanitize_display(branch)} {commit_id[:8]}] {sanitize_display(message or '')}") if total: parts: list[str] = [] if files_modified: parts.append(f"{files_modified} modified") if files_added: parts.append(f"{files_added} added") if files_deleted: parts.append(f"{files_deleted} removed") print(f" {total} file{'s' if total != 1 else ''} changed ({', '.join(parts)})") print(" (dry-run: nothing written)") return # ── Actual writes ───────────────────────────────────────────────────────── for rel_path, object_id in manifest.items(): write_object_from_path(root, object_id, root / rel_path) write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=manifest, directories=directories)) signature = "" signer_public_key = "" signer_key_id = "" if sign and resolved_agent_id: from muse.cli.config import get_signing_identity signing = get_signing_identity(root, agent_id=resolved_agent_id) if signing is not None: result = sign_commit_record( commit_id, resolved_agent_id, signing.private_key, author=author or "", model_id=resolved_model_id, toolchain_id=resolved_toolchain_id, prompt_hash=resolved_prompt_hash, committed_at=committed_at.isoformat(), ) if result is not None: signature, signer_public_key, signer_key_id = result else: logger.warning( "No signing identity found for agent %r — commit will be unsigned. " "Run `muse auth keygen && muse auth register` to set up a keypair.", resolved_agent_id, ) # ── Attestation (Phase 7.3) ─────────────────────────────────────────────── # Runs AFTER commit_id is computed and BEFORE write_commit so the # attestation record can be embedded in commit.metadata["attestation"]. # Failures fall into two buckets: # 1. Provider not registered for this domain → silent DEBUG-level skip. # 2. Provider raises AttestationRequiredError → abort the commit before # writing anything to disk; surface ExitCode.ATTESTATION_REQUIRED. if attest: domain = read_domain(root) provider = get_attestation_provider(domain) if provider is None: logger.debug( "commit --attest: no attestation provider registered for " "domain %r — skipping (no record attached).", domain, ) else: attestation_commit_meta: dict[str, object] = { "action": "write", "path": _attestation_path_for_commit(manifest, parent_manifest), "content_hash": snapshot_id, "timestamp": committed_at.isoformat(), "agent_id": resolved_agent_id, } try: attestation_record = provider.compute_attestation( snapshot_id, attestation_commit_meta ) except AttestationRequiredError as exc: print( f"❌ Attestation required but unavailable: {exc}", file=sys.stderr, ) raise SystemExit(ExitCode.ATTESTATION_REQUIRED) from exc except AttestationError as exc: logger.warning( "commit --attest: provider raised %s: %s — commit proceeding " "without attestation record.", type(exc).__name__, exc, ) attestation_record = None if attestation_record is not None: metadata["attestation"] = json.dumps( attestation_record, sort_keys=True, separators=(",", ":"), ensure_ascii=False, ) # ── Quorum co-signing (Phase 7.7) ───────────────────────────────────────── # Each member signs the SAME provenance_payload that the primary signer # signed. Failure to load a listed signer's key aborts the commit with # ExitCode.USER_ERROR — this is the "signature stripping" defence: an # attacker cannot drop a member from the list at sign time and pretend # the missing sig was "optional". if quorum_signers_raw: from muse.core.provenance import ( encode_public_key as _qencode_pubkey, sign_commit_ed25519 as _qsign, ) from muse.plugins.knowtation.quorum import ( build_quorum_metadata, is_valid_handle as _is_valid_quorum_handle, ) signer_handles = [h.strip() for h in quorum_signers_raw.split(",") if h.strip()] if not signer_handles: print( "❌ --quorum-signers was empty after parsing; supply at least one handle.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) for h in signer_handles: if not _is_valid_quorum_handle(h): print( f"❌ --quorum-signers: {h!r} is not a valid handle.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) if len(set(signer_handles)) != len(signer_handles): print( "❌ --quorum-signers contains duplicates; each handle must appear once.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) if not quorum_org or not quorum_org.startswith("@"): print( "❌ --quorum-signers requires --quorum-org @.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) if not _is_valid_quorum_handle(quorum_org): print( f"❌ --quorum-org {quorum_org!r} is not a valid handle.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) embedded_threshold = ( quorum_threshold if quorum_threshold is not None else len(signer_handles) ) if embedded_threshold < 1 or embedded_threshold > len(signer_handles): print( f"❌ --quorum-threshold {embedded_threshold} must be between 1 and " f"{len(signer_handles)} (signer count).", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) try: overrides = _parse_quorum_key_overrides(quorum_key_overrides) except ValueError as exc: print(f"❌ {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) from exc # Recompute the SAME payload the primary signer signs — guarantees # member sigs cover the identical bytes (replay defence). from muse.core.provenance import provenance_payload as _qpayload quorum_payload = _qpayload( commit_id, author=author or "", agent_id=resolved_agent_id, model_id=resolved_model_id, toolchain_id=resolved_toolchain_id, prompt_hash=resolved_prompt_hash, committed_at=committed_at.isoformat(), ) member_entries: list[dict[str, str]] = [] for h in signer_handles: try: priv_key = _resolve_quorum_member_key(h, overrides) except ValueError as exc: print( f"❌ Quorum signer {h!r} key invalid: {exc}", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) from exc if priv_key is None: print( f"❌ No key found for quorum signer {h!r}. Set " f"MUSE_QUORUM_KEY_, --quorum-key {h}=path, or " f"place ~/.muse/keys/{h}.pem. Aborting commit " "(signature stripping prevention).", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) sig = _qsign(quorum_payload, priv_key) _, pub_b64 = _qencode_pubkey(priv_key) member_entries.append({ "handle": h, "public_key": pub_b64, "signature": sig, }) try: quorum_blob = build_quorum_metadata( quorum_org, embedded_threshold, member_entries ) except ValueError as exc: print(f"❌ Failed to build quorum metadata: {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) from exc metadata["quorum"] = json.dumps( quorum_blob, sort_keys=True, separators=(",", ":"), ensure_ascii=False, ) write_commit(root, CommitRecord( commit_id=commit_id, repo_id=repo_id, branch=branch, snapshot_id=snapshot_id, message=message or "", committed_at=committed_at, parent_commit_id=parent_id, parent2_commit_id=merge_parent2, author=author or "", metadata=metadata, structured_delta=structured_delta, sem_ver_bump=sem_ver_bump, breaking_changes=breaking_changes, agent_id=resolved_agent_id, model_id=resolved_model_id, toolchain_id=resolved_toolchain_id, prompt_hash=resolved_prompt_hash, signature=signature, signer_public_key=signer_public_key, signer_key_id=signer_key_id, )) write_branch_ref(root, branch, commit_id) # Clear the stage after a successful commit so the next muse commit # returns to full-snapshot mode unless the user runs muse code add again. if isinstance(plugin, StagePlugin): plugin.clear_stage(root) append_reflog( root, branch, old_id=parent_id, new_id=commit_id, author=author or "unknown", operation=f"commit: {sanitize_display(message or '(no message)')}", ) # If this commit completed a conflicted merge, record how each conflict # was resolved so rerere can replay it on future identical conflicts. if merge_state is not None and merge_state.ours_commit and merge_state.theirs_commit: def _manifest_for(cid: str) -> Manifest: cr = read_commit(root, cid) if cr is None: return {} snap_rec = read_snapshot(root, cr.snapshot_id) return snap_rec.manifest if snap_rec else {} ours_manifest = _manifest_for(merge_state.ours_commit) theirs_manifest = _manifest_for(merge_state.theirs_commit) domain = read_domain(root) rerere_record_resolutions( root, list(merge_state.conflict_paths), ours_manifest, theirs_manifest, manifest, domain, plugin, ) clear_merge_state(root) # ── Output ──────────────────────────────────────────────────────────────── if fmt == "json": print(json.dumps({ "dry_run": False, "commit_id": commit_id, "branch": branch, "snapshot_id": snapshot_id, "message": message or "", "parent_commit_id": parent_id, "parent2_commit_id": merge_parent2, "committed_at": committed_at.isoformat(), "author": author or "", "agent_id": resolved_agent_id, "sem_ver_bump": sem_ver_bump, "breaking_changes": breaking_changes, "files_changed": { "added": files_added, "modified": files_modified, "deleted": files_deleted, }, })) else: print(f"[{sanitize_display(branch)} {commit_id[:8]}] {sanitize_display(message or '')}") total_files = files_added + files_deleted + files_modified if total_files: stat_parts: list[str] = [] if files_modified: stat_parts.append(f"{files_modified} modified") if files_added: stat_parts.append(f"{files_added} added") if files_deleted: stat_parts.append(f"{files_deleted} removed") print(f" {total_files} file{'s' if total_files != 1 else ''} changed ({', '.join(stat_parts)})")