Addresses the sysadmin REQUEST_CHANGES on PR #203 (reviewed head10d2644790): 1. Lock redesigned; /tmp file removed entirely. The mutation authority is now an in-process record (_MUTATION_AUTHORITY) plus an environment session lock (GITEA_SESSION_PROFILE_LOCK) exported at server launch: - in-process record cannot be spoofed by other local processes, cannot go stale across sessions, and cannot race concurrent agents; - the env lock is inherited by child CLI processes, so review_pr.py can refuse an ad-hoc GITEA_MCP_PROFILE role escalation without any shared file; a missing env lock (direct operator CLI use) stays allowed; - silent except-pass writes are gone; an unresolvable profile fails closed. 2. Standard reviewer workflow unbroken: verify_mutation_authority seeds itself from the live config-resolved context at the first mutation gate (approved preflight path whoami -> eligibility -> review/merge), and now runs as the final gate after eligibility, reusing the identity that eligibility proved (no extra /user call). 3. Trailing whitespace removed from review_pr.py (git diff --check clean). 4. Module-global verify_mutation_authority no-op bypass removed from tests/test_mcp_server.py; replaced with a tests/conftest.py autouse fixture that only resets per-process state (_MUTATION_AUTHORITY, _IDENTITY_CACHE, session lock env) between tests — the gate itself stays live in every test. 5. Tests rewritten for the new design: seeding on first verify, unresolved profile fails closed, remote/profile/identity mismatches fail closed, session-lock env mismatch rejected, foreign-pid authority reseeded, unauthorized author->reviewer pivot blocked, authorized pivot allowed; CLI: mismatch blocked, match allowed, no-lock allowed. 6. Rebased onto current master (c6fd0fd). Closes #199 Refs #194 Co-Authored-By: Claude Fable 5 <[email protected]>
137 lines
5.8 KiB
Python
Executable File
137 lines
5.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Review and sign-off on a Gitea pull request.
|
|
|
|
Submits a review (APPROVE, COMMENT, REQUEST_CHANGES). CLI merge is disabled:
|
|
the `--merge` flag is retained only for compatibility and fails closed without
|
|
making any API call. Merge is handled solely by the gated `gitea_merge_pr` MCP
|
|
workflow (#16), which enforces identity/profile/eligibility, explicit
|
|
confirmation, expected head SHA checking, and self-merge protection.
|
|
|
|
Usage (review only):
|
|
review_pr.py --pr-number 12 --event APPROVE --body "Approved and signed off"
|
|
"""
|
|
import os
|
|
import sys
|
|
import json
|
|
import base64
|
|
import argparse
|
|
import urllib.request
|
|
import urllib.error
|
|
|
|
# Auto-execute using the project's local virtual environment Python
|
|
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
venv_python = os.path.join(PROJECT_ROOT, "venv", "bin", "python3")
|
|
if os.path.exists(venv_python) and sys.executable != venv_python:
|
|
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
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description="Review and sign-off on a Gitea pull request.")
|
|
add_remote_args(parser)
|
|
parser.add_argument("--pr-number", type=int, required=True, help="PR number/index to review.")
|
|
parser.add_argument("--event", choices=["APPROVE", "COMMENT", "REQUEST_CHANGES"], default="APPROVE",
|
|
help="Review event/action type (default: APPROVE).")
|
|
parser.add_argument("--body", default="", help="Review body/comment text.")
|
|
parser.add_argument("--body-file", help="Read review body from this file ('-' for stdin).")
|
|
parser.add_argument("--merge", action="store_true",
|
|
help="DISABLED — fails closed with no API call. CLI merge is not "
|
|
"supported; use the gated gitea_merge_pr MCP workflow (#16).")
|
|
parser.add_argument("--merge-method", choices=["merge", "squash", "rebase"], default="merge",
|
|
help="Ignored — CLI merge is disabled (see --merge).")
|
|
args = parser.parse_args(argv)
|
|
|
|
# Fail closed: direct CLI merge is disabled (#16). LLM automations were
|
|
# using this flag as an ungated merge bypass. Merge is only available via
|
|
# the gated `gitea_merge_pr` MCP workflow, which enforces
|
|
# identity/profile/eligibility, explicit confirmation, expected head SHA,
|
|
# and self-merge protection. No API call is made here.
|
|
if args.merge:
|
|
print(
|
|
"Direct CLI merge is disabled. Merge is only available through the "
|
|
"gated #16 workflow (MCP tool 'gitea_merge_pr'), which enforces "
|
|
"identity/profile/eligibility, explicit confirmation, expected head "
|
|
"SHA checking, and self-merge protection. Re-run without --merge to "
|
|
"submit a review only.",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
|
|
host, org, repo = resolve_remote(args)
|
|
|
|
# ── Reviewer mutation side-channel wall (#199, refs #194) ──
|
|
# The launching MCP session exports GITEA_SESSION_PROFILE_LOCK with the
|
|
# profile it was started with; child processes inherit it. If this CLI
|
|
# resolves a different profile — e.g. an ad-hoc GITEA_MCP_PROFILE
|
|
# 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:
|
|
cli_profile = (get_profile().get("profile_name") or "").strip()
|
|
except Exception as e:
|
|
print(f"Mutation authority check failed: {e}", file=sys.stderr)
|
|
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
|
|
if args.body_file:
|
|
if args.body_file == "-":
|
|
body = sys.stdin.read()
|
|
else:
|
|
with open(args.body_file, "r", encoding="utf-8") as fh:
|
|
body = fh.read()
|
|
|
|
auth = get_auth_header(host)
|
|
if not auth:
|
|
print(f"Could not get credentials or token for {host}.", file=sys.stderr)
|
|
return 1
|
|
|
|
# 1. Fetch PR to get the latest head commit SHA (required for review validation)
|
|
pr_url = f"{repo_api_url(host, org, repo)}/pulls/{args.pr_number}"
|
|
try:
|
|
pr_data = api_request("GET", pr_url, auth)
|
|
except Exception as e:
|
|
print(f"Error fetching PR #{args.pr_number}: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
commit_sha = pr_data.get("head", {}).get("sha")
|
|
if not commit_sha:
|
|
print(f"Could not find head commit SHA for PR #{args.pr_number}.", file=sys.stderr)
|
|
return 1
|
|
|
|
# 2. Submit the PR review
|
|
review_url = f"{repo_api_url(host, org, repo)}/pulls/{args.pr_number}/reviews"
|
|
payload = {
|
|
"body": body,
|
|
"event": args.event,
|
|
"commit_id": commit_sha
|
|
}
|
|
|
|
try:
|
|
api_request("POST", review_url, auth, payload)
|
|
print(f"Successfully submitted review for PR #{args.pr_number}: event={args.event}")
|
|
except Exception as e:
|
|
print(f"Error submitting review: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
# Merge is intentionally not performed here — see the fail-closed guard
|
|
# above. Use the gated `gitea_merge_pr` MCP workflow (#16) to merge.
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|