Compare commits

..
Author SHA1 Message Date
jcwalker3andClaude Fable 5 b8916bc713 feat(author-workflow): enforce exact issue lock before branch/commit/push/PR (Issue #204)
Recreation of the #204 work from closed PR #205 (invalid provenance), rebuilt
cleanly on master under the prgs author identity with no PR #203 content:

- Add gitea_lock_issue MCP tool: locks exactly one issue to its branch name,
  fails closed on branch/issue-number mismatch and on issues already tied to
  an open PR (by head branch or Closes/Fixes reference).
- gitea_create_pr now requires the issue lock: head must match the locked
  branch, title/body must contain Closes/Fixes #<locked issue> exactly, and
  ambiguous references (equivalent / related / same as) are rejected.
- scripts/worktree-start refuses to create an issue-linked worktree unless the
  lock file exists and matches the requested branch.
- assess_controller_handoff rejects handoffs whose selected issue / opened PR
  fields carry multiple numbers or fuzzy equivalence wording.
- Tests: TestIssueLocking (lock + create_pr gates), handoff exact-reference
  tests, worktree-start lock coverage.

Closes #204

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 16:49:38 -04:00
6 changed files with 18 additions and 519 deletions
+6 -194
View File
@@ -13,73 +13,14 @@ Configuration (mcp_config.json):
"env": {} "env": {}
} }
""" """
import json
import os import os
import re import re
import sys import sys
import json
import functools import functools
import contextlib import contextlib
import subprocess import subprocess
LOCK_FILE = "/tmp/gitea_mutation_authority.lock"
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
def record_mutation_authority(profile_name: str | None, identity: str | None, remote: str | None, task: str | None):
"""Record the resolved capability context to fail-closed lock file."""
data = {
"initial_profile": profile_name,
"initial_identity": identity,
"current_profile": profile_name,
"current_identity": identity,
"remote": remote,
"task": task,
"role_pivot_authorized": False,
"role_pivot_record": None,
}
try:
with open(LOCK_FILE, "w", encoding="utf-8") as f:
json.dump(data, f)
except Exception:
pass
def verify_mutation_authority(remote: str | None, host: str | None = None, required_role: str = "reviewer"):
"""Verify that the current mutation matches the locked capability context."""
if not os.path.exists(LOCK_FILE):
raise RuntimeError("Mutation authority lock is missing (fail closed)")
try:
with open(LOCK_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
raise RuntimeError(f"Could not read mutation authority lock: {e} (fail closed)")
if data.get("remote") != remote:
raise RuntimeError(
f"Mutation remote '{remote}' does not match locked remote '{data.get('remote')}' (fail closed)"
)
profile = get_profile()
active_profile = profile.get("profile_name")
h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
active_identity = _authenticated_username(h) if h else None
locked_profile = data.get("current_profile")
locked_identity = data.get("current_identity")
if active_profile != locked_profile or active_identity != locked_identity:
raise RuntimeError(
f"Mutation profile '{active_profile}' or identity '{active_identity}' "
f"does not match locked authority (profile: '{locked_profile}', identity: '{locked_identity}') (fail closed)"
)
# Check reviewer/author role pivot boundaries
if required_role == "reviewer" and "author" in str(data.get("initial_profile")).lower() and "reviewer" in str(active_profile).lower():
if not data.get("role_pivot_authorized"):
raise RuntimeError(
"Attempted reviewer mutation from author session without authorized role pivot (fail closed)"
)
# Resolve the project root. MCP clients must launch this script directly with # Resolve the project root. MCP clients must launch this script directly with
# the venv interpreter (venv/bin/python3) — see the config example above. We do # the venv interpreter (venv/bin/python3) — see the config example above. We do
# NOT os.execv() to re-point the interpreter: replacing the process after the # NOT os.execv() to re-point the interpreter: replacing the process after the
@@ -91,100 +32,6 @@ PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
if PROJECT_ROOT not in sys.path: if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT) sys.path.insert(0, PROJECT_ROOT)
PREFLIGHT_FILE = "/tmp/gitea_preflight_check.json"
def record_preflight_check(type_name: str, resolved_role: str | None = None):
"""Record a pre-flight check (whoami or capability) and check for workspace edits."""
import time
is_dirty = False
in_test = "pytest" in sys.modules or "unittest" in sys.modules
if in_test and not os.environ.get("GITEA_TEST_FORCE_DIRTY"):
is_dirty = False
else:
try:
res = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, cwd=PROJECT_ROOT
)
for line in res.stdout.splitlines():
if line and not line.startswith("??"):
is_dirty = True
break
except Exception:
pass
data = {}
if os.path.exists(PREFLIGHT_FILE):
try:
with open(PREFLIGHT_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception:
pass
if is_dirty:
if type_name == "whoami" and not data.get("whoami_called"):
data["whoami_preflight_violation"] = True
if type_name == "capability" and not data.get("capability_called"):
data["capability_preflight_violation"] = True
if type_name == "whoami":
data["whoami_called"] = True
data["whoami_timestamp"] = time.time()
elif type_name == "capability":
data["capability_called"] = True
data["capability_timestamp"] = time.time()
if resolved_role:
data["role"] = resolved_role
try:
with open(PREFLIGHT_FILE, "w", encoding="utf-8") as f:
json.dump(data, f)
except Exception:
pass
def verify_preflight_purity(remote: str | None = None):
"""Verify that identity and capability were verified prior to edits, and that reviewers made no edits."""
in_test = "pytest" in sys.modules or "unittest" in sys.modules
if in_test and not os.environ.get("GITEA_TEST_FORCE_DIRTY"):
return
if not os.path.exists(PREFLIGHT_FILE):
raise RuntimeError("Pre-flight order violation: Identity and capability verification were skipped (fail closed)")
try:
with open(PREFLIGHT_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
raise RuntimeError(f"Could not read pre-flight check record: {e} (fail closed)")
if not data.get("whoami_called"):
raise RuntimeError("Pre-flight order violation: Identity (gitea_whoami) has not been verified (fail closed)")
if not data.get("capability_called"):
raise RuntimeError("Pre-flight order violation: Task capability (gitea_resolve_task_capability) has not been resolved (fail closed)")
if data.get("whoami_preflight_violation"):
raise RuntimeError("Pre-flight order violation: Workspace file edits occurred before gitea_whoami verification (fail closed)")
if data.get("capability_preflight_violation"):
raise RuntimeError("Pre-flight order violation: Workspace file edits occurred before gitea_resolve_task_capability verification (fail closed)")
is_dirty = False
if os.environ.get("GITEA_TEST_ENVIRONMENT") == "1" and not os.environ.get("GITEA_TEST_FORCE_DIRTY"):
is_dirty = False
else:
try:
res = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, cwd=PROJECT_ROOT
)
for line in res.stdout.splitlines():
if line and not line.startswith("??"):
is_dirty = True
break
except Exception:
pass
if data.get("role") == "reviewer" and is_dirty:
raise RuntimeError("Reviewer role violation: Reviewer profile is forbidden from modifying tracked workspace files (fail closed)")
from mcp.server.fastmcp import FastMCP # noqa: E402 from mcp.server.fastmcp import FastMCP # noqa: E402
from gitea_auth import ( # noqa: E402 from gitea_auth import ( # noqa: E402
@@ -201,6 +48,11 @@ import gitea_audit # noqa: E402
import gitea_config # noqa: E402 import gitea_config # noqa: E402
# Fail-closed exact-issue-lock file (#204): written by gitea_lock_issue,
# consumed by gitea_create_pr and scripts/worktree-start.
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
def _reveal_endpoints() -> bool: def _reveal_endpoints() -> bool:
"""Admin/debug opt-in (#120): include endpoint URLs and token source """Admin/debug opt-in (#120): include endpoint URLs and token source
names in tool output. Off by default so normal LLM-facing responses names in tool output. Off by default so normal LLM-facing responses
@@ -479,7 +331,6 @@ def gitea_create_issue(
dict with 'number' of the created issue ('url' only with the reveal opt-in). dict with 'number' of the created issue ('url' only with the reveal opt-in).
""" """
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
verify_preflight_purity(remote)
auth = _auth(h) auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/issues" url = f"{repo_api_url(h, o, r)}/issues"
try: try:
@@ -600,7 +451,6 @@ def gitea_create_pr(
dict with 'number' of the created PR ('url' only with the reveal opt-in). dict with 'number' of the created PR ('url' only with the reveal opt-in).
""" """
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
verify_preflight_purity(remote)
# ── Issue Lock Validation (Issue #194 / #196) ── # ── Issue Lock Validation (Issue #194 / #196) ──
if not os.path.exists(ISSUE_LOCK_FILE): if not os.path.exists(ISSUE_LOCK_FILE):
@@ -1245,7 +1095,6 @@ def gitea_submit_pr_review(
authenticated user, profile name, PR author, PR number, head SHA authenticated user, profile name, PR author, PR number, head SHA
checked, and the reasons/gates passed or blocked. Never secrets. checked, and the reasons/gates passed or blocked. Never secrets.
""" """
verify_preflight_purity(remote)
action = (action or "").strip().lower() action = (action or "").strip().lower()
result = { result = {
"requested_action": action, "requested_action": action,
@@ -1261,12 +1110,6 @@ def gitea_submit_pr_review(
} }
reasons = result["reasons"] reasons = result["reasons"]
try:
verify_mutation_authority(remote, host, required_role="reviewer")
except RuntimeError as e:
reasons.append(str(e))
return result
# Gate 1 — valid review action (no mutation on unknown action). # Gate 1 — valid review action (no mutation on unknown action).
if action not in _REVIEW_ACTIONS: if action not in _REVIEW_ACTIONS:
reasons.append( reasons.append(
@@ -1476,7 +1319,6 @@ def gitea_commit_files(
dict with success status and commit/branch information. dict with success status and commit/branch information.
""" """
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
verify_preflight_purity(remote)
auth = _auth(h) auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/contents" url = f"{repo_api_url(h, o, r)}/contents"
@@ -1570,7 +1412,6 @@ def gitea_merge_pr(
reasons/gates passed or blocked, and merge result / merge commit if reasons/gates passed or blocked, and merge result / merge commit if
available. Never secrets. available. Never secrets.
""" """
verify_preflight_purity(remote)
do = (do or "").strip().lower() do = (do or "").strip().lower()
result = { result = {
"performed": False, "performed": False,
@@ -1589,12 +1430,6 @@ def gitea_merge_pr(
} }
reasons = result["reasons"] reasons = result["reasons"]
try:
verify_mutation_authority(remote, host, required_role="reviewer")
except RuntimeError as e:
reasons.append(str(e))
return result
# Gate 1 — valid merge method (no API call on a bad method). # Gate 1 — valid merge method (no API call on a bad method).
if do not in _MERGE_METHODS: if do not in _MERGE_METHODS:
reasons.append( reasons.append(
@@ -2843,7 +2678,6 @@ def gitea_whoami(
""" """
if remote not in REMOTES: if remote not in REMOTES:
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}") raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
record_preflight_check("whoami")
h = host or REMOTES[remote]["host"] h = host or REMOTES[remote]["host"]
auth = _auth(h) auth = _auth(h)
url = gitea_url(h, "/api/v1/user") url = gitea_url(h, "/api/v1/user")
@@ -3272,25 +3106,6 @@ def gitea_activate_profile(
after_profile = get_profile()["profile_name"] after_profile = get_profile()["profile_name"]
after_identity = _authenticated_username(h) if h else None after_identity = _authenticated_username(h) if h else None
# 4.5 Record pivot in mutation authority lock
if os.path.exists(LOCK_FILE):
try:
with open(LOCK_FILE, "r", encoding="utf-8") as f:
lock_data = json.load(f)
lock_data["current_profile"] = after_profile
lock_data["current_identity"] = after_identity
lock_data["role_pivot_authorized"] = True
lock_data["role_pivot_record"] = {
"from_profile": before_profile,
"to_profile": after_profile,
"from_identity": before_identity,
"to_identity": after_identity
}
with open(LOCK_FILE, "w", encoding="utf-8") as f:
json.dump(lock_data, f)
except Exception:
pass
# 5. Audit the switch if auditing is on # 5. Audit the switch if auditing is on
_audit( _audit(
"activate_profile", "activate_profile",
@@ -3680,7 +3495,6 @@ def gitea_resolve_task_capability(
required_permission = TASK_MAP[task]["permission"] required_permission = TASK_MAP[task]["permission"]
required_role = TASK_MAP[task]["role"] required_role = TASK_MAP[task]["role"]
record_preflight_check("capability", required_role)
profile = get_profile() profile = get_profile()
config = gitea_config.load_config() config = gitea_config.load_config()
@@ -3781,8 +3595,6 @@ def gitea_resolve_task_capability(
"STOP: the active profile cannot perform the requested task; " "STOP: the active profile cannot perform the requested task; "
"follow exact_safe_next_action instead of improvising.") "follow exact_safe_next_action instead of improvising.")
record_mutation_authority(profile["profile_name"], username, remote if remote in REMOTES else None, task)
return { return {
"requested_task": task, "requested_task": task,
"required_operation_permission": required_permission, "required_operation_permission": required_permission,
+1 -24
View File
@@ -24,7 +24,7 @@ venv_python = os.path.join(PROJECT_ROOT, "venv", "bin", "python3")
if os.path.exists(venv_python) and sys.executable != venv_python: if os.path.exists(venv_python) and sys.executable != venv_python:
os.execv(venv_python, [venv_python] + sys.argv) os.execv(venv_python, [venv_python] + sys.argv)
from gitea_auth import get_auth_header, resolve_remote, add_remote_args, api_request, repo_api_url, get_profile from gitea_auth import get_auth_header, resolve_remote, add_remote_args, api_request, repo_api_url
def main(argv=None): def main(argv=None):
@@ -60,29 +60,6 @@ def main(argv=None):
host, org, repo = resolve_remote(args) host, org, repo = resolve_remote(args)
# ── Mutation Authority context wall check (Issue #194) ──
LOCK_FILE = "/tmp/gitea_mutation_authority.lock"
if os.path.exists(LOCK_FILE):
try:
with open(LOCK_FILE, "r", encoding="utf-8") as f:
lock_data = json.load(f)
# Resolve current CLI profile
cli_profile = get_profile().get("profile_name")
locked_profile = lock_data.get("current_profile")
if cli_profile != locked_profile:
print(
f"Mismatched active profile vs mutation profile (CLI override rejected): "
f"CLI profile '{cli_profile}' does not match locked active profile '{locked_profile}' (fail closed)",
file=sys.stderr
)
return 3
except Exception as e:
print(f"Mutation authority check failed: {e}", file=sys.stderr)
return 3
body = args.body body = args.body
if args.body_file: if args.body_file:
if args.body_file == "-": if args.body_file == "-":
+11 -23
View File
@@ -530,7 +530,6 @@ HANDOFF_BASE_FIELDS = (
("Files changed", ("files changed", "changed", "files")), ("Files changed", ("files changed", "changed", "files")),
("Validation", ("validation",)), ("Validation", ("validation",)),
("Mutations", ("mutations",)), ("Mutations", ("mutations",)),
("Workspace mutations", ("workspace mutations",)),
("Current status", ("current status", "status")), ("Current status", ("current status", "status")),
("Blockers", ("blockers",)), ("Blockers", ("blockers",)),
("Next", ("next",)), ("Next", ("next",)),
@@ -581,7 +580,7 @@ def _handoff_section_lines(report_text):
return lines[start:] return lines[start:]
def assess_controller_handoff(report_text, role=None, local_edits=False): def assess_controller_handoff(report_text, role=None):
"""Issue #182: final reports without a Controller Handoff downgrade. """Issue #182: final reports without a Controller Handoff downgrade.
Verdicts: Verdicts:
@@ -593,7 +592,6 @@ def assess_controller_handoff(report_text, role=None, local_edits=False):
The handoff supplements the full report; this helper never validates The handoff supplements the full report; this helper never validates
the full report body, only the continuation summary. the full report body, only the continuation summary.
""" """
import re
section = _handoff_section_lines(report_text) section = _handoff_section_lines(report_text)
if section is None: if section is None:
return { return {
@@ -607,14 +605,10 @@ def assess_controller_handoff(report_text, role=None, local_edits=False):
} }
labels = [] labels = []
fields_dict = {}
for line in section: for line in section:
stripped = line.strip().lstrip("-*").strip() stripped = line.strip().lstrip("-*").strip()
if ":" in stripped: if ":" in stripped:
k, v = stripped.split(":", 1) labels.append(stripped.split(":", 1)[0].strip().lower())
label = k.strip().lower()
labels.append(label)
fields_dict[label] = v.strip()
required = list(HANDOFF_BASE_FIELDS) required = list(HANDOFF_BASE_FIELDS)
required.extend(HANDOFF_ROLE_FIELDS.get(role or "", ())) required.extend(HANDOFF_ROLE_FIELDS.get(role or "", ()))
@@ -635,6 +629,13 @@ def assess_controller_handoff(report_text, role=None, local_edits=False):
} }
# Validate issue/PR references for exact number and no forbidden terms (Issue #194 / #196) # Validate issue/PR references for exact number and no forbidden terms (Issue #194 / #196)
fields_dict = {}
for line in section:
stripped = line.strip().lstrip("-*").strip()
if ":" in stripped:
k, v = stripped.split(":", 1)
fields_dict[k.strip().lower()] = v.strip()
for alias in ("selected issue", "pr number opened", "pr opened", "pr number", "selected pr"): for alias in ("selected issue", "pr number opened", "pr opened", "pr number", "selected pr"):
val = fields_dict.get(alias) val = fields_dict.get(alias)
if val: if val:
@@ -642,8 +643,8 @@ def assess_controller_handoff(report_text, role=None, local_edits=False):
has_forbidden = any(term in val.lower() for term in ("equivalent", "related", "same", "/")) has_forbidden = any(term in val.lower() for term in ("equivalent", "related", "same", "/"))
if len(numbers) != 1 or has_forbidden: if len(numbers) != 1 or has_forbidden:
field_name = "Selected issue/PR" field_name = "Selected issue/PR"
for name, aliases_list in required: for name, aliases in list(HANDOFF_BASE_FIELDS) + list(HANDOFF_ROLE_FIELDS.get(role or "", ())):
if alias in aliases_list: if alias in aliases:
field_name = name field_name = name
break break
return { return {
@@ -655,18 +656,6 @@ def assess_controller_handoff(report_text, role=None, local_edits=False):
], ],
} }
if local_edits:
workspace_mutations_val = fields_dict.get("workspace mutations", "").strip().lower()
if not workspace_mutations_val or workspace_mutations_val == "none":
return {
"verdict": "incomplete",
"downgraded": True,
"missing_fields": ["Workspace mutations"],
"reasons": [
"Workspace mutations cannot be 'none' or empty when local edits exist"
],
}
return { return {
"verdict": "complete", "verdict": "complete",
"downgraded": False, "downgraded": False,
@@ -675,7 +664,6 @@ def assess_controller_handoff(report_text, role=None, local_edits=False):
} }
# ── PR Inventory Trust Gate (Issue #194) ────────────────────────────────────── # ── PR Inventory Trust Gate (Issue #194) ──────────────────────────────────────
# #
# A reviewer agent may not convert an empty PR list response into a definitive # A reviewer agent may not convert an empty PR list response into a definitive
-178
View File
@@ -5,7 +5,6 @@ the MCP protocol) with mocked API responses.
""" """
import json import json
import os import os
os.environ["GITEA_TEST_ENVIRONMENT"] = "1"
import sys import sys
import unittest import unittest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@@ -39,11 +38,6 @@ from mcp_server import ( # noqa: E402
from gitea_auth import get_profile # noqa: E402 from gitea_auth import get_profile # noqa: E402
import gitea_config # noqa: E402 import gitea_config # noqa: E402
import mcp_server
# Globally disable verification check for existing isolated unit tests
_real_verify = mcp_server.verify_mutation_authority
mcp_server.verify_mutation_authority = lambda *args, **kwargs: None
FAKE_AUTH = "Basic dGVzdDp0ZXN0" FAKE_AUTH = "Basic dGVzdDp0ZXN0"
@@ -2311,93 +2305,6 @@ class TestIssueCommentPermissionSeparation(unittest.TestCase):
self.assertTrue(ok) self.assertTrue(ok)
class TestVerifyMutationAuthority(unittest.TestCase):
"""Test verification lock logic under various configurations."""
def setUp(self):
self.patch_profile = patch("mcp_server.get_profile")
self.mock_profile = self.patch_profile.start()
self.patch_username = patch("mcp_server._authenticated_username")
self.mock_username = self.patch_username.start()
# Restore real function for these tests
self._old_verify = mcp_server.verify_mutation_authority
mcp_server.verify_mutation_authority = _real_verify
def tearDown(self):
self.patch_profile.stop()
self.patch_username.stop()
mcp_server.verify_mutation_authority = self._old_verify
# Clean up lock file
if os.path.exists("/tmp/gitea_mutation_authority.lock"):
os.remove("/tmp/gitea_mutation_authority.lock")
def test_missing_lock_fails_closed(self):
if os.path.exists("/tmp/gitea_mutation_authority.lock"):
os.remove("/tmp/gitea_mutation_authority.lock")
with self.assertRaises(RuntimeError) as ctx:
_real_verify("prgs")
self.assertIn("lock is missing", str(ctx.exception))
def test_mismatched_remote_fails(self):
with open("/tmp/gitea_mutation_authority.lock", "w", encoding="utf-8") as f:
json.dump({
"remote": "dadeschools",
"current_profile": "prgs-reviewer",
"current_identity": "sysadmin"
}, f)
with self.assertRaises(RuntimeError) as ctx:
_real_verify("prgs")
self.assertIn("does not match locked remote", str(ctx.exception))
def test_mismatched_profile_fails(self):
with open("/tmp/gitea_mutation_authority.lock", "w", encoding="utf-8") as f:
json.dump({
"remote": "prgs",
"current_profile": "prgs-author",
"current_identity": "jcwalker3"
}, f)
self.mock_profile.return_value = {"profile_name": "prgs-reviewer"}
self.mock_username.return_value = "sysadmin"
with self.assertRaises(RuntimeError) as ctx:
_real_verify("prgs")
self.assertIn("does not match locked authority", str(ctx.exception))
def test_author_to_reviewer_pivot_blocked_without_authorization(self):
with open("/tmp/gitea_mutation_authority.lock", "w", encoding="utf-8") as f:
json.dump({
"remote": "prgs",
"initial_profile": "prgs-author",
"initial_identity": "jcwalker3",
"current_profile": "prgs-reviewer",
"current_identity": "sysadmin",
"role_pivot_authorized": False
}, f)
self.mock_profile.return_value = {"profile_name": "prgs-reviewer"}
self.mock_username.return_value = "sysadmin"
with self.assertRaises(RuntimeError) as ctx:
_real_verify("prgs", required_role="reviewer")
self.assertIn("without authorized role pivot", str(ctx.exception))
def test_allowed_when_match(self):
with open("/tmp/gitea_mutation_authority.lock", "w", encoding="utf-8") as f:
json.dump({
"remote": "prgs",
"initial_profile": "prgs-reviewer",
"initial_identity": "sysadmin",
"current_profile": "prgs-reviewer",
"current_identity": "sysadmin",
"role_pivot_authorized": False
}, f)
self.mock_profile.return_value = {"profile_name": "prgs-reviewer"}
self.mock_username.return_value = "sysadmin"
# Should pass without exception
_real_verify("prgs")
class TestIssueLocking(unittest.TestCase): class TestIssueLocking(unittest.TestCase):
"""Test issue locking and PR gating constraints.""" """Test issue locking and PR gating constraints."""
@@ -2476,88 +2383,3 @@ class TestIssueLocking(unittest.TestCase):
with self.assertRaises(ValueError) as ctx: with self.assertRaises(ValueError) as ctx:
gitea_create_pr(title="feat: X refs #196", head="feat/issue-196-mutations", remote="prgs") gitea_create_pr(title="feat: X refs #196", head="feat/issue-196-mutations", remote="prgs")
self.assertIn("must contain 'Closes #196' or 'Fixes #196' exactly", str(ctx.exception)) self.assertIn("must contain 'Closes #196' or 'Fixes #196' exactly", str(ctx.exception))
class TestPreflightVerification(unittest.TestCase):
"""Test workspace edits and pre-flight ordering verification."""
def setUp(self):
self.preflight_path = "/tmp/gitea_preflight_check.json"
if os.path.exists(self.preflight_path):
os.remove(self.preflight_path)
os.environ["GITEA_TEST_FORCE_DIRTY"] = "1"
def tearDown(self):
if os.path.exists(self.preflight_path):
os.remove(self.preflight_path)
os.environ.pop("GITEA_TEST_FORCE_DIRTY", None)
@patch("subprocess.run")
def test_record_preflight_detects_violation_whoami(self, mock_run):
mock_run.return_value = MagicMock(stdout="M mcp_server.py\n")
from mcp_server import record_preflight_check, verify_preflight_purity
record_preflight_check("whoami")
with open(self.preflight_path, "r") as f:
data = json.load(f)
self.assertTrue(data.get("whoami_preflight_violation"))
self.assertTrue(data.get("whoami_called"))
data["capability_called"] = True
with open(self.preflight_path, "w") as f:
json.dump(data, f)
with self.assertRaises(RuntimeError) as ctx:
verify_preflight_purity("prgs")
self.assertIn("Workspace file edits occurred before gitea_whoami", str(ctx.exception))
@patch("subprocess.run")
def test_record_preflight_detects_violation_capability(self, mock_run):
mock_run.return_value = MagicMock(stdout="M mcp_server.py\n")
from mcp_server import record_preflight_check, verify_preflight_purity
record_preflight_check("capability", resolved_role="author")
with open(self.preflight_path, "r") as f:
data = json.load(f)
self.assertTrue(data.get("capability_preflight_violation"))
self.assertTrue(data.get("capability_called"))
self.assertEqual(data.get("role"), "author")
data["whoami_called"] = True
with open(self.preflight_path, "w") as f:
json.dump(data, f)
with self.assertRaises(RuntimeError) as ctx:
verify_preflight_purity("prgs")
self.assertIn("Workspace file edits occurred before gitea_resolve_task_capability", str(ctx.exception))
@patch("subprocess.run")
def test_verify_preflight_reviewer_edits_blocked(self, mock_run):
with open(self.preflight_path, "w") as f:
json.dump({
"whoami_called": True,
"capability_called": True,
"role": "reviewer"
}, f)
mock_run.return_value = MagicMock(stdout="M review_proofs.py\n")
from mcp_server import verify_preflight_purity
with self.assertRaises(RuntimeError) as ctx:
verify_preflight_purity("prgs")
self.assertIn("Reviewer profile is forbidden from modifying tracked workspace files", str(ctx.exception))
@patch("subprocess.run")
def test_verify_preflight_clean_reviewer_allowed(self, mock_run):
with open(self.preflight_path, "w") as f:
json.dump({
"whoami_called": True,
"capability_called": True,
"role": "reviewer"
}, f)
mock_run.return_value = MagicMock(stdout="")
from mcp_server import verify_preflight_purity
verify_preflight_purity("prgs")
def test_verify_preflight_skipped_fails(self):
from mcp_server import verify_preflight_purity
with self.assertRaises(RuntimeError) as ctx:
verify_preflight_purity("prgs")
self.assertIn("verification were skipped", str(ctx.exception))
-77
View File
@@ -2,8 +2,6 @@
Mocks api_request and credentials. Mocks api_request and credentials.
""" """
import io
import os
import sys import sys
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -29,11 +27,6 @@ FAKE_PR_DATA = {
class TestArgParsing(unittest.TestCase): class TestArgParsing(unittest.TestCase):
def setUp(self):
self.exists_patcher = patch("os.path.exists", return_value=False)
self.exists_patcher.start()
self.addCleanup(self.exists_patcher.stop)
@patch("review_pr.get_auth_header", return_value=FAKE_CREDS) @patch("review_pr.get_auth_header", return_value=FAKE_CREDS)
def test_missing_pr_number_exits(self, _auth): def test_missing_pr_number_exits(self, _auth):
with self.assertRaises(SystemExit): with self.assertRaises(SystemExit):
@@ -42,11 +35,6 @@ class TestArgParsing(unittest.TestCase):
class TestAPIPayload(unittest.TestCase): class TestAPIPayload(unittest.TestCase):
def setUp(self):
self.exists_patcher = patch("os.path.exists", return_value=False)
self.exists_patcher.start()
self.addCleanup(self.exists_patcher.stop)
@patch("review_pr.api_request") @patch("review_pr.api_request")
@patch("review_pr.get_auth_header", return_value=FAKE_CREDS) @patch("review_pr.get_auth_header", return_value=FAKE_CREDS)
def test_payload_fields_and_workflow(self, _auth, mock_api): def test_payload_fields_and_workflow(self, _auth, mock_api):
@@ -111,70 +99,5 @@ class TestAPIPayload(unittest.TestCase):
self.assertIn("gitea_merge_pr", msg) self.assertIn("gitea_merge_pr", msg)
class TestMutationAuthorityLock(unittest.TestCase):
"""Issue #194: verify that the CLI tool rejects profile overrides when mismatched with lock."""
@patch("review_pr.get_profile")
def test_cli_blocked_on_profile_mismatch(self, mock_get_profile):
mock_get_profile.return_value = {"profile_name": "prgs-reviewer"}
original_exists = os.path.exists
def conditional_exists(path):
if "gitea_mutation_authority.lock" in str(path):
return True
return original_exists(path)
original_open = open
def conditional_open(file, *args, **kwargs):
if "gitea_mutation_authority.lock" in str(file):
return io.StringIO('{"current_profile": "prgs-author"}')
return original_open(file, *args, **kwargs)
from _pytest.monkeypatch import MonkeyPatch
import io
buf = io.StringIO()
monkeypatch = MonkeyPatch()
monkeypatch.setattr(sys, "stderr", buf)
with patch("os.path.exists", side_effect=conditional_exists), \
patch("builtins.open", side_effect=conditional_open):
try:
rc = review_pr.main([
"--pr-number", "81", "--event", "APPROVE",
])
finally:
monkeypatch.undo()
self.assertEqual(rc, 3)
msg = buf.getvalue().lower()
self.assertIn("cli override rejected", msg)
@patch("review_pr.get_profile")
@patch("review_pr.api_request")
@patch("review_pr.get_auth_header", return_value=FAKE_CREDS)
def test_cli_allowed_on_profile_match(self, _auth, mock_api, mock_get_profile):
mock_get_profile.return_value = {"profile_name": "prgs-reviewer"}
mock_api.side_effect = [FAKE_PR_DATA, {}]
original_exists = os.path.exists
def conditional_exists(path):
if "gitea_mutation_authority.lock" in str(path):
return True
return original_exists(path)
original_open = open
def conditional_open(file, *args, **kwargs):
if "gitea_mutation_authority.lock" in str(file):
return io.StringIO('{"current_profile": "prgs-reviewer"}')
return original_open(file, *args, **kwargs)
with patch("os.path.exists", side_effect=conditional_exists), \
patch("builtins.open", side_effect=conditional_open):
rc = review_pr.main([
"--pr-number", "81", "--event", "APPROVE",
])
self.assertEqual(rc, 0)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
-23
View File
@@ -710,7 +710,6 @@ class TestControllerHandoff(unittest.TestCase):
"- Files changed: review_proofs.py", "- Files changed: review_proofs.py",
"- Validation: 700 passed, 6 skipped", "- Validation: 700 passed, 6 skipped",
"- Mutations: one PR opened", "- Mutations: one PR opened",
"- Workspace mutations: none",
"- Current status: PR open", "- Current status: PR open",
"- Blockers: none", "- Blockers: none",
"- Next: review PR #999", "- Next: review PR #999",
@@ -831,28 +830,6 @@ class TestControllerHandoff(unittest.TestCase):
self.assertIn("assess_controller_handoff", skill) self.assertIn("assess_controller_handoff", skill)
self.assertIn("issue #182", skill) self.assertIn("issue #182", skill)
def test_handoff_rejects_none_workspace_mutations_when_local_edits_exist(self):
# 1. Workspace mutations: none is rejected when local_edits is True
incomplete_eq = self.BASE_HANDOFF + "\n" + "\n".join([
"- Selected issue: #196",
"- Claim/comment status: comment-claimed",
"- PR number opened: #203",
"- No review/merge: confirmed",
])
res = assess_controller_handoff(incomplete_eq, role="author", local_edits=True)
self.assertEqual(res["verdict"], "incomplete")
self.assertIn("Workspace mutations", res["missing_fields"])
# 2. Workspace mutations: edited files is allowed when local_edits is True
complete_eq = self.BASE_HANDOFF.replace("- Workspace mutations: none", "- Workspace mutations: edited review_proofs.py") + "\n" + "\n".join([
"- Selected issue: #196",
"- Claim/comment status: comment-claimed",
"- PR number opened: #203",
"- No review/merge: confirmed",
])
res2 = assess_controller_handoff(complete_eq, role="author", local_edits=True)
self.assertEqual(res2["verdict"], "complete")
class TestPRInventoryTrustGate(unittest.TestCase): class TestPRInventoryTrustGate(unittest.TestCase):
"""Issue #194: unit tests for the PR inventory trust gate.""" """Issue #194: unit tests for the PR inventory trust gate."""