fix(session): pin immutable MCP context; ban cross-host profile substitution (Closes #714)

Capability resolution and mutation gates no longer auto-switch profiles.
Session profile/remote/host/identity are bound after pin or first seed,
and drift or cross-host resolution fails closed before writes.

Co-Authored-By: Grok <[email protected]>
This commit is contained in:
2026-07-14 23:05:41 -04:00
co-authored by Grok
parent 1eafb757a9
commit 632c568865
4 changed files with 1164 additions and 165 deletions
@@ -0,0 +1,390 @@
"""#714: fail closed on cross-host MCP profile drift and capability substitution."""
from __future__ import annotations
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import gitea_config # noqa: E402
import mcp_server # noqa: E402
import session_context_binding as session_ctx # noqa: E402
CONFIG_714 = {
"version": 2,
"allow_runtime_switching": True,
"contexts": {
"prgs": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
},
"mdcps": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.dadeschools.net"},
},
},
"profiles": {
"prgs-author": {
"enabled": True,
"context": "prgs",
"role": "author",
"username": "jcwalker3",
"base_url": "https://gitea.prgs.cc",
"auth": {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"},
"allowed_operations": [
"gitea.read",
"gitea.issue.create",
"gitea.issue.comment",
"gitea.issue.close",
"gitea.pr.create",
"gitea.pr.comment",
"gitea.branch.create",
"gitea.branch.push",
"gitea.repo.commit",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.request_changes",
],
"execution_profile": "prgs-author",
},
"mdcps-reviewer": {
"enabled": True,
"context": "mdcps",
"role": "reviewer",
"username": "913443",
"base_url": "https://gitea.dadeschools.net",
"auth": {"type": "env", "name": "GITEA_TOKEN_MDCPS_REVIEWER"},
"allowed_operations": [
"gitea.read",
"gitea.pr.review",
"gitea.pr.comment",
"gitea.pr.approve",
"gitea.pr.request_changes",
],
"forbidden_operations": [
"gitea.branch.create",
"gitea.branch.push",
"gitea.pr.create",
"gitea.pr.merge",
"gitea.repo.commit",
],
"execution_profile": "mdcps-reviewer",
},
"mdcps-author": {
"enabled": True,
"context": "mdcps",
"role": "author",
"username": "913443",
"base_url": "https://gitea.dadeschools.net",
"auth": {"type": "env", "name": "GITEA_TOKEN_MDCPS_AUTHOR"},
"allowed_operations": [
"gitea.read",
"gitea.issue.create",
"gitea.issue.comment",
"gitea.pr.create",
"gitea.pr.comment",
"gitea.branch.create",
"gitea.branch.push",
"gitea.repo.commit",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.request_changes",
],
"execution_profile": "mdcps-author",
},
},
}
class TestIssue714SessionContextImmutability(unittest.TestCase):
def setUp(self):
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG_714))
self._remotes = patch.dict(
mcp_server.REMOTES,
{
"dadeschools": {
"host": "gitea.dadeschools.net",
"org": "913443",
"repo": "eAgenda",
},
"prgs": {
"host": "gitea.prgs.cc",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
},
},
clear=False,
)
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
mcp_server._MUTATION_AUTHORITY = None
session_ctx.clear_session_context()
self._env = {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": "mdcps-reviewer",
"GITEA_TOKEN_MDCPS_REVIEWER": "mdcps-reviewer-token",
"GITEA_TOKEN_MDCPS_AUTHOR": "mdcps-author-token",
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
}
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
mcp_server._MUTATION_AUTHORITY = None
session_ctx.clear_session_context()
self._dir.cleanup()
def _api_side_effect(self, method, url, header):
# Token identity mapping for whoami endpoint
auth = str(header)
if "mdcps-reviewer-token" in auth or "mdcps-author-token" in auth:
login = "913443"
else:
login = "jcwalker3"
return {"login": login, "full_name": "Test", "id": 1, "email": "[email protected]"}
def test_pin_mdcps_reviewer_never_drifts_to_prgs_author(self):
"""Reproduce the incident: pin mdcps-reviewer then resolve comment_issue."""
with patch.dict(os.environ, self._env, clear=False):
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
act = mcp_server.gitea_activate_profile(
profile_name="mdcps-reviewer", remote="dadeschools"
)
self.assertTrue(act["success"])
self.assertEqual(act["after_profile"], "mdcps-reviewer")
who1 = mcp_server.gitea_whoami(remote="dadeschools")
self.assertEqual(who1["profile"]["profile_name"], "mdcps-reviewer")
self.assertEqual(who1["username"], "913443")
# Capability that mdcps-reviewer lacks — must NOT switch to prgs-author
res = mcp_server.gitea_resolve_task_capability(
task="comment_issue", remote="dadeschools"
)
self.assertEqual(res["active_profile"], "mdcps-reviewer")
self.assertFalse(res["allowed_in_current_session"])
self.assertFalse(res.get("auto_profile_substitution", True))
self.assertNotIn("prgs-author", res["matching_configured_profile"])
self.assertIn("mdcps-author", res["matching_configured_profile"])
who2 = mcp_server.gitea_whoami(remote="dadeschools")
rt = mcp_server.gitea_get_runtime_context(remote="dadeschools")
self.assertEqual(who2["profile"]["profile_name"], "mdcps-reviewer")
self.assertEqual(rt["active_profile"], "mdcps-reviewer")
# Still pinned — never prgs-author
self.assertEqual(
gitea_config.selected_profile_name(), "mdcps-reviewer"
)
def test_repeated_calls_remain_on_mdcps_reviewer(self):
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-reviewer", remote="dadeschools"
)
for _ in range(5):
res = mcp_server.gitea_resolve_task_capability(
task="review_pr", remote="dadeschools"
)
self.assertEqual(res["active_profile"], "mdcps-reviewer")
who = mcp_server.gitea_whoami(remote="dadeschools")
self.assertEqual(who["profile"]["profile_name"], "mdcps-reviewer")
rt = mcp_server.gitea_get_runtime_context(remote="dadeschools")
self.assertEqual(rt["active_profile"], "mdcps-reviewer")
def test_dadeschools_request_cannot_resolve_through_prgs_author(self):
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-reviewer", remote="dadeschools"
)
res = mcp_server.gitea_resolve_task_capability(
task="comment_issue", remote="dadeschools"
)
self.assertNotEqual(res["active_profile"], "prgs-author")
self.assertNotIn("prgs-author", res["matching_configured_profile"])
# Gate must not silently switch either
blocked = mcp_server._profile_permission_block(
"gitea.issue.comment", remote="dadeschools"
)
self.assertIsNotNone(blocked)
self.assertFalse(blocked.get("success", True))
self.assertEqual(
gitea_config.selected_profile_name(), "mdcps-reviewer"
)
def test_unsupported_capability_fails_closed_structured(self):
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-reviewer", remote="dadeschools"
)
res = mcp_server.gitea_resolve_task_capability(
task="comment_issue", remote="dadeschools"
)
self.assertFalse(res["allowed_in_current_session"])
self.assertTrue(res["stop_required"])
self.assertIn("reason", res)
self.assertIn("exact_safe_next_action", res)
self.assertIn("activate_profile", res["exact_safe_next_action"])
# Unknown task still fail closed
with self.assertRaises(ValueError):
mcp_server.gitea_resolve_task_capability(
task="reopen_issue", remote="dadeschools"
)
def test_identity_mismatch_blocks_mutation(self):
"""Profile expects 913443 but authenticated as jcwalker3."""
def wrong_identity(method, url, header):
return {
"login": "jcwalker3",
"full_name": "Wrong",
"id": 9,
"email": "[email protected]",
}
with patch.dict(os.environ, self._env, clear=False):
with patch("mcp_server.api_request", side_effect=wrong_identity):
mcp_server.gitea_activate_profile(
profile_name="mdcps-reviewer", remote="dadeschools"
)
blocked = mcp_server._session_context_mutation_block(
remote="dadeschools"
)
self.assertIsNotNone(blocked)
self.assertTrue(
any("identity mismatch" in r for r in blocked["reasons"])
)
def test_repository_host_mismatch_blocks_mutation(self):
"""mdcps-reviewer cannot serve prgs remote."""
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-reviewer", remote="dadeschools"
)
blocked = mcp_server._session_context_mutation_block(remote="prgs")
self.assertIsNotNone(blocked)
self.assertTrue(
any("cross-host" in r for r in blocked["reasons"])
)
def test_drift_cannot_reach_write(self):
"""Simulated mid-session profile override cannot pass permission gate."""
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-reviewer", remote="dadeschools"
)
# Bind session as mdcps-reviewer
self.assertEqual(
session_ctx.get_session_context()["profile_name"],
"mdcps-reviewer",
)
# Hostile override without activate_profile
gitea_config._active_profile_override = "prgs-author"
blocked = mcp_server._session_context_mutation_block(
remote="dadeschools"
)
self.assertIsNotNone(blocked)
self.assertTrue(any("drift" in r or "cross-host" in r for r in blocked["reasons"]))
def test_static_prgs_author_still_works(self):
env = {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": "prgs-author",
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
}
with patch.dict(os.environ, env, clear=False):
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
session_ctx.clear_session_context()
gitea_config._active_profile_override = None
res = mcp_server.gitea_resolve_task_capability(
task="create_issue", remote="prgs"
)
self.assertEqual(res["active_profile"], "prgs-author")
self.assertTrue(res["allowed_in_current_session"])
self.assertEqual(res["active_identity"], "jcwalker3")
def test_static_mdcps_author_still_works(self):
env = {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": "mdcps-author",
"GITEA_TOKEN_MDCPS_AUTHOR": "mdcps-author-token",
}
with patch.dict(os.environ, env, clear=False):
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
session_ctx.clear_session_context()
gitea_config._active_profile_override = None
res = mcp_server.gitea_resolve_task_capability(
task="comment_issue", remote="dadeschools"
)
self.assertEqual(res["active_profile"], "mdcps-author")
self.assertTrue(res["allowed_in_current_session"])
self.assertNotIn("prgs-author", res["matching_configured_profile"])
class TestSessionContextBindingUnit(unittest.TestCase):
def setUp(self):
session_ctx.clear_session_context()
def tearDown(self):
session_ctx.clear_session_context()
def test_profile_matches_remote_by_base_url(self):
remotes = {
"dadeschools": {"host": "gitea.dadeschools.net"},
"prgs": {"host": "gitea.prgs.cc"},
}
mdcps = {"base_url": "https://gitea.dadeschools.net", "context": "mdcps"}
prgs = {"base_url": "https://gitea.prgs.cc", "context": "prgs"}
self.assertTrue(
session_ctx.profile_matches_remote(mdcps, "dadeschools", remotes)
)
self.assertFalse(session_ctx.profile_matches_remote(mdcps, "prgs", remotes))
self.assertTrue(session_ctx.profile_matches_remote(prgs, "prgs", remotes))
def test_bind_and_detect_drift(self):
session_ctx.bind_session_context(
profile_name="mdcps-reviewer",
remote="dadeschools",
host="gitea.dadeschools.net",
identity="913443",
source="test",
)
ok = session_ctx.assess_session_context(
profile_name="mdcps-reviewer",
remote="dadeschools",
host="gitea.dadeschools.net",
identity="913443",
)
self.assertTrue(ok["proven"])
bad = session_ctx.assess_session_context(
profile_name="prgs-author",
remote="dadeschools",
host="gitea.dadeschools.net",
identity="jcwalker3",
)
self.assertTrue(bad["block"])
self.assertTrue(any("drift" in r for r in bad["reasons"]))
if __name__ == "__main__":
unittest.main()
+56 -35
View File
@@ -1,4 +1,9 @@
"""Tests for operation-scoped role selection and automatic dispatch switching (#228)."""
"""Tests for operation-scoped role selection (#228, updated by #714).
#714 removes silent automatic profile substitution. Capability resolution
and mutation gates evaluate only the active profile; explicit
``gitea_activate_profile`` is required to switch roles.
"""
import os
import sys
import json
@@ -11,10 +16,12 @@ sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.par
import gitea_config
import gitea_auth
import mcp_server
import session_context_binding as session_ctx
from reviewer_worktree import assess_author_worktree_continuity
CONFIG_TEST = {
"version": 2,
"allow_runtime_switching": True,
"contexts": {
"ctx": {
"enabled": True,
@@ -30,6 +37,7 @@ CONFIG_TEST = {
"context": "ctx",
"role": "author",
"username": "author-user",
"base_url": "https://gitea.example.com",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read", "gitea.issue.create", "gitea.pr.create", "gitea.branch.push", "gitea.issue.comment"],
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
@@ -40,6 +48,7 @@ CONFIG_TEST = {
"context": "ctx",
"role": "reviewer",
"username": "reviewer-user",
"base_url": "https://gitea.example.com",
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.issue.comment"],
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
@@ -59,6 +68,7 @@ class TestOperationScopedRoles(unittest.TestCase):
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
mcp_server._MUTATION_AUTHORITY = None
session_ctx.clear_session_context()
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
self._write_config(CONFIG_TEST)
@@ -68,6 +78,7 @@ class TestOperationScopedRoles(unittest.TestCase):
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
mcp_server._MUTATION_AUTHORITY = None
session_ctx.clear_session_context()
self._dir.cleanup()
def _write_config(self, obj):
@@ -85,62 +96,71 @@ class TestOperationScopedRoles(unittest.TestCase):
return env
@patch("mcp_server.api_request")
def test_auto_switch_to_reviewer(self, mock_api):
# mock identity resolution to return username matching profile
def test_no_auto_switch_to_reviewer(self, mock_api):
# #714: capability resolution must not silently switch author → reviewer
mock_api.side_effect = lambda method, url, header: (
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
)
with patch.dict(os.environ, self._env("author-profile")):
# initially we are author-profile
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
self.assertEqual(mcp_server.get_profile()["profile_name"], "author-profile")
# resolve a reviewer task (review_pr)
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
# verify it automatically switched to reviewer-profile
self.assertTrue(res["allowed_in_current_session"])
self.assertTrue(res["available_in_session"])
self.assertEqual(res["active_profile"], "reviewer-profile")
self.assertEqual(res["active_identity"], "reviewer-user")
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
self.assertFalse(res["allowed_in_current_session"])
self.assertFalse(res["available_in_session"])
self.assertEqual(res["active_profile"], "author-profile")
self.assertEqual(res["active_identity"], "author-user")
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
self.assertIn("reviewer-profile", res["matching_configured_profile"])
self.assertFalse(res.get("auto_profile_substitution", True))
@patch("mcp_server.api_request")
def test_auto_switch_to_author(self, mock_api):
def test_no_auto_switch_to_author(self, mock_api):
mock_api.side_effect = lambda method, url, header: (
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
)
with patch.dict(os.environ, self._env("reviewer-profile")):
# initially we are reviewer-profile
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
# resolve an author task (create_issue)
res = mcp_server.gitea_resolve_task_capability(task="create_issue", remote="prgs")
# verify it automatically switched to author-profile
self.assertTrue(res["allowed_in_current_session"])
self.assertTrue(res["available_in_session"])
self.assertEqual(res["active_profile"], "author-profile")
self.assertEqual(res["active_identity"], "author-user")
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
self.assertFalse(res["allowed_in_current_session"])
self.assertFalse(res["available_in_session"])
self.assertEqual(res["active_profile"], "reviewer-profile")
self.assertEqual(res["active_identity"], "reviewer-user")
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
self.assertIn("author-profile", res["matching_configured_profile"])
@patch("mcp_server.api_request")
def test_restart_required_when_unattached(self, mock_api):
def test_explicit_activate_profile_still_works(self, mock_api):
mock_api.side_effect = lambda method, url, header: (
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
)
with patch.dict(os.environ, self._env("author-profile")):
act = mcp_server.gitea_activate_profile(
profile_name="reviewer-profile", remote="prgs"
)
self.assertTrue(act["success"])
self.assertEqual(act["after_profile"], "reviewer-profile")
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
self.assertTrue(res["allowed_in_current_session"])
self.assertEqual(res["active_profile"], "reviewer-profile")
@patch("mcp_server.api_request")
def test_denied_when_matching_profile_token_missing(self, mock_api):
mock_api.side_effect = lambda method, url, header: {"login": "author-user"}
# launch without reviewer token in env
with patch.dict(os.environ, self._env("author-profile", with_reviewer_token=False)):
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
# resolve a reviewer task (review_pr)
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
# verify it did NOT switch and reports restart_required
self.assertFalse(res["allowed_in_current_session"])
self.assertFalse(res["available_in_session"])
self.assertTrue(res["configured"])
self.assertTrue(res["restart_required"])
self.assertTrue(res["stop_required"])
self.assertIn("Reviewer profile exists but MCP server was added after session startup and is not attached", res["reason"])
self.assertEqual(res["active_profile"], "author-profile")
def test_author_continuity_dirty_worktree(self):
# author is allowed to keep dirty worktree
@@ -161,20 +181,21 @@ class TestOperationScopedRoles(unittest.TestCase):
self.assertFalse(res2["proven"])
@patch("mcp_server.api_request")
def test_mutating_actions_auto_switch(self, mock_api):
def test_mutating_actions_do_not_auto_switch(self, mock_api):
mock_api.side_effect = lambda method, url, header: (
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
)
with patch.dict(os.environ, self._env("author-profile")):
# verify we are author-profile
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
# call a reviewer mutation check helper (like _profile_permission_block with reviewer permission)
blocked = mcp_server._profile_permission_block("gitea.pr.merge", remote="prgs")
self.assertIsNone(blocked) # should switch to reviewer-profile and allow it (no permission block)
blocked = mcp_server._profile_permission_block(
"gitea.pr.merge", remote="prgs"
)
self.assertIsNotNone(blocked)
self.assertFalse(blocked.get("success", True))
# verify we dynamically switched to reviewer-profile
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
# profile must remain author — no silent substitution
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
if __name__ == "__main__":