dev_guard.py
python
sha256:2a8dfcf895b528326eedf00a21babac1a18d90176b856ea1e0fb3027b39be0da
feat(dev-safety): Phase 3 of #185 — guard rail blocks mutat…
Sonnet 5
patch
2 days ago
| 1 | """Guard rail: refuse mutating commands against a protected canonical repo |
| 2 | when running from an editable (live-source) build. |
| 3 | |
| 4 | Phase 3 of musehub staging #185. The hazard: an editable install (e.g. the |
| 5 | `muse-dev` entry point created in Phase 2, `pip install -e ~/ecosystem/muse`) |
| 6 | loads code straight from a repo it can also mutate. A mid-edit, syntactically |
| 7 | valid but semantically broken build can corrupt that repo's own `.muse` |
| 8 | object store. This module makes that require an explicit, loud opt-in |
| 9 | instead of happening by default. |
| 10 | |
| 11 | Only applies to editable builds — a stable, non-editable `muse` (installed |
| 12 | via the official installer venv) has no live-source hazard and is never |
| 13 | blocked by this guard, regardless of target. |
| 14 | |
| 15 | Scope: local object-store-mutating commands only. `hub` subcommands go over |
| 16 | the network to a remote server and are a different risk category (remote |
| 17 | data correctness, not local object-store corruption) — deliberately out of |
| 18 | scope here. |
| 19 | """ |
| 20 | from __future__ import annotations |
| 21 | |
| 22 | import argparse |
| 23 | import os |
| 24 | from pathlib import Path |
| 25 | |
| 26 | DEFAULT_PROTECTED_ROOTS = [ |
| 27 | str(Path.home() / "ecosystem" / "muse"), |
| 28 | str(Path.home() / "ecosystem" / "musehub"), |
| 29 | ] |
| 30 | |
| 31 | OVERRIDE_ENV_VAR = "MUSE_DEV_ALLOW_CANONICAL" |
| 32 | PROTECTED_ROOTS_ENV_VAR = "MUSE_DEV_PROTECTED_ROOTS" |
| 33 | |
| 34 | # Top-level commands that write to the local object store, refs, or working |
| 35 | # tree in a way that's hard to reverse if the code producing them is broken. |
| 36 | _MUTATING_TOP_LEVEL_COMMANDS = { |
| 37 | "commit", "merge", "push", "pull", "reset", "rm", "revert", |
| 38 | "cherry-pick", "rebase", "restore", "apply", "apply-patch", |
| 39 | "unpack-objects", "update-ref", "prune", "gc", "clean", |
| 40 | "checkout-symbol", "mv", "commit-tree", "symbolic-ref", "bundle", |
| 41 | "sparse-checkout", |
| 42 | } |
| 43 | |
| 44 | # `code <subcommand>` is a mixed namespace — most of it is pure read/analysis |
| 45 | # (grep, impact, hotspots, ...); only these actually mutate tracked content. |
| 46 | _MUTATING_CODE_SUBCOMMANDS = { |
| 47 | "add", "reset", "patch", "rename", "migrate", "semantic-cherry-pick", |
| 48 | } |
| 49 | |
| 50 | |
| 51 | class GuardBlocked(SystemExit): |
| 52 | """Raised (as a SystemExit subclass) when a mutating command is refused.""" |
| 53 | |
| 54 | |
| 55 | def is_editable_build() -> bool: |
| 56 | """True if this process is running muse's code straight from a source |
| 57 | checkout rather than a venv's copied ``site-packages`` tree.""" |
| 58 | import muse |
| 59 | return "site-packages" not in str(Path(muse.__file__).resolve().parent.parent) |
| 60 | |
| 61 | |
| 62 | def _protected_roots_from_env() -> list[str]: |
| 63 | raw = os.environ.get(PROTECTED_ROOTS_ENV_VAR) |
| 64 | if raw: |
| 65 | return [p for p in raw.split(":") if p] |
| 66 | return DEFAULT_PROTECTED_ROOTS |
| 67 | |
| 68 | |
| 69 | def is_protected_root(path: str, *, protected_roots: list[str] | None = None) -> bool: |
| 70 | """True if ``path`` is, or is nested inside, any protected canonical repo root.""" |
| 71 | roots = protected_roots if protected_roots is not None else _protected_roots_from_env() |
| 72 | resolved = Path(path).resolve() |
| 73 | for root in roots: |
| 74 | root_resolved = Path(root).resolve() |
| 75 | if resolved == root_resolved or root_resolved in resolved.parents: |
| 76 | return True |
| 77 | return False |
| 78 | |
| 79 | |
| 80 | def classify_is_mutating(args: argparse.Namespace) -> bool: |
| 81 | """True if the parsed CLI args represent a local-object-store-mutating command.""" |
| 82 | command = getattr(args, "command", None) |
| 83 | if command == "code": |
| 84 | return getattr(args, "code_command", None) in _MUTATING_CODE_SUBCOMMANDS |
| 85 | return command in _MUTATING_TOP_LEVEL_COMMANDS |
| 86 | |
| 87 | |
| 88 | def check_guard( |
| 89 | args: argparse.Namespace, |
| 90 | *, |
| 91 | cwd: str | None = None, |
| 92 | protected_roots: list[str] | None = None, |
| 93 | is_editable: bool | None = None, |
| 94 | ) -> None: |
| 95 | """Raise :class:`GuardBlocked` if this call should be refused. |
| 96 | |
| 97 | All keyword params default to the real live values (current process's |
| 98 | editable-ness, real cwd, env-configured protected roots) — tests pass |
| 99 | them explicitly to avoid ever touching the real canonical repos. |
| 100 | """ |
| 101 | editable = is_editable_build() if is_editable is None else is_editable |
| 102 | if not editable: |
| 103 | return |
| 104 | if not classify_is_mutating(args): |
| 105 | return |
| 106 | |
| 107 | effective_cwd = os.getcwd() if cwd is None else cwd |
| 108 | if not is_protected_root(effective_cwd, protected_roots=protected_roots): |
| 109 | return |
| 110 | |
| 111 | if os.environ.get(OVERRIDE_ENV_VAR) == "1": |
| 112 | return |
| 113 | |
| 114 | command = getattr(args, "command", "?") |
| 115 | sub = getattr(args, "code_command", None) |
| 116 | full_command = f"{command} {sub}" if sub else command |
| 117 | raise GuardBlocked( |
| 118 | f"❌ Refusing to run mutating command `muse {full_command}` from an editable " |
| 119 | f"build against a protected canonical repo ({effective_cwd}).\n" |
| 120 | f" This is muse-dev's guard rail (musehub staging #185) — an editable " |
| 121 | f"install can corrupt the object store of the very repo it was built from.\n" |
| 122 | f" Set {OVERRIDE_ENV_VAR}=1 to override, only if you're certain this is " |
| 123 | f"intentional." |
| 124 | ) |
File History
1 commit
sha256:2a8dfcf895b528326eedf00a21babac1a18d90176b856ea1e0fb3027b39be0da
feat(dev-safety): Phase 3 of #185 — guard rail blocks mutat…
Sonnet 5
patch
2 days ago