docs: LLM-Agent-SHA opaque attribution convention, Phase 0 (#86)
Implements the Phase 0 owner decision on #86 (issuecomment-1354): - docs/llm-agent-sha.md: format llm-<12 lowercase hex> (^llm-[0-9a-f]{12}$), generation rules, per-PR/workstream lifetime, visible markdown metadata blocks, no SHA in branch/worktree names, same-SHA vs same-user vs same-profile distinction. Attribution only — never an eligibility input. - docs/llm-workflow-runbooks.md: attribution subsection + handoff/review runbook pointers. - templates start-issue.md / review-pr.md: handoff and review metadata blocks; reviewer rule that a different SHA is not a different actor. - tests/test_llm_agent_sha.py: negative tests — same Gitea user with a different LLM-Agent-SHA still fails self-review and self-merge; eligibility results are identical with/without/across SHA env values; no gate accepts or reads any agent-SHA input. No launcher/env handling, no gitea_whoami fields, no PR auto-injection, no audit schema changes. No eligibility behavior changed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user