test_upgrade_regime_security.py
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | """Security — Track O / O3 upgrade-regime fail-closed (§O2.9 security).""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import json |
| 6 | import sys |
| 7 | from io import StringIO |
| 8 | from pathlib import Path |
| 9 | |
| 10 | from adapters.templating import render_template |
| 11 | from adapters.config import load_config |
| 12 | from cli.context import CliContext |
| 13 | from cli.kit_root import kit_root |
| 14 | from cli.main import main |
| 15 | from cli.output import OutputContext |
| 16 | from tests.support import ( |
| 17 | FIXTURES, |
| 18 | make_runner, |
| 19 | muse_mirror_status_runner, |
| 20 | muse_status_runner, |
| 21 | ok, |
| 22 | run_cli, |
| 23 | seed_muse_substrate, |
| 24 | ) |
| 25 | from tools.upgrade_regime.ceremony import ( |
| 26 | check_g3_deploy_script, |
| 27 | check_g4_deploy_script, |
| 28 | ) |
| 29 | |
| 30 | |
| 31 | def test_no_secrets_in_ceremony_json(tmp_path: Path) -> None: |
| 32 | seed_muse_substrate(tmp_path) |
| 33 | assert ( |
| 34 | run_cli( |
| 35 | [ |
| 36 | "init", |
| 37 | "--from-config", |
| 38 | str(FIXTURES / "config-muse-only.yaml"), |
| 39 | "--non-interactive", |
| 40 | ], |
| 41 | cwd=tmp_path, |
| 42 | kit=kit_root(), |
| 43 | runner=muse_status_runner(tmp_path), |
| 44 | ) |
| 45 | == 0 |
| 46 | ) |
| 47 | base = muse_mirror_status_runner(tmp_path) |
| 48 | responses = dict(base.responses) |
| 49 | responses["git remote get-url origin"] = ok("[email protected]:o/r.git") |
| 50 | buf = StringIO() |
| 51 | old = sys.stdout |
| 52 | sys.stdout = buf |
| 53 | try: |
| 54 | ctx = CliContext.create( |
| 55 | runner=make_runner(responses), |
| 56 | cwd=tmp_path, |
| 57 | kit=kit_root(), |
| 58 | output=OutputContext(json_mode=True), |
| 59 | ) |
| 60 | code = main( |
| 61 | [ |
| 62 | "upgrade-regime", |
| 63 | "--from", |
| 64 | "muse-only", |
| 65 | "--to", |
| 66 | "muse+git-mirror", |
| 67 | "--dry-run", |
| 68 | "--json", |
| 69 | ], |
| 70 | ctx=ctx, |
| 71 | ) |
| 72 | finally: |
| 73 | sys.stdout = old |
| 74 | assert code == 0 |
| 75 | out = buf.getvalue() |
| 76 | assert "AKIA" not in out |
| 77 | assert "BEGIN PRIVATE KEY" not in out |
| 78 | assert "/Users/" not in out |
| 79 | payload = json.loads(out) |
| 80 | assert "gates" in payload |
| 81 | |
| 82 | |
| 83 | def test_g3_g4_refuse_git_dir_dot_and_push_main() -> None: |
| 84 | config = load_config(FIXTURES / "config-muse-git-mirror.yaml") |
| 85 | script = render_template( |
| 86 | kit_root() / "templates" / "scripts" / "muse-bridge-deploy.sh.template", |
| 87 | config, |
| 88 | ) |
| 89 | assert check_g3_deploy_script(script).ok |
| 90 | assert check_g4_deploy_script(script).ok |
| 91 | bad = '#!/bin/bash\nmuse bridge git-export --git-dir .\ngit push origin main\n' |
| 92 | assert not check_g3_deploy_script(bad).ok |
| 93 | assert not check_g4_deploy_script(bad).ok |
| 94 | |
| 95 | |
| 96 | def test_path_escape_config_refused(tmp_path: Path) -> None: |
| 97 | seed_muse_substrate(tmp_path) |
| 98 | outside = tmp_path / ".." / "outside-config.yaml" |
| 99 | # Prefer absolute path outside repo |
| 100 | outside = tmp_path.parent / f"escape-config-{tmp_path.name}.yaml" |
| 101 | outside.write_text("overseer_config_version: 1\n", encoding="utf-8") |
| 102 | try: |
| 103 | code = run_cli( |
| 104 | [ |
| 105 | "--config", |
| 106 | str(outside), |
| 107 | "upgrade-regime", |
| 108 | "--from", |
| 109 | "muse-only", |
| 110 | "--to", |
| 111 | "muse+git-mirror", |
| 112 | "--dry-run", |
| 113 | ], |
| 114 | cwd=tmp_path, |
| 115 | kit=kit_root(), |
| 116 | ) |
| 117 | assert code == 4 |
| 118 | finally: |
| 119 | outside.unlink(missing_ok=True) |
| 120 | |
| 121 | |
| 122 | def test_live_bridge_without_yes_refused(tmp_path: Path) -> None: |
| 123 | seed_muse_substrate(tmp_path) |
| 124 | assert ( |
| 125 | run_cli( |
| 126 | [ |
| 127 | "init", |
| 128 | "--from-config", |
| 129 | str(FIXTURES / "config-muse-only.yaml"), |
| 130 | "--non-interactive", |
| 131 | ], |
| 132 | cwd=tmp_path, |
| 133 | kit=kit_root(), |
| 134 | runner=muse_status_runner(tmp_path), |
| 135 | ) |
| 136 | == 0 |
| 137 | ) |
| 138 | base = muse_mirror_status_runner(tmp_path) |
| 139 | responses = dict(base.responses) |
| 140 | responses["git remote get-url origin"] = ok("[email protected]:o/r.git") |
| 141 | # Apply first so gates can pass, then live without -y |
| 142 | assert ( |
| 143 | run_cli( |
| 144 | ["upgrade-regime", "--from", "muse-only", "--to", "muse+git-mirror", "--apply"], |
| 145 | cwd=tmp_path, |
| 146 | kit=kit_root(), |
| 147 | runner=make_runner(responses), |
| 148 | ) |
| 149 | == 0 |
| 150 | ) |
| 151 | code = run_cli( |
| 152 | [ |
| 153 | "upgrade-regime", |
| 154 | "--from", |
| 155 | "muse-only", |
| 156 | "--to", |
| 157 | "muse+git-mirror", |
| 158 | "--apply", |
| 159 | "--live-bridge", |
| 160 | ], |
| 161 | cwd=tmp_path, |
| 162 | kit=kit_root(), |
| 163 | runner=make_runner(responses), |
| 164 | ) |
| 165 | assert code == 4 |
| 166 | |
| 167 | |
| 168 | def test_git_only_baseline_unchanged_by_ceremony(tmp_path: Path) -> None: |
| 169 | """K7 MuseHub-optional: git-only trees are refused, not rewritten.""" |
| 170 | assert ( |
| 171 | run_cli( |
| 172 | ["init", "--regime", "git-only", "--non-interactive"], |
| 173 | cwd=tmp_path, |
| 174 | kit=kit_root(), |
| 175 | ) |
| 176 | == 0 |
| 177 | ) |
| 178 | before = (tmp_path / ".overseer" / "config.yaml").read_bytes() |
| 179 | code = run_cli( |
| 180 | ["upgrade-regime", "--from", "muse-only", "--to", "muse+git-mirror", "--apply"], |
| 181 | cwd=tmp_path, |
| 182 | kit=kit_root(), |
| 183 | ) |
| 184 | assert code == 4 |
| 185 | assert (tmp_path / ".overseer" / "config.yaml").read_bytes() == before |
| 186 | |
| 187 | |
| 188 | def test_no_network_required_for_c0_c5(tmp_path: Path) -> None: |
| 189 | """RecordingRunner only — no SubprocessRunner network for dry-run path.""" |
| 190 | seed_muse_substrate(tmp_path) |
| 191 | assert ( |
| 192 | run_cli( |
| 193 | [ |
| 194 | "init", |
| 195 | "--from-config", |
| 196 | str(FIXTURES / "config-muse-only.yaml"), |
| 197 | "--non-interactive", |
| 198 | ], |
| 199 | cwd=tmp_path, |
| 200 | kit=kit_root(), |
| 201 | runner=muse_status_runner(tmp_path), |
| 202 | ) |
| 203 | == 0 |
| 204 | ) |
| 205 | base = muse_mirror_status_runner(tmp_path) |
| 206 | responses = dict(base.responses) |
| 207 | responses["git remote get-url origin"] = ok("[email protected]:o/r.git") |
| 208 | runner = make_runner(responses) |
| 209 | code = run_cli( |
| 210 | ["upgrade-regime", "--from", "muse-only", "--to", "muse+git-mirror", "--dry-run"], |
| 211 | cwd=tmp_path, |
| 212 | kit=kit_root(), |
| 213 | runner=runner, |
| 214 | ) |
| 215 | assert code == 0 |
| 216 | # Only local git remote get-url (mocked) — no curl/gh/muse export |
| 217 | for cmd, _cwd in runner.calls: |
| 218 | assert "curl" not in cmd |
| 219 | assert "gh " not in cmd |
| 220 | assert "bridge git-export" not in cmd |