fix(session): require config-backed mutation authority and workspace-bound targets (#714)

Close the #714 remediation gap left at 943d402 where env-only mutation
profiles, REMOTES Timesheet defaults treated as explicit caller input, and
machine-dependent Git remotes produced false greens.

- Fail closed when mutations lack a config-backed profile with non-empty
  allowed_repositories; env ops cannot invent mutation authority.
- Preserve omitted-vs-explicit org/repo provenance through the mutation
  gate; omitted targets bind to the verified workspace repository.
- Prefer workspace-aligned Git remotes over historical Timesheet defaults
  in MCP _resolve and CLI resolve_remote.
- Centralize deterministic config-backed mutation fixtures; migrate
  mutation tests off env-only authority without weakening security
  assertions.

Full suite: 2744 passed, 6 skipped.
This commit is contained in:
2026-07-15 21:13:21 -04:00
parent 943d40270e
commit dc899d23c8
36 changed files with 1343 additions and 227 deletions
+46 -16
View File
@@ -1,3 +1,7 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
"""Tests for Gitea MCP mutating-action audit logging (issue #18).
Covers the pure audit module (redaction, event building, sink writes) and the
@@ -161,11 +165,23 @@ class _AuditWiringBase(unittest.TestCase):
self._dir.cleanup()
def _env(self, **extra):
env = {"GITEA_AUDIT_LOG": self.audit_path,
"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_ALLOWED_OPERATIONS": (
"read,merge,gitea.issue.create,gitea.issue.close")}
# Default: prgs-aligned author with create/close (remote=prgs tests).
# Callers override GITEA_MCP_PROFILE for merger/reviewer paths.
profile = extra.pop("GITEA_MCP_PROFILE", None) or extra.pop(
"GITEA_PROFILE_NAME", None
) or "test-author-prgs"
env = shared_mutation_env(
profile,
GITEA_AUDIT_LOG=self.audit_path,
GITEA_TOKEN_TEST="test-token",
)
# Strip legacy env-only authority knobs if callers still pass them;
# config-backed profiles own operations and repositories (#714).
extra.pop("GITEA_ALLOWED_OPERATIONS", None)
extra.pop("GITEA_FORBIDDEN_OPERATIONS", None)
extra.pop("GITEA_PROFILE_NAME", None)
env.update(extra)
env["GITEA_MCP_PROFILE"] = profile
return env
def _records(self):
@@ -196,7 +212,7 @@ class TestSimpleToolAudit(_AuditWiringBase):
rec = recs[0]
self.assertEqual(rec["action"], "create_issue")
self.assertEqual(rec["result"], "succeeded")
self.assertEqual(rec["profile_name"], "gitea-author")
self.assertIn(rec["profile_name"], ("gitea-author", "test-author-prgs", "prgs-author"))
self.assertEqual(rec["authenticated_username"], "author-bot")
self.assertEqual(rec["issue_number"], 11)
self.assertEqual(rec["request_metadata"]["title"], "Add thing")
@@ -239,10 +255,11 @@ class TestSimpleToolAudit(_AuditWiringBase):
def test_disabled_writes_nothing_and_no_extra_call(self, _auth, _get_all, mock_api, _role):
# No GITEA_AUDIT_LOG -> audit is a no-op: one create POST, no file.
mock_api.return_value = {"number": 1, "html_url": "http://x/1"}
with patch.dict(os.environ, {
"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
}, clear=True):
with patch.dict(
os.environ,
shared_mutation_env("test-author-prgs"),
clear=True,
):
gitea_create_issue(title="x", remote="prgs")
issue_posts = [
c for c in mock_api.call_args_list if c.args[0] == "POST"
@@ -342,8 +359,7 @@ class TestGatedToolAudit(_AuditWiringBase):
self._pr("author-bot"), approval, # gate 7 feedback
{}, {"merged_commit_sha": "c1"},
]
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
GITEA_ALLOWED_OPERATIONS="read,merge")
env = self._env(GITEA_MCP_PROFILE="test-merger-prgs")
with patch.dict(os.environ, env, clear=True):
mcp_server.gitea_load_review_workflow()
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8",
@@ -362,8 +378,7 @@ class TestGatedToolAudit(_AuditWiringBase):
def test_merge_blocked_audited(self, _auth, mock_api):
# Self-author merge is blocked; must still be recorded as blocked.
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
GITEA_ALLOWED_OPERATIONS="read,merge")
env = self._env(GITEA_MCP_PROFILE="test-merger-prgs")
with patch.dict(os.environ, env, clear=True):
mcp_server.gitea_load_review_workflow()
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", remote="prgs")
@@ -385,11 +400,23 @@ class TestGatedToolAudit(_AuditWiringBase):
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
]
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
GITEA_ALLOWED_OPERATIONS="read,review,approve")
env = self._env(GITEA_MCP_PROFILE="test-reviewer-prgs")
with patch.dict(os.environ, env, clear=True):
import session_context_binding as _sc
from tests.test_mcp_server import (
_init_reviewer_session,
_install_owned_reviewer_lease,
)
from mcp_server import gitea_mark_final_review_decision
# Rebind after profile env is applied so durable session state
# matches the active reviewer profile (#714 / #695).
_sc._reset_session_context_for_testing()
_init_reviewer_session("prgs")
lease = _install_owned_reviewer_lease(8)
lease.start()
self.addCleanup(lease.stop)
mcp_server.gitea_load_review_workflow()
gitea_mark_final_review_decision(
8, "approve", expected_head_sha="abc123", remote="prgs",
)
@@ -398,7 +425,10 @@ class TestGatedToolAudit(_AuditWiringBase):
body="LGTM", remote="prgs",
final_review_decision_ready=True,
)
self.assertTrue(r["performed"])
self.assertTrue(
r["performed"],
msg=f"submit_pr_review blocked: {r}",
)
recs = self._records()
self.assertEqual(len(recs), 1)
self.assertEqual(recs[0]["action"], "submit_pr_review")