test_land_closeout_security.py python
145 lines 5.4 KB
Raw
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1 docs: MuseHub-first before ISR #74 — staging solidify NEXT Human 1 day ago
1 """Security: land-closeout fail-closed, no secrets, no exec, muse-only no git/gh
2 (§PMHF.10 security)."""
3
4 from __future__ import annotations
5
6 from pathlib import Path
7 from unittest.mock import MagicMock
8
9 from adapters.runner import RecordingRunner
10 from adapters.types import AnchorResult, HeadResult, StatusResult
11 from tests.support import (
12 KIT_ROOT,
13 land_a_fence_body,
14 land_handover_text,
15 load_fixture_config,
16 seed_land_repo,
17 write_config,
18 )
19 from tools.governance_hygiene.next_regen import LAND_B_REMEDIATION
20 from tools.land_closeout import check_land_closeout, land_closeout_payload
21
22 CI_TEMPLATE = KIT_ROOT / "templates" / "ci" / "governance-closeout-github-actions.yml"
23
24
25 def _adapter(tip: str = "cafebabe") -> MagicMock:
26 adapter = MagicMock()
27 adapter.status.return_value = StatusResult(
28 regime="git-only", dirty=False, branch="main", muse_dirty=None, git_dirty=False
29 )
30 adapter.read_head.return_value = HeadResult(sha=tip, kind="git")
31 adapter.read_canonical_anchor.return_value = AnchorResult(
32 anchor_sha=tip, source="origin/main"
33 )
34 return adapter
35
36
37 def test_report_does_not_echo_handover_secrets(tmp_path: Path) -> None:
38 secret = "ghp_seCRETtoken1234567890abcdefFAKE"
39 seed_land_repo(
40 tmp_path,
41 claim="deadbeef",
42 handover_text=land_handover_text(
43 "deadbeef",
44 fence_body=land_a_fence_body(paste_extra=f"token: {secret}\n"),
45 ),
46 )
47 config = load_fixture_config(tmp_path, "config-git-only.yaml")
48 report = check_land_closeout(config, tmp_path, adapter=_adapter())
49 payload = land_closeout_payload(report)
50 assert secret not in str(payload)
51
52
53 def test_remediation_strings_are_never_executed(tmp_path: Path) -> None:
54 seed_land_repo(tmp_path, claim="deadbeef")
55 config = load_fixture_config(tmp_path, "config-git-only.yaml")
56 runner = RecordingRunner(responses={}, calls=[])
57 report = check_land_closeout(
58 config, tmp_path, adapter=_adapter(), runner=runner, probe_merged_pr=False
59 )
60 assert report.remediation == LAND_B_REMEDIATION
61 # The remediation text is advisory only — no command containing it ever runs.
62 assert not any(LAND_B_REMEDIATION[:20] in call[0] for call in runner.calls)
63
64
65 def test_no_write_commands_from_land_closeout(tmp_path: Path) -> None:
66 seed_land_repo(
67 tmp_path,
68 handover_text=land_handover_text(
69 "cafebabe",
70 fence_body=land_a_fence_body(paste_extra="PR #206 open — waiting for merge.\n"),
71 ),
72 )
73 config = load_fixture_config(tmp_path, "config-git-only.yaml")
74 runner = RecordingRunner(responses={}, calls=[])
75 check_land_closeout(
76 config, tmp_path, adapter=_adapter(), runner=runner, probe_merged_pr=True
77 )
78 forbidden = ("git push", "git commit", "git merge", "gh pr merge")
79 for call in runner.calls:
80 assert not any(call[0].startswith(cmd) for cmd in forbidden)
81
82
83 def test_muse_only_never_calls_git_or_gh(tmp_path: Path) -> None:
84 write_config(tmp_path, "config-muse-only.yaml")
85 (tmp_path / ".overseer" / "version.lock").write_text(
86 "lock_version: 1\nkit_version: 0.1.0\nconfig_version: 1\n"
87 "footprint_digest: sha256:" + ("0" * 64) + "\n"
88 'installed_at: "2026-01-01T00:00:00Z"\nsynced_at: "2026-01-01T00:00:00Z"\n'
89 "footprint: []\n",
90 encoding="utf-8",
91 )
92 docs = tmp_path / "docs"
93 docs.mkdir(parents=True, exist_ok=True)
94 (docs / "OVERSEER-HANDOVER.md").write_text(
95 land_handover_text(
96 "cafebabe",
97 fence_body=land_a_fence_body(paste_extra="PR #206 open — waiting for merge.\n"),
98 ),
99 encoding="utf-8",
100 )
101 (docs / "ROADMAP.md").write_text("## Build queue\n\n", encoding="utf-8")
102
103 config = load_fixture_config(tmp_path, "config-muse-only.yaml")
104 adapter = MagicMock()
105 adapter.status.return_value = StatusResult(
106 regime="muse-only", dirty=False, branch="main", muse_dirty=False, git_dirty=None
107 )
108 adapter.read_head.return_value = HeadResult(sha="cafebabe", kind="muse")
109 adapter.read_canonical_anchor.return_value = AnchorResult(
110 anchor_sha="cafebabe", source="muse:main"
111 )
112 runner = RecordingRunner(responses={}, calls=[])
113 report = check_land_closeout(
114 config,
115 tmp_path,
116 adapter=adapter,
117 runner=runner,
118 probe_merged_pr=True, # must be ignored for muse-only (§PMHF.5.3)
119 )
120 assert report.optional_pr_merged is None
121 assert not any(
122 call[0].startswith("git") or call[0].startswith("gh") for call in runner.calls
123 )
124
125
126 def test_fail_closed_on_corrupted_lock(tmp_path: Path) -> None:
127 seed_land_repo(tmp_path)
128 (tmp_path / ".overseer" / "version.lock").write_text(
129 ":: not yaml ::\n\t{{{", encoding="utf-8"
130 )
131 config = load_fixture_config(tmp_path, "config-git-only.yaml")
132 report = check_land_closeout(config, tmp_path, adapter=_adapter())
133 assert report.state == "unreadable"
134 assert not report.ok
135
136
137 def test_ci_template_uses_github_token_only_and_never_pushes() -> None:
138 text = CI_TEMPLATE.read_text(encoding="utf-8")
139 assert "secrets." not in text # standard GITHUB_TOKEN only (github.token)
140 assert "github.token" in text
141 active = "\n".join(
142 line for line in text.splitlines() if line.strip() and not line.strip().startswith("#")
143 )
144 assert "git push" not in active
145 assert "cursor" not in active.lower() # no Cursor-only steps
File History 1 commit
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1 docs: MuseHub-first before ISR #74 — staging solidify NEXT Human 1 day ago