"""Negative tests for LLM-Agent-SHA attribution (#86, Phase 0). ``LLM-Agent-SHA`` (docs/llm-agent-sha.md) is attribution metadata ONLY. These tests prove it can never bypass the review/merge safety gates: 1. Same Gitea user + different LLM-Agent-SHA still fails self-review/approval. 2. Same Gitea user + different LLM-Agent-SHA still fails self-merge. 3. The eligibility logic does not consult SHA metadata at all — results are identical with no SHA, one SHA, or a different SHA in the environment, and no gate accepts an agent-SHA input. Phase 0 adds no SHA support to any MCP tool; the environment variables set here (``LLM_AGENT_SHA`` / ``LLM_AGENT_ROLE``) simulate a future launcher and must be ignored by every gate. """ import inspect import os import re import sys import unittest from unittest.mock import patch sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) from mcp_server import ( # noqa: E402 gitea_check_pr_eligibility, gitea_merge_pr, gitea_review_pr, gitea_submit_pr_review, ) FAKE_AUTH = "Basic dGVzdDp0ZXN0" SHA_PATTERN = re.compile(r"^llm-[0-9a-f]{12}$") # Two distinct, well-formed agent SHAs "belonging" to the same Gitea user. SHA_IMPLEMENTER = "llm-8f3a9c2d6b41" SHA_WOULD_BE_REVIEWER = "llm-41d0e7aa9f2c" def _pr(author, state="open", sha="abc123", mergeable=True): return { "user": {"login": author}, "state": state, "head": {"sha": sha}, "mergeable": mergeable, } class TestShaFormatConvention(unittest.TestCase): """The documented format: llm-<12 lowercase hex>, nothing identifying.""" def test_documented_examples_are_valid(self): for value in (SHA_IMPLEMENTER, SHA_WOULD_BE_REVIEWER, "llm-b7c93d441a08"): self.assertRegex(value, SHA_PATTERN) def test_identifying_or_malformed_values_are_rejected(self): for bad in ( "llm-8F3A9C2D6B41", # uppercase "llm-8f3a9c2d6b4", # too short "llm-8f3a9c2d6b411", # too long "llm-opus4", # model name, not hex "claude-8f3a9c2d6b41", # provider prefix "jcwalker3", # username "llm-user@example.com", # email "8f3a9c2d6b41", # missing prefix "", ): self.assertNotRegex(bad, SHA_PATTERN) class TestShaCannotBypassSelfReview(unittest.TestCase): """Scenario: user A (SHA 1) authored the PR; user A (SHA 2) tries to act.""" def _env(self, agent_sha, role): # Reviewer-capable profile + a simulated launcher-injected agent SHA. return { "GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_ALLOWED_OPERATIONS": "read,review,approve,merge", "LLM_AGENT_SHA": agent_sha, "LLM_AGENT_ROLE": role, } @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_same_user_different_sha_cannot_approve(self, _auth, mock_api): mock_api.side_effect = [{"login": "jcwalker3"}, _pr("jcwalker3")] env = self._env(SHA_WOULD_BE_REVIEWER, "reviewer") with patch.dict(os.environ, env, clear=True): r = gitea_check_pr_eligibility(pr_number=9, action="approve", remote="prgs") self.assertFalse(r["eligible"]) self.assertIn("authenticated user is PR author", r["reasons"]) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_same_user_different_sha_cannot_merge(self, _auth, mock_api): mock_api.side_effect = [{"login": "jcwalker3"}, _pr("jcwalker3")] env = self._env(SHA_WOULD_BE_REVIEWER, "merger") with patch.dict(os.environ, env, clear=True): r = gitea_check_pr_eligibility(pr_number=9, action="merge", remote="prgs") self.assertFalse(r["eligible"]) self.assertIn("authenticated user is PR author", r["reasons"]) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_gated_merge_tool_refuses_self_merge_despite_sha(self, _auth, mock_api): # Even the fully-confirmed gated merge path must refuse: correct # confirmation string, mergeable PR, merge-capable profile — but the # authenticated user is the PR author, whatever the agent SHA says. mock_api.side_effect = [{"login": "jcwalker3"}, _pr("jcwalker3")] env = self._env(SHA_WOULD_BE_REVIEWER, "merger") with patch.dict(os.environ, env, clear=True): r = gitea_merge_pr( pr_number=9, confirmation="MERGE PR 9", remote="prgs") self.assertFalse(r.get("performed")) for call in mock_api.call_args_list: method, url = call.args[0], call.args[1] self.assertFalse( method == "POST" and url.endswith("/merge"), f"self-merge mutation reached the API: {method} {url}", ) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_review_tool_refuses_self_approval_despite_sha(self, _auth, mock_api): mock_api.side_effect = [{"login": "jcwalker3"}, _pr("jcwalker3")] env = self._env(SHA_WOULD_BE_REVIEWER, "reviewer") with patch.dict(os.environ, env, clear=True): r = gitea_review_pr( pr_number=9, event="APPROVE", body="self approve", merge=False, remote="prgs") self.assertFalse(r["success"]) self.assertIn("authenticated user is PR author", r["message"]) for call in mock_api.call_args_list: method, url = call.args[0], call.args[1] self.assertFalse( method == "POST" and url.endswith("/reviews"), f"self-review mutation reached the API: {method} {url}", ) class TestEligibilityNeverConsultsSha(unittest.TestCase): """The gates have no SHA input: not via env, not via parameters.""" def _run_eligibility(self, extra_env): env = { "GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_ALLOWED_OPERATIONS": "read,review,approve", } env.update(extra_env) with patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \ patch("mcp_server.api_request") as mock_api: mock_api.side_effect = [{"login": "reviewer-bot"}, _pr("author-bot")] with patch.dict(os.environ, env, clear=True): return gitea_check_pr_eligibility( pr_number=5, action="approve", remote="prgs") def test_result_identical_with_without_and_across_shas(self): baseline = self._run_eligibility({}) with_one = self._run_eligibility( {"LLM_AGENT_SHA": SHA_IMPLEMENTER, "LLM_AGENT_ROLE": "implementer"}) with_other = self._run_eligibility( {"LLM_AGENT_SHA": SHA_WOULD_BE_REVIEWER, "LLM_AGENT_ROLE": "reviewer"}) self.assertEqual(baseline, with_one) self.assertEqual(baseline, with_other) self.assertTrue(baseline["eligible"]) # sanity: a real decision ran def test_eligibility_result_carries_no_agent_sha(self): r = self._run_eligibility( {"LLM_AGENT_SHA": SHA_IMPLEMENTER, "LLM_AGENT_ROLE": "implementer"}) blob = repr(r) self.assertNotIn(SHA_IMPLEMENTER, blob) self.assertNotIn("LLM_AGENT", blob) def test_gate_functions_accept_no_agent_sha_parameter(self): for fn in (gitea_check_pr_eligibility, gitea_merge_pr, gitea_review_pr, gitea_submit_pr_review): for param in inspect.signature(fn).parameters: lowered = param.lower() self.assertNotIn("agent", lowered, f"{fn.__name__} accepts agent param {param!r}") self.assertNotIn("llm", lowered, f"{fn.__name__} accepts llm param {param!r}") def test_gate_sources_never_read_agent_sha(self): # Phase 0 guarantee: no gate reads LLM_AGENT_* metadata at all. for fn in (gitea_check_pr_eligibility, gitea_merge_pr, gitea_review_pr, gitea_submit_pr_review): src = inspect.getsource(fn) self.assertNotIn("LLM_AGENT", src, f"{fn.__name__} reads LLM_AGENT metadata") self.assertNotIn("agent_sha", src, f"{fn.__name__} consults an agent SHA") if __name__ == "__main__": unittest.main()