fix(guard): pin session-state authority and reject direct-import mutation (#695)
Close remaining AC1/AC2/AC6-8 gaps after REQUEST_CHANGES and the PR #701 recurrence: GITEA_ALLOW_DIRECT_MCP_IMPORT never authorizes mutations; production transport bind pins GITEA_MCP_SESSION_STATE_DIR so redirected dirs cannot forge independent decision locks; mark/submit fail closed offline; quarantine continues to void contaminated merge eligibility. Regression tests reproduce the PR #701 direct-import + state-dir override sequence. Full suite: 2665 passed, 6 skipped. Closes #695 (remediation on PR #696)
This commit is contained in:
@@ -58,6 +58,17 @@ def _reset_mutation_authority(monkeypatch):
|
||||
_fallback: str = state_dir,
|
||||
_env_key: str = mcp_session_state.STATE_DIR_ENV,
|
||||
) -> str:
|
||||
# #695 AC2: when production native transport has pinned a session
|
||||
# state root, that pin is authoritative even under test isolation
|
||||
# (PR #701 redirected-state regression).
|
||||
try:
|
||||
import mcp_daemon_guard
|
||||
|
||||
pinned = mcp_daemon_guard.pinned_session_state_dir()
|
||||
if pinned:
|
||||
return pinned
|
||||
except Exception:
|
||||
pass
|
||||
raw = (os.environ.get(_env_key) or "").strip()
|
||||
return raw or _fallback
|
||||
|
||||
|
||||
@@ -63,7 +63,16 @@ class TestNativeTransportBinding(unittest.TestCase):
|
||||
mcp_daemon_guard.assert_sanctioned_mutation_runtime("offline_import")
|
||||
msg = str(ctx.exception)
|
||||
self.assertIn("#695", msg)
|
||||
self.assertIn("not sufficient", msg.lower() + " " + msg)
|
||||
# FORCE disables pytest allowance; direct-import env is rejected first
|
||||
# (AC1). Without ALLOW_DIRECT, env-alone also yields "not sufficient".
|
||||
lowered = msg.lower()
|
||||
self.assertTrue(
|
||||
"direct" in lowered
|
||||
or "not sufficient" in lowered
|
||||
or "allow_direct" in lowered
|
||||
or "gitea_allow_direct" in lowered,
|
||||
msg,
|
||||
)
|
||||
|
||||
def test_direct_import_mark_rejected_outside_entrypoint(self):
|
||||
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||
@@ -677,5 +686,163 @@ class TestDocsStopAfterNativeFailure(unittest.TestCase):
|
||||
self.assertIn("GITEA_ALLOW_DIRECT_MCP_IMPORT", doc)
|
||||
|
||||
|
||||
class TestPR701DirectImportSessionOverrideSequence(unittest.TestCase):
|
||||
"""AC9 regression: exact PR #701 contaminated-approval sequence must fail closed.
|
||||
|
||||
Observed attack:
|
||||
- GITEA_ALLOW_DIRECT_MCP_IMPORT=1
|
||||
- import mutation tools from gitea_mcp_server
|
||||
- GITEA_MCP_SESSION_STATE_DIR=.mcp_session_701 (cross-PR lock evasion)
|
||||
- mark_final + submit_pr_review
|
||||
"""
|
||||
|
||||
def tearDown(self) -> None:
|
||||
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||
os.environ.pop(mcp_daemon_guard.FORCE_PROVENANCE_FAIL_ENV, None)
|
||||
os.environ.pop(mcp_daemon_guard.SANCTIONED_DAEMON_ENV, None)
|
||||
os.environ.pop(mcp_daemon_guard.ALLOW_DIRECT_IMPORT_ENV, None)
|
||||
os.environ.pop(mcp_daemon_guard.SESSION_STATE_DIR_ENV, None)
|
||||
|
||||
def test_offline_run_submit_sequence_fails_closed(self):
|
||||
"""Fresh interpreter: direct import + state-dir override cannot mark/submit."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
redirect = str(Path(tmp) / ".mcp_session_701")
|
||||
snippet = textwrap.dedent(
|
||||
f"""
|
||||
import os
|
||||
import sys
|
||||
os.environ["GITEA_ALLOW_DIRECT_MCP_IMPORT"] = "1"
|
||||
os.environ["GITEA_MCP_SESSION_STATE_DIR"] = {redirect!r}
|
||||
os.environ["GITEA_MCP_PROFILE"] = "prgs-reviewer"
|
||||
# No pytest modules in this subprocess.
|
||||
import mcp_daemon_guard as g
|
||||
assert g.is_native_mcp_transport() is False
|
||||
assert g.is_production_native_mcp_transport() is False
|
||||
try:
|
||||
g.assert_sanctioned_mutation_runtime("run_submit_mark")
|
||||
except g.UnsanctionedRuntimeError as exc:
|
||||
msg = str(exc)
|
||||
assert "GITEA_ALLOW_DIRECT_MCP_IMPORT" in msg or "#695" in msg
|
||||
else:
|
||||
raise SystemExit("direct-import env authorized mutation runtime")
|
||||
try:
|
||||
g.mark_sanctioned_daemon()
|
||||
except g.UnsanctionedRuntimeError:
|
||||
pass
|
||||
else:
|
||||
raise SystemExit("mark_sanctioned_daemon authorized offline import")
|
||||
# Simulate decision-lock write into redirected dir only — must not
|
||||
# establish native authority.
|
||||
import mcp_session_state as ss
|
||||
wrote = ss.save_state(
|
||||
kind=ss.KIND_DECISION_LOCK,
|
||||
payload={{
|
||||
"final_review_decision_ready": True,
|
||||
"ready_pr_number": 701,
|
||||
"ready_action": "approve",
|
||||
"ready_expected_head_sha": "6b675f5c834b41f9d74e8a54294ff44dddf28ae4",
|
||||
"session_profile": "prgs-reviewer",
|
||||
"session_profile_lock": "prgs-reviewer",
|
||||
"remote": "prgs",
|
||||
}},
|
||||
profile_identity="prgs-reviewer",
|
||||
state_dir={redirect!r},
|
||||
)
|
||||
assert wrote is not None
|
||||
assert g.is_native_mcp_transport() is False
|
||||
try:
|
||||
g.assert_no_direct_import_bypass("gitea_submit_pr_review")
|
||||
except g.UnsanctionedRuntimeError:
|
||||
pass
|
||||
else:
|
||||
raise SystemExit("direct-import bypass accepted for submit")
|
||||
print("OK")
|
||||
"""
|
||||
)
|
||||
proc = _run_offline_snippet(snippet)
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertIn("OK", proc.stdout)
|
||||
|
||||
def test_session_state_dir_pin_ignores_post_bind_redirect(self):
|
||||
"""AC2: after production bind, env STATE_DIR override is ignored."""
|
||||
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||
import mcp_session_state
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
legitimate = str(Path(tmp) / "legitimate-state")
|
||||
rogue = str(Path(tmp) / ".mcp_session_701")
|
||||
os.makedirs(legitimate, mode=0o700, exist_ok=True)
|
||||
os.makedirs(rogue, mode=0o700, exist_ok=True)
|
||||
os.environ[mcp_daemon_guard.SESSION_STATE_DIR_ENV] = legitimate
|
||||
canonical = str((REPO_ROOT / "mcp_server.py").resolve())
|
||||
|
||||
def _fake_caller():
|
||||
return canonical
|
||||
|
||||
with patch.object(
|
||||
mcp_daemon_guard,
|
||||
"_caller_official_entrypoint_path",
|
||||
side_effect=_fake_caller,
|
||||
):
|
||||
with patch.object(
|
||||
mcp_daemon_guard, "is_pytest_runtime", return_value=False
|
||||
):
|
||||
mcp_daemon_guard.mark_sanctioned_daemon()
|
||||
mcp_daemon_guard.bind_native_mcp_transport(transport="stdio")
|
||||
pinned = mcp_daemon_guard.pinned_session_state_dir()
|
||||
self.assertEqual(pinned, str(Path(legitimate).resolve()))
|
||||
# Attacker redirects env after bind (PR #701).
|
||||
os.environ[mcp_daemon_guard.SESSION_STATE_DIR_ENV] = rogue
|
||||
self.assertEqual(
|
||||
mcp_daemon_guard.pinned_session_state_dir(),
|
||||
str(Path(legitimate).resolve()),
|
||||
)
|
||||
self.assertEqual(
|
||||
mcp_session_state.default_state_dir(),
|
||||
str(Path(legitimate).resolve()),
|
||||
)
|
||||
self.assertNotEqual(
|
||||
mcp_session_state.default_state_dir(),
|
||||
str(Path(rogue).resolve()),
|
||||
)
|
||||
# Unpinned env view still sees rogue (diagnostics only).
|
||||
unpinned = mcp_session_state.env_session_state_dir_unpinned()
|
||||
self.assertTrue(
|
||||
unpinned == rogue
|
||||
or Path(unpinned).resolve() == Path(rogue).resolve(),
|
||||
unpinned,
|
||||
)
|
||||
|
||||
def test_direct_import_env_does_not_authorize_under_force_unsanctioned(self):
|
||||
mcp_daemon_guard.clear_native_runtime_for_tests()
|
||||
os.environ[mcp_daemon_guard.ALLOW_DIRECT_IMPORT_ENV] = "1"
|
||||
os.environ[mcp_daemon_guard.FORCE_PROVENANCE_FAIL_ENV] = "1"
|
||||
self.assertTrue(mcp_daemon_guard.direct_import_env_enabled())
|
||||
# Under pytest, assert_no_direct_import_bypass is a no-op; FORCE path
|
||||
# still blocks is_native / assert_sanctioned via force-unsanctioned.
|
||||
self.assertFalse(mcp_daemon_guard.is_native_mcp_transport())
|
||||
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
|
||||
mcp_daemon_guard.assert_sanctioned_mutation_runtime("direct-import")
|
||||
|
||||
def test_quarantine_voids_merge_approval_for_contaminated_review(self):
|
||||
"""AC6–AC8: quarantined APPROVED does not satisfy merge approval head."""
|
||||
entry = {
|
||||
"verdict": "APPROVED",
|
||||
"dismissed": False,
|
||||
"reviewed_head_sha": "6b675f5c834b41f9d74e8a54294ff44dddf28ae4",
|
||||
"review_id": 431,
|
||||
"submitted_at": "2026-07-13T23:52:34Z",
|
||||
"quarantined": True,
|
||||
}
|
||||
result = merge_approval_gate.assess_merge_approval_head(
|
||||
current_head_sha="6b675f5c834b41f9d74e8a54294ff44dddf28ae4",
|
||||
latest_by_reviewer={"sysadmin": entry},
|
||||
quarantined_review_ids={431},
|
||||
)
|
||||
self.assertFalse(result["approval_at_current_head"])
|
||||
self.assertEqual(result["quarantined_approvals_at_current_head"], 1)
|
||||
self.assertIn("quarantined", (result["stale_approval_block_reason"] or ""))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user