test_dev_guard.py
python
sha256:2a8dfcf895b528326eedf00a21babac1a18d90176b856ea1e0fb3027b39be0da
feat(dev-safety): Phase 3 of #185 — guard rail blocks mutat…
Sonnet 5
patch
2 days ago
| 1 | """Phase 3 of #185 (musehub staging): guard rail against mutating an editable |
| 2 | build's own canonical repo. |
| 3 | |
| 4 | Unit tests exercise the classifier functions directly. The integration test |
| 5 | runs the real `muse-dev` binary end-to-end but points `MUSE_DEV_PROTECTED_ROOTS` |
| 6 | at a disposable temp repo, never the real ~/ecosystem/muse — this guard rail |
| 7 | must never be integration-tested against the actual canonical repo it exists |
| 8 | to protect. |
| 9 | """ |
| 10 | import argparse |
| 11 | import os |
| 12 | import subprocess |
| 13 | import sys |
| 14 | from pathlib import Path |
| 15 | |
| 16 | import pytest |
| 17 | |
| 18 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "muse" / "cli")) |
| 19 | from dev_guard import ( # noqa: E402 |
| 20 | GuardBlocked, |
| 21 | check_guard, |
| 22 | classify_is_mutating, |
| 23 | is_protected_root, |
| 24 | ) |
| 25 | |
| 26 | |
| 27 | def _ns(**kwargs) -> argparse.Namespace: |
| 28 | return argparse.Namespace(**kwargs) |
| 29 | |
| 30 | |
| 31 | class TestIsProtectedRoot: |
| 32 | def test_exact_match_is_protected(self, tmp_path: Path) -> None: |
| 33 | assert is_protected_root(str(tmp_path), protected_roots=[str(tmp_path)]) |
| 34 | |
| 35 | def test_subdirectory_of_protected_root_is_protected(self, tmp_path: Path) -> None: |
| 36 | sub = tmp_path / "nested" / "deep" |
| 37 | sub.mkdir(parents=True) |
| 38 | assert is_protected_root(str(sub), protected_roots=[str(tmp_path)]) |
| 39 | |
| 40 | def test_sibling_path_is_not_protected(self, tmp_path: Path) -> None: |
| 41 | sibling = tmp_path.parent / "not-protected" |
| 42 | assert not is_protected_root(str(sibling), protected_roots=[str(tmp_path)]) |
| 43 | |
| 44 | def test_unrelated_third_party_repo_is_not_protected(self, tmp_path: Path) -> None: |
| 45 | assert not is_protected_root("/some/other/repo", protected_roots=[str(tmp_path)]) |
| 46 | |
| 47 | |
| 48 | class TestClassifyIsMutating: |
| 49 | @pytest.mark.parametrize("command", ["commit", "merge", "push", "pull", "reset", "rm", "revert", "rebase", "gc", "prune", "clean"]) |
| 50 | def test_known_mutating_top_level_commands(self, command: str) -> None: |
| 51 | assert classify_is_mutating(_ns(command=command)) |
| 52 | |
| 53 | @pytest.mark.parametrize("command", ["status", "log", "diff", "branch", "rev-parse"]) |
| 54 | def test_known_readonly_top_level_commands(self, command: str) -> None: |
| 55 | assert not classify_is_mutating(_ns(command=command)) |
| 56 | |
| 57 | def test_code_grep_is_readonly(self) -> None: |
| 58 | assert not classify_is_mutating(_ns(command="code", code_command="grep")) |
| 59 | |
| 60 | def test_code_add_is_mutating(self) -> None: |
| 61 | assert classify_is_mutating(_ns(command="code", code_command="add")) |
| 62 | |
| 63 | def test_code_impact_is_readonly(self) -> None: |
| 64 | assert not classify_is_mutating(_ns(command="code", code_command="impact")) |
| 65 | |
| 66 | def test_code_patch_is_mutating(self) -> None: |
| 67 | assert classify_is_mutating(_ns(command="code", code_command="patch")) |
| 68 | |
| 69 | |
| 70 | class TestCheckGuard: |
| 71 | def test_mutating_command_against_protected_root_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: |
| 72 | monkeypatch.delenv("MUSE_DEV_ALLOW_CANONICAL", raising=False) |
| 73 | with pytest.raises(GuardBlocked): |
| 74 | check_guard(_ns(command="commit"), cwd=str(tmp_path), protected_roots=[str(tmp_path)], is_editable=True) |
| 75 | |
| 76 | def test_mutating_command_with_override_env_passes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: |
| 77 | monkeypatch.setenv("MUSE_DEV_ALLOW_CANONICAL", "1") |
| 78 | check_guard(_ns(command="commit"), cwd=str(tmp_path), protected_roots=[str(tmp_path)], is_editable=True) # must not raise |
| 79 | |
| 80 | def test_readonly_command_against_protected_root_passes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: |
| 81 | monkeypatch.delenv("MUSE_DEV_ALLOW_CANONICAL", raising=False) |
| 82 | check_guard(_ns(command="status"), cwd=str(tmp_path), protected_roots=[str(tmp_path)], is_editable=True) # must not raise |
| 83 | |
| 84 | def test_mutating_command_against_unprotected_root_passes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: |
| 85 | monkeypatch.delenv("MUSE_DEV_ALLOW_CANONICAL", raising=False) |
| 86 | other = tmp_path / "sandbox" |
| 87 | other.mkdir() |
| 88 | check_guard(_ns(command="commit"), cwd=str(other), protected_roots=[str(tmp_path / "protected")], is_editable=True) # must not raise |
| 89 | |
| 90 | def test_stable_non_editable_build_is_never_blocked(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: |
| 91 | monkeypatch.delenv("MUSE_DEV_ALLOW_CANONICAL", raising=False) |
| 92 | # A stable (non-editable) `muse` build has no live-source hazard — |
| 93 | # the guard only ever applies to editable/dev builds. |
| 94 | check_guard(_ns(command="commit"), cwd=str(tmp_path), protected_roots=[str(tmp_path)], is_editable=False) # must not raise |
| 95 | |
| 96 | |
| 97 | class TestRealMuseDevBinaryIntegration: |
| 98 | """Exercises the real `muse-dev` binary — but only against a disposable |
| 99 | temp repo pointed to via MUSE_DEV_PROTECTED_ROOTS, never the real checkout.""" |
| 100 | |
| 101 | def _muse_dev_path(self) -> str | None: |
| 102 | proc = subprocess.run(["zsh", "-lc", "command -v muse-dev"], capture_output=True, text=True) |
| 103 | return proc.stdout.strip() or None |
| 104 | |
| 105 | def test_commit_blocked_against_fake_protected_root(self, tmp_path: Path) -> None: |
| 106 | muse_dev = self._muse_dev_path() |
| 107 | if muse_dev is None: |
| 108 | pytest.skip("muse-dev not installed on this machine yet (Phase 2 not applied)") |
| 109 | |
| 110 | repo = tmp_path / "fake-canonical-repo" |
| 111 | repo.mkdir() |
| 112 | subprocess.run([muse_dev, "init"], cwd=repo, capture_output=True, text=True, check=True) |
| 113 | (repo / "file.txt").write_text("hello\n") |
| 114 | subprocess.run([muse_dev, "code", "add", "."], cwd=repo, capture_output=True, text=True, check=True) |
| 115 | |
| 116 | env = dict(os.environ, MUSE_DEV_PROTECTED_ROOTS=str(repo)) |
| 117 | env.pop("MUSE_DEV_ALLOW_CANONICAL", None) |
| 118 | proc = subprocess.run( |
| 119 | [muse_dev, "commit", "-m", "should be blocked"], |
| 120 | cwd=repo, capture_output=True, text=True, env=env, |
| 121 | ) |
| 122 | |
| 123 | assert proc.returncode != 0 |
| 124 | assert "MUSE_DEV_ALLOW_CANONICAL" in (proc.stdout + proc.stderr) |
| 125 | |
| 126 | def test_commit_allowed_with_override(self, tmp_path: Path) -> None: |
| 127 | muse_dev = self._muse_dev_path() |
| 128 | if muse_dev is None: |
| 129 | pytest.skip("muse-dev not installed on this machine yet (Phase 2 not applied)") |
| 130 | |
| 131 | repo = tmp_path / "fake-canonical-repo-override" |
| 132 | repo.mkdir() |
| 133 | subprocess.run([muse_dev, "init"], cwd=repo, capture_output=True, text=True, check=True) |
| 134 | (repo / "file.txt").write_text("hello\n") |
| 135 | subprocess.run([muse_dev, "code", "add", "."], cwd=repo, capture_output=True, text=True, check=True) |
| 136 | |
| 137 | env = dict(os.environ, MUSE_DEV_PROTECTED_ROOTS=str(repo), MUSE_DEV_ALLOW_CANONICAL="1") |
| 138 | proc = subprocess.run( |
| 139 | [muse_dev, "commit", "-m", "should be allowed"], |
| 140 | cwd=repo, capture_output=True, text=True, env=env, |
| 141 | ) |
| 142 | |
| 143 | assert proc.returncode == 0, proc.stderr |
| 144 | |
| 145 | def test_status_never_blocked_against_fake_protected_root(self, tmp_path: Path) -> None: |
| 146 | muse_dev = self._muse_dev_path() |
| 147 | if muse_dev is None: |
| 148 | pytest.skip("muse-dev not installed on this machine yet (Phase 2 not applied)") |
| 149 | |
| 150 | repo = tmp_path / "fake-canonical-repo-readonly" |
| 151 | repo.mkdir() |
| 152 | subprocess.run([muse_dev, "init"], cwd=repo, capture_output=True, text=True, check=True) |
| 153 | |
| 154 | env = dict(os.environ, MUSE_DEV_PROTECTED_ROOTS=str(repo)) |
| 155 | env.pop("MUSE_DEV_ALLOW_CANONICAL", None) |
| 156 | proc = subprocess.run([muse_dev, "status"], cwd=repo, capture_output=True, text=True, env=env) |
| 157 | |
| 158 | assert proc.returncode == 0, proc.stderr |
File History
1 commit
sha256:2a8dfcf895b528326eedf00a21babac1a18d90176b856ea1e0fb3027b39be0da
feat(dev-safety): Phase 3 of #185 — guard rail blocks mutat…
Sonnet 5
patch
2 days ago