Compare commits

..
9 changed files with 716 additions and 314 deletions
+274 -136
View File
@@ -22,42 +22,12 @@ import contextlib
import subprocess import subprocess
# Mutation-authority record (#199, refs #194). Deliberately in-process, NOT a LOCK_FILE = "/tmp/gitea_mutation_authority.lock"
# file: a /tmp lock is host-global, writable (spoofable) by any local process, ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
# goes silently stale across sessions, and races between concurrent agent
# sessions. This record lives and dies with the MCP server process, so it can
# never be forged from outside or leak between sessions. The CLI side-channel
# (a subprocess overriding GITEA_MCP_PROFILE to escalate roles) is covered by
# SESSION_PROFILE_LOCK_ENV below: the server exports its launch profile into
# the environment, children inherit it, and reviewer CLIs (review_pr.py)
# refuse to run under a different resolved profile.
_MUTATION_AUTHORITY: dict | None = None
SESSION_PROFILE_LOCK_ENV = "GITEA_SESSION_PROFILE_LOCK" 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 = {
def _export_session_profile_lock():
"""Export this process's launch profile for child CLI processes.
setdefault: an already-locked environment (outer session) wins, so a
nested launch cannot relabel the session.
"""
try:
name = (get_profile().get("profile_name") or "").strip()
if name:
os.environ.setdefault(SESSION_PROFILE_LOCK_ENV, name)
except Exception:
# Profile resolution problems surface loudly on the first real call;
# the lock export must not mask them here at import time.
pass
def record_mutation_authority(profile_name: str | None, identity: str | None,
remote: str | None, task: str | None):
"""Record the resolved capability context for this process (fail-closed
consumers in verify_mutation_authority)."""
global _MUTATION_AUTHORITY
_MUTATION_AUTHORITY = {
"initial_profile": profile_name, "initial_profile": profile_name,
"initial_identity": identity, "initial_identity": identity,
"current_profile": profile_name, "current_profile": profile_name,
@@ -66,83 +36,48 @@ def record_mutation_authority(profile_name: str | None, identity: str | None,
"task": task, "task": task,
"role_pivot_authorized": False, "role_pivot_authorized": False,
"role_pivot_record": None, "role_pivot_record": None,
"pid": os.getpid(),
} }
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)")
def verify_mutation_authority(remote: str | None, host: str | None = None, try:
required_role: str = "reviewer", with open(LOCK_FILE, "r", encoding="utf-8") as f:
active_identity: str | None = None): data = json.load(f)
"""Verify the current mutation matches this process's recorded authority. except Exception as e:
raise RuntimeError(f"Could not read mutation authority lock: {e} (fail closed)")
Fail-closed rules:
- No recorded authority (or one from another process after a fork) is
seeded from the live, config-resolved context — the approved preflight
path (whoami → eligibility → mutation) therefore works without an
explicit resolve call — but an unresolvable profile still fails closed.
- GITEA_SESSION_PROFILE_LOCK (set by the launching session) must match
the active profile: a mid-session GITEA_MCP_PROFILE override flips the
active profile away from the lock and is refused.
- Remote, profile, and identity must match the recorded authority.
- An author→reviewer pivot requires an authorized pivot record
(gitea_activate_profile in dynamic mode); it can never be improvised.
"""
global _MUTATION_AUTHORITY
profile = get_profile()
active_profile = profile.get("profile_name")
if active_identity is None:
# Callers that already proved the identity (eligibility gate) pass it
# in; otherwise resolve it here (cached, read-only).
h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
active_identity = _authenticated_username(h) if h else None
if not active_profile:
raise RuntimeError(
"Mutation authority unavailable: active profile unresolved (fail closed)"
)
session_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
if session_lock and session_lock != active_profile:
raise RuntimeError(
f"Active profile '{active_profile}' does not match the session "
f"profile lock '{session_lock}' — profile side-channel override "
"rejected (fail closed)"
)
data = _MUTATION_AUTHORITY
if data is None or data.get("pid") != os.getpid():
# First mutation gate in this process (approved preflight path):
# seed the authority from the live context, then verify against it.
record_mutation_authority(
active_profile, active_identity, remote, "seeded-at-mutation-gate"
)
data = _MUTATION_AUTHORITY
if data.get("remote") != remote: if data.get("remote") != remote:
raise RuntimeError( raise RuntimeError(
f"Mutation remote '{remote}' does not match locked remote " f"Mutation remote '{remote}' does not match locked remote '{data.get('remote')}' (fail closed)"
f"'{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_profile = data.get("current_profile")
locked_identity = data.get("current_identity") locked_identity = data.get("current_identity")
if active_profile != locked_profile or active_identity != locked_identity: if active_profile != locked_profile or active_identity != locked_identity:
raise RuntimeError( raise RuntimeError(
f"Mutation profile '{active_profile}' or identity '{active_identity}' " f"Mutation profile '{active_profile}' or identity '{active_identity}' "
f"does not match locked authority (profile: '{locked_profile}', " f"does not match locked authority (profile: '{locked_profile}', identity: '{locked_identity}') (fail closed)"
f"identity: '{locked_identity}') (fail closed)"
) )
# Reviewer/author role pivot boundary: only an authorized pivot # Check reviewer/author role pivot boundaries
# (recorded by gitea_activate_profile) may cross author → reviewer. if required_role == "reviewer" and "author" in str(data.get("initial_profile")).lower() and "reviewer" in str(active_profile).lower():
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"): if not data.get("role_pivot_authorized"):
raise RuntimeError( raise RuntimeError(
"Attempted reviewer mutation from author session without " "Attempted reviewer mutation from author session without authorized role pivot (fail closed)"
"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
@@ -156,6 +91,100 @@ 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
@@ -450,6 +479,7 @@ 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:
@@ -465,6 +495,84 @@ def gitea_create_issue(
return _with_optional_url({"number": data["number"]}, data.get("html_url")) return _with_optional_url({"number": data["number"]}, data.get("html_url"))
@mcp.tool()
def gitea_lock_issue(
issue_number: int,
branch_name: str,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Lock exactly one Gitea issue and its branch name to ensure durable tracking.
Args:
issue_number: The tracking issue number.
branch_name: The branch name (must match (fix|feat|docs|chore)/issue-<issue_number>-<desc>).
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override Gitea host.
org: Override Org.
repo: Override Repo.
"""
# 1. Enforce branch name includes issue number
expected_pattern = f"issue-{issue_number}"
if expected_pattern not in branch_name:
raise ValueError(
f"Branch name '{branch_name}' must contain locked issue pattern '{expected_pattern}' (fail closed)"
)
# 2. Check if the issue already has an open PR (reuse protection)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls?state=open"
try:
prs = api_get_all(url, auth)
except Exception as e:
raise RuntimeError(f"Could not list open PRs to verify issue lock: {e}")
for pr in prs:
pr_head = pr.get("head", {}).get("ref", "")
pr_title = pr.get("title", "")
pr_body = pr.get("body", "")
if expected_pattern in pr_head:
raise ValueError(
f"Issue #{issue_number} is already tied to an open PR (PR #{pr.get('number')}, branch '{pr_head}') (fail closed)"
)
patterns = [
f"closes #{issue_number}",
f"fixes #{issue_number}",
]
text_to_check = f"{pr_title} {pr_body}".lower()
if any(p in text_to_check for p in patterns):
raise ValueError(
f"Issue #{issue_number} is already tied to an open PR (PR #{pr.get('number')}) via Closes/Fixes reference (fail closed)"
)
data = {
"issue_number": issue_number,
"branch_name": branch_name,
"remote": remote,
"org": o,
"repo": r,
}
try:
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
json.dump(data, f)
except Exception as e:
raise RuntimeError(f"Could not write issue lock file: {e}")
return {
"success": True,
"message": f"Successfully locked issue #{issue_number} to branch '{branch_name}' (fail-closed check complete).",
"issue_number": issue_number,
"branch_name": branch_name,
}
@mcp.tool() @mcp.tool()
def gitea_create_pr( def gitea_create_pr(
title: str, title: str,
@@ -492,6 +600,42 @@ 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) ──
if not os.path.exists(ISSUE_LOCK_FILE):
raise RuntimeError("Issue lock is missing (fail closed). Call gitea_lock_issue first.")
try:
with open(ISSUE_LOCK_FILE, "r", encoding="utf-8") as f:
lock_data = json.load(f)
except Exception as e:
raise RuntimeError(f"Could not read issue lock file: {e} (fail closed)")
locked_issue = lock_data.get("issue_number")
locked_branch = lock_data.get("branch_name")
if head != locked_branch:
raise ValueError(
f"PR head branch '{head}' does not match locked branch '{locked_branch}' (fail closed)"
)
# Check for forbidden terms anywhere in title/body
forbidden_terms = ["equivalent", "related", "same as"]
text_to_check = f"{title} {body}".lower()
for term in forbidden_terms:
if term in text_to_check:
raise ValueError(
f"PR title or body contains forbidden term '{term}' (fail closed)"
)
# Ensure Closes #<locked_issue> or Fixes #<locked_issue> is present exactly
closes_pattern = re.compile(rf"\b(closes|fixes)\s+#{locked_issue}\b", re.IGNORECASE)
if not closes_pattern.search(text_to_check):
raise ValueError(
f"PR title or body must contain 'Closes #{locked_issue}' or 'Fixes #{locked_issue}' exactly to ensure durable tracking (fail closed)"
)
auth = _auth(h) auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls" url = f"{repo_api_url(h, o, r)}/pulls"
payload = {"title": title, "body": body, "head": head, "base": base} payload = {"title": title, "body": body, "head": head, "base": base}
@@ -1101,6 +1245,7 @@ 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,
@@ -1116,6 +1261,12 @@ 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(
@@ -1168,17 +1319,6 @@ def gitea_submit_pr_review(
reasons.append("PR head SHA unavailable (fail closed)") reasons.append("PR head SHA unavailable (fail closed)")
return result return result
# Gate 5 — in-process mutation authority (#199): the last check before
# the mutating POST, using the identity the eligibility gate proved.
# A profile/identity flip or side-channel override between preflight
# and mutation fails closed here.
try:
verify_mutation_authority(remote, host, required_role="reviewer",
active_identity=auth_user)
except RuntimeError as e:
reasons.append(str(e))
return result
# All gates passed — perform the single mutating call. # All gates passed — perform the single mutating call.
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
try: try:
@@ -1336,6 +1476,7 @@ 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"
@@ -1429,6 +1570,7 @@ 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,
@@ -1447,6 +1589,12 @@ 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(
@@ -1525,17 +1673,6 @@ def gitea_merge_pr(
reasons.append("self-merge blocked (authenticated user is PR author)") reasons.append("self-merge blocked (authenticated user is PR author)")
return result return result
# Gate 7 — in-process mutation authority (#199): the last check before
# the merge mutation, using the identity the eligibility gate proved.
# A profile/identity flip or side-channel override between preflight
# and merge fails closed here.
try:
verify_mutation_authority(remote, host, required_role="reviewer",
active_identity=auth_user)
except RuntimeError as e:
reasons.append(str(e))
return result
# All gates passed — perform the single merge mutation. # All gates passed — perform the single merge mutation.
try: try:
auth = _auth(h) auth = _auth(h)
@@ -2706,6 +2843,7 @@ 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")
@@ -3134,21 +3272,24 @@ 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 the authorized pivot in the in-process mutation authority # 4.5 Record pivot in mutation authority lock
# and keep the session profile lock in sync — this is the ONLY path that if os.path.exists(LOCK_FILE):
# may authorize an author→reviewer role pivot. try:
if _MUTATION_AUTHORITY is not None: with open(LOCK_FILE, "r", encoding="utf-8") as f:
_MUTATION_AUTHORITY["current_profile"] = after_profile lock_data = json.load(f)
_MUTATION_AUTHORITY["current_identity"] = after_identity lock_data["current_profile"] = after_profile
_MUTATION_AUTHORITY["role_pivot_authorized"] = True lock_data["current_identity"] = after_identity
_MUTATION_AUTHORITY["role_pivot_record"] = { lock_data["role_pivot_authorized"] = True
"from_profile": before_profile, lock_data["role_pivot_record"] = {
"to_profile": after_profile, "from_profile": before_profile,
"from_identity": before_identity, "to_profile": after_profile,
"to_identity": after_identity, "from_identity": before_identity,
} "to_identity": after_identity
if os.environ.get(SESSION_PROFILE_LOCK_ENV) and after_profile: }
os.environ[SESSION_PROFILE_LOCK_ENV] = after_profile 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(
@@ -3539,6 +3680,7 @@ 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()
@@ -3661,8 +3803,4 @@ def gitea_resolve_task_capability(
# ── Entry point ─────────────────────────────────────────────────────────────── # ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__": if __name__ == "__main__":
# Lock this session's launch profile into the environment so child CLI
# processes (e.g. review_pr.py) can detect and refuse profile
# side-channel overrides (#199).
_export_session_profile_lock()
mcp.run(transport="stdio") mcp.run(transport="stdio")
+18 -21
View File
@@ -60,31 +60,28 @@ def main(argv=None):
host, org, repo = resolve_remote(args) host, org, repo = resolve_remote(args)
# ── Reviewer mutation side-channel wall (#199, refs #194) ── # ── Mutation Authority context wall check (Issue #194) ──
# The launching MCP session exports GITEA_SESSION_PROFILE_LOCK with the LOCK_FILE = "/tmp/gitea_mutation_authority.lock"
# profile it was started with; child processes inherit it. If this CLI
# resolves a different profile — e.g. an ad-hoc GITEA_MCP_PROFILE if os.path.exists(LOCK_FILE):
# override escalating an author-bound session to reviewer — refuse
# before any API call. No lock in the environment means no session
# context (direct operator CLI use), which stays allowed. Unlike a /tmp
# lock file, the environment is per-process-tree: other sessions cannot
# spoof it and it cannot go stale across sessions.
session_lock = (os.environ.get("GITEA_SESSION_PROFILE_LOCK") or "").strip()
if session_lock:
try: try:
cli_profile = (get_profile().get("profile_name") or "").strip() 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: except Exception as e:
print(f"Mutation authority check failed: {e}", file=sys.stderr) print(f"Mutation authority check failed: {e}", file=sys.stderr)
return 3 return 3
if cli_profile != session_lock:
print(
f"Mismatched active profile vs session profile lock "
f"(CLI override rejected): CLI profile '{cli_profile}' does "
f"not match locked session profile '{session_lock}' "
f"(fail closed)",
file=sys.stderr,
)
return 3
body = args.body body = args.body
if args.body_file: if args.body_file:
+43 -2
View File
@@ -530,6 +530,7 @@ 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",)),
@@ -580,7 +581,7 @@ def _handoff_section_lines(report_text):
return lines[start:] return lines[start:]
def assess_controller_handoff(report_text, role=None): def assess_controller_handoff(report_text, role=None, local_edits=False):
"""Issue #182: final reports without a Controller Handoff downgrade. """Issue #182: final reports without a Controller Handoff downgrade.
Verdicts: Verdicts:
@@ -592,6 +593,7 @@ def assess_controller_handoff(report_text, role=None):
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 {
@@ -605,10 +607,14 @@ def assess_controller_handoff(report_text, role=None):
} }
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:
labels.append(stripped.split(":", 1)[0].strip().lower()) k, v = stripped.split(":", 1)
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 "", ()))
@@ -627,6 +633,40 @@ def assess_controller_handoff(report_text, role=None):
"reasons": [f"handoff missing required field: {m}" "reasons": [f"handoff missing required field: {m}"
for m in missing], for m in missing],
} }
# Validate issue/PR references for exact number and no forbidden terms (Issue #194 / #196)
for alias in ("selected issue", "pr number opened", "pr opened", "pr number", "selected pr"):
val = fields_dict.get(alias)
if val:
numbers = re.findall(r"\d+", val)
has_forbidden = any(term in val.lower() for term in ("equivalent", "related", "same", "/"))
if len(numbers) != 1 or has_forbidden:
field_name = "Selected issue/PR"
for name, aliases_list in required:
if alias in aliases_list:
field_name = name
break
return {
"verdict": "incomplete",
"downgraded": True,
"missing_fields": [field_name],
"reasons": [
f"{field_name} must specify exactly one number and no ambiguous references (got: '{val}')"
],
}
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,
@@ -635,6 +675,7 @@ def assess_controller_handoff(report_text, role=None):
} }
# ── 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
+10
View File
@@ -40,6 +40,16 @@ start_ref="${2:-prgs/master}"
# Enforce issue-linked, traceable branch names (issue → branch → worktree → PR). # Enforce issue-linked, traceable branch names (issue → branch → worktree → PR).
if [[ "$allow_unlinked" -eq 0 ]]; then if [[ "$allow_unlinked" -eq 0 ]]; then
if [[ ! -f "/tmp/gitea_issue_lock.json" ]]; then
echo "Error: Issue lock file '/tmp/gitea_issue_lock.json' is missing. You must lock exactly one issue before branch creation (fail closed)." >&2
exit 2
fi
locked_branch=$(python3 -c "import json; print(json.load(open('/tmp/gitea_issue_lock.json')).get('branch_name', ''))")
if [[ "$branch" != "$locked_branch" ]]; then
echo "Error: Requested branch '$branch' does not match locked branch '$locked_branch' (fail closed)." >&2
exit 2
fi
if [[ "$branch" =~ ^(fix|feat|docs|chore)/issue-[0-9]+-.+ ]] \ if [[ "$branch" =~ ^(fix|feat|docs|chore)/issue-[0-9]+-.+ ]] \
|| [[ "$branch" =~ ^review/pr-[0-9]+-.+ ]]; then || [[ "$branch" =~ ^review/pr-[0-9]+-.+ ]]; then
: :
-28
View File
@@ -1,28 +0,0 @@
"""Shared pytest fixtures for the Gitea-Tools test suite."""
import sys
import pytest
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
@pytest.fixture(autouse=True)
def _reset_mutation_authority(monkeypatch):
"""Isolate the in-process mutation authority between tests (#199).
The mutation-authority gate stays LIVE in every test — this fixture only
clears the per-process record and the session profile lock so one test's
seeded authority (or an intentionally mismatched one) cannot leak into
the next test. It must never replace verify_mutation_authority with a
no-op: individual tests that need a specific authority state set it up
explicitly.
"""
monkeypatch.delenv("GITEA_SESSION_PROFILE_LOCK", raising=False)
try:
import mcp_server
except Exception:
yield
return
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
yield
+239 -95
View File
@@ -5,6 +5,7 @@ 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
@@ -33,11 +34,15 @@ from mcp_server import ( # noqa: E402
gitea_submit_pr_review, gitea_submit_pr_review,
gitea_list_issue_comments, gitea_list_issue_comments,
gitea_create_issue_comment, gitea_create_issue_comment,
gitea_lock_issue,
) )
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 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"
@@ -87,10 +92,13 @@ class TestCreatePR(unittest.TestCase):
@patch("mcp_server.api_request") @patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_creates_pr(self, _auth, mock_api): @patch("os.path.exists", return_value=True)
@patch("builtins.open")
def test_creates_pr(self, mock_open, mock_exists, _auth, mock_api):
mock_open.return_value.__enter__.return_value.read.return_value = '{"issue_number": 123, "branch_name": "feat/x"}'
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"} mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
result = gitea_create_pr(title="feat: X", head="feat/x", base="main") result = gitea_create_pr(title="feat: X Closes #123", head="feat/x", base="main")
self.assertEqual(result["number"], 3) self.assertEqual(result["number"], 3)
self.assertNotIn("url", result) self.assertNotIn("url", result)
payload = mock_api.call_args[0][3] payload = mock_api.call_args[0][3]
@@ -99,10 +107,13 @@ class TestCreatePR(unittest.TestCase):
@patch("mcp_server.api_request") @patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_reveal_opt_in_includes_url(self, _auth, mock_api): @patch("os.path.exists", return_value=True)
@patch("builtins.open")
def test_create_pr_reveal_opt_in_includes_url(self, mock_open, mock_exists, _auth, mock_api):
mock_open.return_value.__enter__.return_value.read.return_value = '{"issue_number": 123, "branch_name": "feat/x"}'
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"} mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
with patch.dict(os.environ, {"GITEA_MCP_REVEAL_ENDPOINTS": "1"}, clear=True): with patch.dict(os.environ, {"GITEA_MCP_REVEAL_ENDPOINTS": "1"}, clear=True):
result = gitea_create_pr(title="feat: X", head="feat/x", base="main") result = gitea_create_pr(title="feat: X Closes #123", head="feat/x", base="main")
self.assertIn("pulls/3", result["url"]) self.assertIn("pulls/3", result["url"])
@@ -2301,119 +2312,252 @@ class TestIssueCommentPermissionSeparation(unittest.TestCase):
class TestVerifyMutationAuthority(unittest.TestCase): class TestVerifyMutationAuthority(unittest.TestCase):
"""In-process mutation authority (#199, refs #194). """Test verification lock logic under various configurations."""
The authority record lives in mcp_server._MUTATION_AUTHORITY (per
process, reset between tests by conftest); the CLI side-channel is
covered by the GITEA_SESSION_PROFILE_LOCK environment lock. There is no
lock file — nothing here touches /tmp.
"""
def setUp(self): def setUp(self):
self.patch_profile = patch("mcp_server.get_profile") self.patch_profile = patch("mcp_server.get_profile")
self.mock_profile = self.patch_profile.start() self.mock_profile = self.patch_profile.start()
self.patch_username = patch("mcp_server._authenticated_username") self.patch_username = patch("mcp_server._authenticated_username")
self.mock_username = self.patch_username.start() self.mock_username = self.patch_username.start()
self.mock_profile.return_value = {"profile_name": "prgs-reviewer"}
self.mock_username.return_value = "sysadmin" # Restore real function for these tests
self._old_verify = mcp_server.verify_mutation_authority
mcp_server.verify_mutation_authority = _real_verify
def tearDown(self): def tearDown(self):
self.patch_profile.stop() self.patch_profile.stop()
self.patch_username.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 _authority(self, **overrides): def test_missing_lock_fails_closed(self):
data = { if os.path.exists("/tmp/gitea_mutation_authority.lock"):
"initial_profile": "prgs-reviewer", os.remove("/tmp/gitea_mutation_authority.lock")
"initial_identity": "sysadmin",
"current_profile": "prgs-reviewer",
"current_identity": "sysadmin",
"remote": "prgs",
"task": "review_pr",
"role_pivot_authorized": False,
"role_pivot_record": None,
"pid": os.getpid(),
}
data.update(overrides)
mcp_server._MUTATION_AUTHORITY = data
def test_missing_authority_seeds_from_live_context(self):
# Approved preflight path (whoami → eligibility → mutation): the
# first mutation gate seeds the authority instead of failing closed,
# so the standard reviewer workflow keeps working.
mcp_server._MUTATION_AUTHORITY = None
mcp_server.verify_mutation_authority("prgs")
seeded = mcp_server._MUTATION_AUTHORITY
self.assertIsNotNone(seeded)
self.assertEqual(seeded["current_profile"], "prgs-reviewer")
self.assertEqual(seeded["current_identity"], "sysadmin")
self.assertEqual(seeded["remote"], "prgs")
def test_unresolved_profile_fails_closed(self):
self.mock_profile.return_value = {}
mcp_server._MUTATION_AUTHORITY = None
with self.assertRaises(RuntimeError) as ctx: with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_mutation_authority("prgs") _real_verify("prgs")
self.assertIn("profile unresolved", str(ctx.exception)) self.assertIn("lock is missing", str(ctx.exception))
def test_mismatched_remote_fails(self): def test_mismatched_remote_fails(self):
self._authority(remote="dadeschools") 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: with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_mutation_authority("prgs") _real_verify("prgs")
self.assertIn("does not match locked remote", str(ctx.exception)) self.assertIn("does not match locked remote", str(ctx.exception))
def test_profile_flip_after_record_fails(self): def test_mismatched_profile_fails(self):
# Authority was recorded as author; the active profile now resolves with open("/tmp/gitea_mutation_authority.lock", "w", encoding="utf-8") as f:
# as reviewer (e.g. an env-var flip mid-session) — refuse. json.dump({
self._authority( "remote": "prgs",
initial_profile="prgs-author", "current_profile": "prgs-author",
initial_identity="jcwalker3", "current_identity": "jcwalker3"
current_profile="prgs-author", }, f)
current_identity="jcwalker3", self.mock_profile.return_value = {"profile_name": "prgs-reviewer"}
) self.mock_username.return_value = "sysadmin"
with self.assertRaises(RuntimeError) as ctx: with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_mutation_authority("prgs") _real_verify("prgs")
self.assertIn("does not match locked authority", str(ctx.exception)) self.assertIn("does not match locked authority", str(ctx.exception))
def test_session_lock_env_mismatch_fails(self):
# The launching session locked the environment to the author
# profile; the active profile resolves as reviewer — side-channel
# override rejected even with a matching in-process authority.
self._authority()
with patch.dict(os.environ,
{"GITEA_SESSION_PROFILE_LOCK": "prgs-author"}):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_mutation_authority("prgs")
self.assertIn("side-channel override rejected", str(ctx.exception))
def test_foreign_pid_authority_is_not_trusted(self):
# An authority record from another process (fork leftovers) is
# discarded and reseeded from the live context, never reused.
self._authority(current_profile="prgs-author", pid=os.getpid() + 1)
mcp_server.verify_mutation_authority("prgs")
self.assertEqual(
mcp_server._MUTATION_AUTHORITY["current_profile"], "prgs-reviewer"
)
self.assertEqual(mcp_server._MUTATION_AUTHORITY["pid"], os.getpid())
def test_author_to_reviewer_pivot_blocked_without_authorization(self): def test_author_to_reviewer_pivot_blocked_without_authorization(self):
self._authority( with open("/tmp/gitea_mutation_authority.lock", "w", encoding="utf-8") as f:
initial_profile="prgs-author", json.dump({
initial_identity="jcwalker3", "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: with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_mutation_authority("prgs", required_role="reviewer") _real_verify("prgs", required_role="reviewer")
self.assertIn("without authorized role pivot", str(ctx.exception)) self.assertIn("without authorized role pivot", str(ctx.exception))
def test_authorized_pivot_is_allowed(self):
self._authority(
initial_profile="prgs-author",
initial_identity="jcwalker3",
role_pivot_authorized=True,
)
mcp_server.verify_mutation_authority("prgs", required_role="reviewer")
def test_allowed_when_match(self): def test_allowed_when_match(self):
self._authority() with open("/tmp/gitea_mutation_authority.lock", "w", encoding="utf-8") as f:
with patch.dict(os.environ, json.dump({
{"GITEA_SESSION_PROFILE_LOCK": "prgs-reviewer"}): "remote": "prgs",
mcp_server.verify_mutation_authority("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):
"""Test issue locking and PR gating constraints."""
def tearDown(self):
if os.path.exists("/tmp/gitea_issue_lock.json"):
os.remove("/tmp/gitea_issue_lock.json")
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_issue_success(self, _auth, mock_api):
mock_api.return_value = [] # no open PRs
res = gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertTrue(res["success"])
self.assertTrue(os.path.exists("/tmp/gitea_issue_lock.json"))
def test_lock_issue_mismatch_branch_fails(self):
with self.assertRaises(ValueError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-195-mutations", remote="prgs")
self.assertIn("must contain locked issue pattern", str(ctx.exception))
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_issue_reused_by_open_pr_branch(self, _auth, mock_api):
mock_api.return_value = [{
"number": 200,
"head": {"ref": "feat/issue-196-boundary"},
"title": "Some PR",
"body": "No closes ref"
}]
with self.assertRaises(ValueError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertIn("already tied to an open PR", str(ctx.exception))
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_issue_reused_by_open_pr_closes_ref(self, _auth, mock_api):
mock_api.return_value = [{
"number": 200,
"head": {"ref": "feat/other-branch"},
"title": "Some PR",
"body": "fixes #196"
}]
with self.assertRaises(ValueError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertIn("already tied to an open PR", str(ctx.exception))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_missing_lock_fails(self, _auth):
if os.path.exists("/tmp/gitea_issue_lock.json"):
os.remove("/tmp/gitea_issue_lock.json")
with self.assertRaises(RuntimeError) as ctx:
gitea_create_pr(title="feat: X Closes #196", head="feat/issue-196-mutations", remote="prgs")
self.assertIn("Issue lock is missing", str(ctx.exception))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_branch_mismatch_fails(self, _auth):
with open("/tmp/gitea_issue_lock.json", "w", encoding="utf-8") as f:
json.dump({"issue_number": 196, "branch_name": "feat/issue-196-mutations"}, f)
with self.assertRaises(ValueError) as ctx:
gitea_create_pr(title="feat: X Closes #196", head="feat/issue-196-different", remote="prgs")
self.assertIn("does not match locked branch", str(ctx.exception))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_forbidden_terms_fails(self, _auth):
with open("/tmp/gitea_issue_lock.json", "w", encoding="utf-8") as f:
json.dump({"issue_number": 196, "branch_name": "feat/issue-196-mutations"}, f)
for term in ("equivalent to #196", "related to #196", "same as #196"):
with self.assertRaises(ValueError) as ctx:
gitea_create_pr(title=f"feat: X {term}", head="feat/issue-196-mutations", remote="prgs")
self.assertIn("contains forbidden term", str(ctx.exception))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_missing_closes_ref_fails(self, _auth):
with open("/tmp/gitea_issue_lock.json", "w", encoding="utf-8") as f:
json.dump({"issue_number": 196, "branch_name": "feat/issue-196-mutations"}, f)
with self.assertRaises(ValueError) as ctx:
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))
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))
+42 -27
View File
@@ -112,23 +112,39 @@ class TestAPIPayload(unittest.TestCase):
class TestMutationAuthorityLock(unittest.TestCase): class TestMutationAuthorityLock(unittest.TestCase):
"""#199 (refs #194): the CLI refuses to run under a profile that differs """Issue #194: verify that the CLI tool rejects profile overrides when mismatched with lock."""
from the session profile lock exported by the launching MCP session."""
@patch("review_pr.get_profile") @patch("review_pr.get_profile")
def test_cli_blocked_on_session_lock_mismatch(self, mock_get_profile): def test_cli_blocked_on_profile_mismatch(self, mock_get_profile):
# An author-bound session exported the lock; the CLI resolves a
# reviewer profile (GITEA_MCP_PROFILE side-channel override) — reject
# before any API call.
mock_get_profile.return_value = {"profile_name": "prgs-reviewer"} 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 import io
buf = io.StringIO() buf = io.StringIO()
with patch.dict(os.environ, monkeypatch = MonkeyPatch()
{"GITEA_SESSION_PROFILE_LOCK": "prgs-author"}), \ monkeypatch.setattr(sys, "stderr", buf)
patch.object(sys, "stderr", buf):
rc = review_pr.main([ with patch("os.path.exists", side_effect=conditional_exists), \
"--pr-number", "81", "--event", "APPROVE", patch("builtins.open", side_effect=conditional_open):
]) try:
rc = review_pr.main([
"--pr-number", "81", "--event", "APPROVE",
])
finally:
monkeypatch.undo()
self.assertEqual(rc, 3) self.assertEqual(rc, 3)
msg = buf.getvalue().lower() msg = buf.getvalue().lower()
self.assertIn("cli override rejected", msg) self.assertIn("cli override rejected", msg)
@@ -139,22 +155,21 @@ class TestMutationAuthorityLock(unittest.TestCase):
def test_cli_allowed_on_profile_match(self, _auth, mock_api, mock_get_profile): def test_cli_allowed_on_profile_match(self, _auth, mock_api, mock_get_profile):
mock_get_profile.return_value = {"profile_name": "prgs-reviewer"} mock_get_profile.return_value = {"profile_name": "prgs-reviewer"}
mock_api.side_effect = [FAKE_PR_DATA, {}] mock_api.side_effect = [FAKE_PR_DATA, {}]
with patch.dict(os.environ,
{"GITEA_SESSION_PROFILE_LOCK": "prgs-reviewer"}):
rc = review_pr.main([
"--pr-number", "81", "--event", "APPROVE",
])
self.assertEqual(rc, 0)
@patch("review_pr.api_request") original_exists = os.path.exists
@patch("review_pr.get_auth_header", return_value=FAKE_CREDS) def conditional_exists(path):
def test_cli_allowed_without_session_lock(self, _auth, mock_api): if "gitea_mutation_authority.lock" in str(path):
# No lock in the environment = direct operator CLI use; the wall return True
# does not apply and the normal flow proceeds. return original_exists(path)
mock_api.side_effect = [FAKE_PR_DATA, {}]
env = {k: v for k, v in os.environ.items() original_open = open
if k != "GITEA_SESSION_PROFILE_LOCK"} def conditional_open(file, *args, **kwargs):
with patch.dict(os.environ, env, clear=True): 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([ rc = review_pr.main([
"--pr-number", "81", "--event", "APPROVE", "--pr-number", "81", "--event", "APPROVE",
]) ])
+57
View File
@@ -710,6 +710,7 @@ 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",
@@ -775,6 +776,40 @@ class TestControllerHandoff(unittest.TestCase):
self.assertEqual(result["verdict"], "incomplete") self.assertEqual(result["verdict"], "incomplete")
self.assertIn("No review/merge confirmation", result["missing_fields"]) self.assertIn("No review/merge confirmation", result["missing_fields"])
def test_author_role_rejects_equivalent_or_multiple_issues(self):
# 1. equivalent reference blocked
incomplete_eq = self.BASE_HANDOFF + "\n" + "\n".join([
"- Selected issue: Issue #194 / #196 equivalent",
"- Claim/comment status: comment-claimed",
"- PR number opened: #999",
"- No review/merge: confirmed",
])
res = assess_controller_handoff(incomplete_eq, role="author")
self.assertEqual(res["verdict"], "incomplete")
self.assertIn("Selected issue", res["missing_fields"])
# 2. multiple issues blocked
incomplete_multi = self.BASE_HANDOFF + "\n" + "\n".join([
"- Selected issue: #194, #196",
"- Claim/comment status: comment-claimed",
"- PR number opened: #999",
"- No review/merge: confirmed",
])
res = assess_controller_handoff(incomplete_multi, role="author")
self.assertEqual(res["verdict"], "incomplete")
self.assertIn("Selected issue", res["missing_fields"])
def test_author_role_rejects_fuzzy_pr_number(self):
incomplete_pr = self.BASE_HANDOFF + "\n" + "\n".join([
"- Selected issue: #196",
"- Claim/comment status: comment-claimed",
"- PR number opened: PR #203 / #204 equivalent",
"- No review/merge: confirmed",
])
res = assess_controller_handoff(incomplete_pr, role="author")
self.assertEqual(res["verdict"], "incomplete")
self.assertIn("PR number opened", res["missing_fields"])
def test_inventory_role_requires_inventory_fields(self): def test_inventory_role_requires_inventory_fields(self):
complete = self.BASE_HANDOFF + "\n" + "\n".join([ complete = self.BASE_HANDOFF + "\n" + "\n".join([
"- Repositories checked: Gitea-Tools, mcp-control-plane", "- Repositories checked: Gitea-Tools, mcp-control-plane",
@@ -796,6 +831,28 @@ 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."""
+33 -5
View File
@@ -14,11 +14,39 @@ BRANCHES = REPO / "branches"
def run(script, *args): def run(script, *args):
proc = subprocess.run( branch = None
["bash", str(SCRIPTS / script), *args], for arg in args:
capture_output=True, text=True, cwd=str(REPO), if not arg.startswith("-"):
) branch = arg
return proc.returncode, proc.stdout, proc.stderr break
lock_file = Path("/tmp/gitea_issue_lock.json")
created_lock = False
if script == "worktree-start" and branch:
import re
import json
m = re.search(r"issue-(\d+)", branch)
if not m:
m = re.search(r"pr-(\d+)", branch)
issue_num = int(m.group(1)) if m else 999
lock_file.write_text(json.dumps({
"issue_number": issue_num,
"branch_name": branch,
"remote": "prgs",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools"
}), encoding="utf-8")
created_lock = True
try:
proc = subprocess.run(
["bash", str(SCRIPTS / script), *args],
capture_output=True, text=True, cwd=str(REPO),
)
return proc.returncode, proc.stdout, proc.stderr
finally:
if created_lock and lock_file.exists():
lock_file.unlink()
class TestWorktreeStart(unittest.TestCase): class TestWorktreeStart(unittest.TestCase):