fix(session): pin immutable MCP context; ban cross-host profile substitution (Closes #714) #715
@@ -228,6 +228,26 @@ class TestIssue714SessionContextImmutability(unittest.TestCase):
|
|||||||
gitea_config.selected_profile_name(), "mdcps-reviewer"
|
gitea_config.selected_profile_name(), "mdcps-reviewer"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_prgs_request_cannot_resolve_through_mdcps_profile(self):
|
||||||
|
"""Reverse of the incident: a pinned MDCPS profile must never serve a
|
||||||
|
prgs request, nor be silently swapped for the prgs-side profile."""
|
||||||
|
with patch.dict(os.environ, self._env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="mdcps-author", remote="dadeschools"
|
||||||
|
)
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="create_issue", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertNotEqual(res["active_profile"], "prgs-author")
|
||||||
|
self.assertEqual(res["active_profile"], "mdcps-author")
|
||||||
|
self.assertFalse(res["allowed_in_current_session"])
|
||||||
|
self.assertTrue(res["stop_required"])
|
||||||
|
blocked = mcp_server._session_context_mutation_block(remote="prgs")
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(any("cross-host" in r for r in blocked["reasons"]))
|
||||||
|
self.assertEqual(gitea_config.selected_profile_name(), "mdcps-author")
|
||||||
|
|
||||||
def test_unsupported_capability_fails_closed_structured(self):
|
def test_unsupported_capability_fails_closed_structured(self):
|
||||||
with patch.dict(os.environ, self._env, clear=False):
|
with patch.dict(os.environ, self._env, clear=False):
|
||||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
@@ -444,6 +464,92 @@ class TestSessionContextBindingUnit(unittest.TestCase):
|
|||||||
self.assertEqual(results, [original] * 8)
|
self.assertEqual(results, [original] * 8)
|
||||||
self.assertEqual(session_ctx.get_session_context(), original)
|
self.assertEqual(session_ctx.get_session_context(), original)
|
||||||
|
|
||||||
|
def test_concurrent_initialization_has_single_winning_binding(self):
|
||||||
|
"""Racing first-binds from an unbound context: exactly one winner, and
|
||||||
|
every racer observes that same complete binding (never a partial one)."""
|
||||||
|
self.assertIsNone(session_ctx.get_session_context())
|
||||||
|
thread_count = 8
|
||||||
|
barrier = threading.Barrier(thread_count)
|
||||||
|
results = []
|
||||||
|
results_lock = threading.Lock()
|
||||||
|
|
||||||
|
def racing_seed(index: int) -> None:
|
||||||
|
barrier.wait()
|
||||||
|
result = session_ctx.seed_session_context_if_unbound(
|
||||||
|
profile_name=f"prgs-author-{index}",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity=f"user-{index}",
|
||||||
|
source="concurrent-init-test",
|
||||||
|
)
|
||||||
|
with results_lock:
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
threads = [
|
||||||
|
threading.Thread(target=racing_seed, args=(i,))
|
||||||
|
for i in range(thread_count)
|
||||||
|
]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
self.assertFalse(any(thread.is_alive() for thread in threads))
|
||||||
|
winner = session_ctx.get_session_context()
|
||||||
|
self.assertIsNotNone(winner)
|
||||||
|
self.assertEqual(len(results), thread_count)
|
||||||
|
self.assertEqual(results, [winner] * thread_count)
|
||||||
|
# A losing racer's identity must never be spliced into the winner.
|
||||||
|
self.assertEqual(
|
||||||
|
winner["identity"], winner["profile_name"].replace("prgs-author-", "user-")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_repository_mismatch_blocks_before_mutation(self):
|
||||||
|
"""Same host and profile, different repository, must still fail closed."""
|
||||||
|
session_ctx.bind_session_context(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
repository="Gitea-Tools",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
same = session_ctx.assess_session_context(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
repository="Gitea-Tools",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
require_bound=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(same["proven"])
|
||||||
|
other_repo = session_ctx.assess_session_context(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
repository="eAgenda",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
require_bound=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(other_repo["block"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("repository drift" in r for r in other_repo["reasons"])
|
||||||
|
)
|
||||||
|
other_org = session_ctx.assess_session_context(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
repository="Gitea-Tools",
|
||||||
|
org="913443",
|
||||||
|
require_bound=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(other_org["block"])
|
||||||
|
self.assertTrue(any("org drift" in r for r in other_org["reasons"]))
|
||||||
|
|
||||||
def test_returned_snapshot_cannot_mutate_binding(self):
|
def test_returned_snapshot_cannot_mutate_binding(self):
|
||||||
session_ctx.bind_session_context(
|
session_ctx.bind_session_context(
|
||||||
profile_name="mdcps-reviewer",
|
profile_name="mdcps-reviewer",
|
||||||
|
|||||||
Reference in New Issue
Block a user