From 66d37dd3cb1aa900952160c9f9a48145b136829c Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Tue, 7 Jul 2026 11:52:41 -0400 Subject: [PATCH 01/12] feat: require exact per-mutation capability proof in reviewer reports (Closes #405) Adds mutation-capability table validation so review_pr cannot authorize merge or delete mutations without their own exact capability rows. Co-Authored-By: Claude Opus 4.8 (1M context) --- final_report_validator.py | 17 +++ review_proofs.py | 9 ++ reviewer_mutation_capability_proof.py | 129 ++++++++++++++++ .../workflows/review-merge-pr.md | 20 +++ ...test_reviewer_mutation_capability_proof.py | 141 ++++++++++++++++++ 5 files changed, 316 insertions(+) create mode 100644 reviewer_mutation_capability_proof.py create mode 100644 tests/test_reviewer_mutation_capability_proof.py diff --git a/final_report_validator.py b/final_report_validator.py index bbc4b6c..6529ee8 100644 --- a/final_report_validator.py +++ b/final_report_validator.py @@ -938,6 +938,22 @@ def _rule_reviewer_review_mutation( ) +def _rule_reviewer_mutation_capability_proof(report_text: str) -> list[dict[str, str]]: + from reviewer_mutation_capability_proof import assess_mutation_capability_proof + + result = assess_mutation_capability_proof(report_text) + if not result.get("block"): + return [] + return _findings_from_reasons( + "reviewer.mutation_capability_proof", + result.get("reasons") or [], + field="Capabilities proven", + severity="block", + safe_next_action=result.get("safe_next_action") + or "document exact per-mutation capability proof before each mutation", + ) + + _SHARED_ISSUE_LOCK_RULES = ( _rule_shared_issue_lock_external_state, _rule_shared_manual_lock_pr_override, @@ -965,6 +981,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { _rule_reviewer_target_branch_freshness, _rule_reviewer_mutation_ledger, _rule_reviewer_review_mutation, + _rule_reviewer_mutation_capability_proof, ], "reconcile_already_landed": [ _rule_reconcile_controller_handoff, diff --git a/review_proofs.py b/review_proofs.py index b74ebb8..4a9322b 100644 --- a/review_proofs.py +++ b/review_proofs.py @@ -5601,3 +5601,12 @@ def assess_proof_backed_handoff_report(report_text, **kwargs): from reviewer_proof_backed_handoff import assess_proof_backed_handoff_report as _assess return _assess(report_text, **kwargs) + + +def assess_mutation_capability_proof(report_text, **kwargs): + """#405: exact per-mutation capability proof in reviewer final reports.""" + from reviewer_mutation_capability_proof import ( + assess_mutation_capability_proof as _assess, + ) + + return _assess(report_text, **kwargs) diff --git a/reviewer_mutation_capability_proof.py b/reviewer_mutation_capability_proof.py new file mode 100644 index 0000000..1488738 --- /dev/null +++ b/reviewer_mutation_capability_proof.py @@ -0,0 +1,129 @@ +"""Exact per-mutation capability proof verifier for reviewer reports (#405). + +A reviewer final report may prove ``review_pr`` capability and then also merge +a PR or delete a remote branch. Merge and branch deletion are separate +mutations that require their own exact capability proof — a nearby capability +must never authorize a different operation. This verifier requires a +mutation-capability table pairing every performed mutation with the exact +task/permission resolved *before* that mutation. +""" + +from __future__ import annotations + +import re + +# Mutations this verifier tracks, with the exact capability tokens that +# authorize each. A row for the mutation must cite one of its own tokens; +# tokens from a different mutation (a "nearby capability") never count. +_REVIEW_TOKENS = ("review_pr", "gitea.pr.review", "gitea.pr.approve", + "gitea.pr.request_changes", "request_changes_pr", "approve_pr") +_MERGE_TOKENS = ("merge_pr", "gitea.pr.merge") +_DELETE_TOKENS = ("delete_branch", "gitea.branch.delete") + +# Detect that a mutation was actually performed (not merely mentioned as a +# non-goal or skipped). +_MERGE_PERFORMED = re.compile( + r"(?:gitea_merge_pr\b(?![^\n]*\b(?:not called|skipped|blocked)\b)|" + r"^\s*[-*]?\s*merge result\s*:\s*merged\b|" + r"\bpr merged\b|\bmerge commit\s*(?:sha)?\s*[:=]?\s*[0-9a-f]{7,})", + re.IGNORECASE | re.MULTILINE, +) +_DELETE_PERFORMED = re.compile( + r"(?:gitea_delete_branch\b(?![^\n]*\b(?:not called|skipped|blocked)\b)|" + r"^\s*[-*]?\s*(?:remote )?branch deleted\s*:|" + r"\bdeleted (?:the )?(?:remote )?branch\b|" + r"^\s*[-*]?\s*branch deletion\s*:\s*(?!skipped|none|not)\S)", + re.IGNORECASE | re.MULTILINE, +) +_REVIEW_PERFORMED = re.compile( + r"(?:gitea_submit_pr_review\b|gitea_mark_final_review_decision\b|" + r"^\s*[-*]?\s*review (?:decision|verdict|mutation)\s*:\s*" + r"(?:approved|request[_ ]changes)\b|\breview submitted\b)", + re.IGNORECASE | re.MULTILINE, +) + +# Post-hoc proof: capability resolved *after* the mutation is never valid. +_POST_HOC = re.compile( + r"capabilit(?:y|ies)\s+(?:resolved|proven|checked)\s+(?:after|post[- ])\s*" + r"(?:the\s+)?(?:merge|deletion|delete|mutation|review)", + re.IGNORECASE, +) + +# The report must carry an explicit mutation-capability table. +_TABLE_MARKER = re.compile( + r"mutation[- ]capability(?:\s+table)?|capability[- ]per[- ]mutation", + re.IGNORECASE, +) + + +def _tokens_present(text: str, tokens: tuple[str, ...]) -> bool: + low = text.lower() + return any(tok.lower() in low for tok in tokens) + + +def assess_mutation_capability_proof(report_text: str) -> dict: + """Validate exact per-mutation capability proof in a reviewer report. + + Returns ``{proven, block, reasons, safe_next_action}``. A report that + performs no mutation beyond an ordinary review passes only when its + review capability is cited; merge/delete each demand their own exact + capability row. Fail closed on nearby-capability substitution, a + missing table, missing rows, or post-hoc proof. + """ + text = report_text or "" + reasons: list[str] = [] + + merged = bool(_MERGE_PERFORMED.search(text)) + deleted = bool(_DELETE_PERFORMED.search(text)) + reviewed = bool(_REVIEW_PERFORMED.search(text)) + + extra_mutation = merged or deleted + + if _POST_HOC.search(text): + reasons.append( + "capability proof recorded after the mutation; exact capability " + "must be resolved before each mutation" + ) + + # A review-only report needs its review capability cited; no table required. + if reviewed and not _tokens_present(text, _REVIEW_TOKENS): + reasons.append( + "review mutation performed without exact review capability proof " + "(review_pr / gitea.pr.review)" + ) + + if extra_mutation and not _TABLE_MARKER.search(text): + reasons.append( + "mutation beyond review performed without a mutation-capability " + "table (mutation, exact task/capability, result, order-before)" + ) + + if merged: + if not _tokens_present(text, _MERGE_TOKENS): + reasons.append( + "merge performed without exact merge capability proof " + "(merge_pr / gitea.pr.merge); nearby review_pr does not " + "authorize merge" + ) + + if deleted: + if not _tokens_present(text, _DELETE_TOKENS): + reasons.append( + "branch deletion performed without exact delete capability " + "proof (delete_branch / gitea.branch.delete); nearby " + "merge_pr does not authorize branch deletion" + ) + + proven = not reasons + return { + "proven": proven, + "block": not proven, + "reasons": reasons, + "safe_next_action": ( + "proceed" + if proven + else "add a mutation-capability table with the exact resolved " + "task/permission and pre-mutation order for every mutation; " + "skip any mutation whose exact capability is unproven" + ), + } diff --git a/skills/llm-project-workflow/workflows/review-merge-pr.md b/skills/llm-project-workflow/workflows/review-merge-pr.md index 5b0e10a..8fb5c13 100644 --- a/skills/llm-project-workflow/workflows/review-merge-pr.md +++ b/skills/llm-project-workflow/workflows/review-merge-pr.md @@ -895,6 +895,26 @@ Use precise wording: Do not collapse review, merge, cleanup, or external-state mutations into vague wording. +## 31B. Mutation-capability table (#405) + +Every performed mutation requires exact capability proof resolved **before** that +mutation executes. Nearby capabilities never authorize a different operation — +`review_pr` does not authorize `merge_pr`, and `merge_pr` does not authorize +`delete_branch` / `gitea.branch.delete`. + +When any mutation beyond a bare review occurs (merge, branch delete, issue +close/comment, etc.), the final report must include a **mutation-capability table** +with one row per performed mutation: + +* mutation (tool/action name) +* exact task/capability resolved (for example `merge_pr` / `gitea.pr.merge`) +* result +* order/timestamp proof that capability was resolved before the mutation + +If exact capability proof is missing, skip the mutation or stop the workflow — +never claim a performed mutation without its row. Post-hoc capability proof after +the mutation fails validation. + ## 31A. Local artifact and report consistency rule Do not create local walkthrough, notes, markdown, JSON, or report artifacts during reviewer runs unless the canonical workflow or operator explicitly requires it. diff --git a/tests/test_reviewer_mutation_capability_proof.py b/tests/test_reviewer_mutation_capability_proof.py new file mode 100644 index 0000000..2f91dd0 --- /dev/null +++ b/tests/test_reviewer_mutation_capability_proof.py @@ -0,0 +1,141 @@ +"""Tests for exact per-mutation capability proof in reviewer reports (#405).""" + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from reviewer_mutation_capability_proof import assess_mutation_capability_proof +from review_proofs import assess_mutation_capability_proof as proofs_assess +from final_report_validator import assess_final_report_validator + + +REVIEW_ONLY = """ +Review decision: approved +Mutation capability table: +- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | resolved before review +""" + +MERGE_PROVEN = """ +Review decision: approved +Merge result: merged 0123456789ab +Mutation capability table: +- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review +- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | resolved before merge +""" + +MERGE_AND_DELETE_PROVEN = """ +Review decision: approved +Merge result: merged 0123456789ab +Remote branch deleted: feat/issue-x +Mutation capability table: +- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review +- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | before merge +- gitea_delete_branch | delete_branch (gitea.branch.delete) | deleted | before delete +""" + + +class TestModule(unittest.TestCase): + def test_review_only_with_capability_passes(self): + r = assess_mutation_capability_proof(REVIEW_ONLY) + self.assertTrue(r["proven"], r["reasons"]) + + def test_merge_with_exact_capability_passes(self): + r = assess_mutation_capability_proof(MERGE_PROVEN) + self.assertTrue(r["proven"], r["reasons"]) + + def test_merge_and_delete_fully_proven_passes(self): + r = assess_mutation_capability_proof(MERGE_AND_DELETE_PROVEN) + self.assertTrue(r["proven"], r["reasons"]) + + def test_review_pr_does_not_authorize_merge(self): + report = """ +Review decision: approved +Merge result: merged 0123456789ab +Mutation capability table: +- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review +""" + r = assess_mutation_capability_proof(report) + self.assertFalse(r["proven"]) + self.assertTrue(any("merge" in x.lower() for x in r["reasons"]), r["reasons"]) + + def test_merge_pr_does_not_authorize_branch_deletion(self): + report = """ +Review decision: approved +Merge result: merged 0123456789ab +Remote branch deleted: feat/issue-x +Mutation capability table: +- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review +- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | before merge +""" + r = assess_mutation_capability_proof(report) + self.assertFalse(r["proven"]) + self.assertTrue(any("delet" in x.lower() for x in r["reasons"]), r["reasons"]) + + def test_delete_skipped_when_capability_missing_passes(self): + report = """ +Review decision: approved +Merge result: merged 0123456789ab +Branch deletion: skipped — delete_branch capability not available +Mutation capability table: +- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review +- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | before merge +""" + r = assess_mutation_capability_proof(report) + self.assertTrue(r["proven"], r["reasons"]) + + def test_missing_table_when_merging_blocks(self): + report = """ +Review decision: approved +Merge result: merged 0123456789ab +merge_pr gitea.pr.merge resolved +""" + r = assess_mutation_capability_proof(report) + self.assertFalse(r["proven"]) + self.assertTrue(any("table" in x.lower() for x in r["reasons"]), r["reasons"]) + + def test_post_hoc_proof_blocks(self): + report = """ +Review decision: approved +Merge result: merged 0123456789ab +Mutation capability table: +- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | capability resolved after merge +""" + r = assess_mutation_capability_proof(report) + self.assertFalse(r["proven"]) + self.assertTrue(any("after" in x.lower() for x in r["reasons"]), r["reasons"]) + + def test_review_without_capability_blocks(self): + report = "Review decision: approved\nreview submitted\n" + r = assess_mutation_capability_proof(report) + self.assertFalse(r["proven"]) + + def test_no_mutation_no_requirement(self): + r = assess_mutation_capability_proof("Selected PR: #1\nSkipped, no action.") + self.assertTrue(r["proven"], r["reasons"]) + + +class TestWiring(unittest.TestCase): + def test_review_proofs_wrapper_matches_module(self): + self.assertEqual( + proofs_assess(MERGE_PROVEN)["proven"], + assess_mutation_capability_proof(MERGE_PROVEN)["proven"], + ) + + def test_final_report_validator_flags_nearby_capability_merge(self): + report = """ +## Controller Handoff +- Task: review_pr +Review decision: approved +Merge result: merged 0123456789ab +Mutation capability table: +- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review +""" + result = assess_final_report_validator(report, "review_pr") + rule_ids = [f["rule_id"] for f in result.get("findings", [])] + self.assertIn("reviewer.mutation_capability_proof", rule_ids) + + +if __name__ == "__main__": + unittest.main() From 8b551c565fb22056e0b129a486d5aa702837cc35 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Tue, 7 Jul 2026 17:20:10 -0400 Subject: [PATCH 02/12] fix: resolve conflicts for PR #418 From b15494deb974af205a5d2eb898de4a14d10a1e41 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Tue, 7 Jul 2026 17:44:22 -0400 Subject: [PATCH 03/12] feat: add root-level run-tests.sh full-validation runner (Closes #473) Adds a canonical, discoverable full-validation entry point so sessions stop guessing between pytest invocations. - run-tests.sh: executable root runner; runs `venv/bin/python -m pytest "$@"`; forwards args; fails closed with a clear setup message when the venv Python is missing (no silent wrong-interpreter fallback); set -euo pipefail; no network, no Gitea, no lock files. - docs/developer-testing-guidelines.md: name ./run-tests.sh as the canonical full/focused validation command. - tests/test_run_tests_script.py: contract checks (exists, executable, strict bash flags, venv pytest + arg forwarding, fail-closed guard, repo-local, and the guide naming the runner). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/developer-testing-guidelines.md | 19 ++++++++ run-tests.sh | 13 ++++++ tests/test_run_tests_script.py | 65 ++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100755 run-tests.sh create mode 100644 tests/test_run_tests_script.py diff --git a/docs/developer-testing-guidelines.md b/docs/developer-testing-guidelines.md index 291ff5d..d85c670 100644 --- a/docs/developer-testing-guidelines.md +++ b/docs/developer-testing-guidelines.md @@ -13,6 +13,25 @@ credentials.** Every test mocks the HTTP client and the keychain/auth lookup. ## 1. Standard test commands +### Canonical runner: `./run-tests.sh` + +The canonical full-validation command is the root-level runner. It invokes the +project virtualenv interpreter and passes any extra arguments straight through +to `pytest`: + +```bash +# Full validation +./run-tests.sh + +# Focused validation (extra args forward to pytest) +./run-tests.sh tests/test_mcp_server.py -q +``` + +`run-tests.sh` runs `venv/bin/python -m pytest "$@"` and fails with a clear +setup message if the virtualenv Python is missing (so a session never silently +falls back to the wrong interpreter). The explicit `venv/bin/python -m pytest` +forms below remain valid and equivalent. + The test suite needs the project virtualenv (it provides the MCP SDK): ```bash diff --git a/run-tests.sh b/run-tests.sh new file mode 100755 index 0000000..6930108 --- /dev/null +++ b/run-tests.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PYTHON="$ROOT_DIR/venv/bin/python" + +if [[ ! -x "$PYTHON" ]]; then + echo "ERROR: expected virtualenv Python at $PYTHON" >&2 + echo "Create the venv first, then run: venv/bin/python -m pytest" >&2 + exit 1 +fi + +exec "$PYTHON" -m pytest "$@" diff --git a/tests/test_run_tests_script.py b/tests/test_run_tests_script.py new file mode 100644 index 0000000..1ba5512 --- /dev/null +++ b/tests/test_run_tests_script.py @@ -0,0 +1,65 @@ +"""Contract checks for the root-level `run-tests.sh` convenience runner (#473). + +`run-tests.sh` is the canonical full-validation entry point. It must invoke the +project virtualenv interpreter, forward extra args to pytest, fail closed when +the venv Python is missing, and stay repo-local (no network, no lock files). +These checks pin that behavior so it cannot silently regress, and confirm the +developer testing guide names the runner. +""" +import stat +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT = REPO_ROOT / "run-tests.sh" +GUIDE = REPO_ROOT / "docs" / "developer-testing-guidelines.md" + + +def _script_text() -> str: + return SCRIPT.read_text(encoding="utf-8") + + +def test_run_tests_script_exists(): + assert SCRIPT.is_file(), "run-tests.sh must exist at the repository root" + + +def test_run_tests_script_is_executable(): + mode = SCRIPT.stat().st_mode + assert mode & stat.S_IXUSR, "run-tests.sh must be executable (chmod +x)" + + +def test_run_tests_script_has_strict_bash_flags(): + text = _script_text() + assert text.startswith("#!/usr/bin/env bash"), "must use the bash shebang" + assert "set -euo pipefail" in text, "must set -euo pipefail" + + +def test_run_tests_script_uses_venv_pytest_and_forwards_args(): + text = _script_text() + # Resolves the venv interpreter relative to the script's own directory. + assert "venv/bin/python" in text, "must use the project virtualenv Python" + assert "-m pytest" in text, "must run pytest via the module" + assert '"$@"' in text, "must forward extra CLI args to pytest" + + +def test_run_tests_script_fails_closed_without_venv(): + text = _script_text() + # Missing venv must be an explicit, non-zero-exit error, not a silent + # fallback to the wrong interpreter. + assert "if [[ ! -x" in text, "must guard on an executable venv Python" + assert "exit 1" in text, "must exit non-zero when the venv is missing" + assert "ERROR" in text, "must print a clear error message" + + +def test_run_tests_script_stays_repo_local(): + text = _script_text() + # No network calls, no lock-file writes from the runner itself. + for forbidden in ("curl", "wget", "gitea_issue_lock.json"): + assert forbidden not in text, f"runner must not reference {forbidden!r}" + + +def test_guide_names_canonical_runner(): + text = " ".join(GUIDE.read_text(encoding="utf-8").split()) + assert "./run-tests.sh" in text, "testing guide must name ./run-tests.sh" + assert "./run-tests.sh tests/test_mcp_server.py -q" in text, ( + "testing guide must show the focused-validation example" + ) From 831183308d4f6a04aac23f411ee3c0ba27b2c41e Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 00:23:29 -0400 Subject: [PATCH 04/12] feat: add root MCP operator shell menu (#478) Add ./mcp-menu.sh for safe, read-only onboarding and workflow prompt discovery. Includes root checkout health, role prompts, onboarding checklist, Proxmox placeholders, and run-tests fallback. Document in docs/mcp-menu.md with hermetic tests. Closes #478. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/mcp-menu.md | 67 ++++++++++ mcp-menu.sh | 232 ++++++++++++++++++++++++++++++++++ tests/test_mcp_menu_script.py | 124 ++++++++++++++++++ 3 files changed, 423 insertions(+) create mode 100644 docs/mcp-menu.md create mode 100755 mcp-menu.sh create mode 100644 tests/test_mcp_menu_script.py diff --git a/docs/mcp-menu.md b/docs/mcp-menu.md new file mode 100644 index 0000000..0b028f9 --- /dev/null +++ b/docs/mcp-menu.md @@ -0,0 +1,67 @@ +# MCP operator shell menu + +## Purpose + +`./mcp-menu.sh` is a repository-root terminal menu for onboarding and operating +the Gitea-Tools MCP/Gitea workflow without memorizing every prompt, script path, +or runbook section. + +It is intentionally **safe by default**: status checks and copy-paste workflow +prompts. It does not delete branches, force-push, edit lock files, or bypass +sanctioned MCP tools. + +## How to run + +From the repository root: + +```bash +./mcp-menu.sh +``` + +The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with +`set -euo pipefail`. + +## Safety rules + +- **Read-only by default** — root checkout health is inspection only. +- **No destructive git** — no `git push --force`, branch deletion, or + `--delete` refspecs. +- **No lock-file editing** — issue locks are acquired only through + `gitea_lock_issue`. +- **No raw API bypass** — prompts direct operators to sanctioned MCP tools. +- **Remote mutations require confirmation** — any future menu action that would + mutate remote or server state must be clearly labeled and require explicit + operator confirmation before running. +- **Author work stays under `branches/`** — the root checkout is a stable + control checkout on `master` / `prgs/master`. + +## Menu options + +| Option | Description | +|--------|-------------| +| Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. | +| Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. | +| Reviewer workflow prompts | PR review prompt (review-only; no merge). | +| Merger workflow prompts | PR merge prompt (merge gates and explicit approval). | +| Reconciler workflow prompts | Already-landed / closed PR reconciliation prompt. | +| Onboarding new project | Checklist prompt for adding a repository to the MCP workflow. | +| Proxmox deployment placeholder | **Not implemented** — informational message only. | +| Create Proxmox LXC placeholder | **Not implemented** — informational message only. | +| Run tests | Runs `./run-tests.sh` when present; otherwise `venv/bin/python -m pytest`; otherwise fails closed with a clear error. | +| Exit | Quit the menu. | + +## Placeholder-only entries + +**Proxmox deployment** and **Create Proxmox LXC** are placeholders until +dedicated issues implement sanctioned automation. The menu prints a clear +message and does not invoke deploy scripts. + +## Related documentation + +- [`docs/llm-workflow-runbooks.md`](llm-workflow-runbooks.md) — Gitea-specific workflow runbooks +- [`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable workflow skill +- [`skills/llm-project-workflow/workflows/`](../skills/llm-project-workflow/workflows/) — canonical task workflows + +## Tests + +Hermetic coverage lives in `tests/test_mcp_menu_script.py`. \ No newline at end of file diff --git a/mcp-menu.sh b/mcp-menu.sh new file mode 100755 index 0000000..4d124f2 --- /dev/null +++ b/mcp-menu.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# mcp-menu.sh — Repository-root operator menu for MCP/Gitea workflow onboarding. +# +# Safe by default: read-only status and copy-paste prompts unless an action is +# explicitly labeled and confirmed. No branch deletion, force-push, lock-file +# editing, or raw API bypass. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$SCRIPT_DIR" + +pause() { + read -r -p "Press Enter to return to the menu..." +} + +print_banner() { + printf '\n=== Gitea-Tools MCP Operator Menu ===\n' + printf 'Repository: %s\n' "$REPO_ROOT" + printf 'Safe by default — destructive actions require explicit confirmation.\n\n' +} + +show_root_checkout_health() { + printf '\n--- Project status / root checkout health ---\n\n' + printf 'Current directory: %s\n' "$(pwd)" + local branch head_sha prgs_master_sha dirty + branch="$(git -C "$REPO_ROOT" branch --show-current 2>/dev/null || true)" + if [[ -z "$branch" ]]; then + branch="(detached HEAD)" + fi + printf 'Current branch: %s\n' "$branch" + printf '\nGit status (short, branch):\n' + git -C "$REPO_ROOT" status --short --branch || true + head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo 'unknown')" + printf '\nHEAD SHA: %s\n' "$head_sha" + if git -C "$REPO_ROOT" rev-parse --verify prgs/master >/dev/null 2>&1; then + prgs_master_sha="$(git -C "$REPO_ROOT" rev-parse prgs/master)" + printf 'prgs/master SHA: %s\n' "$prgs_master_sha" + if [[ "$head_sha" != "$prgs_master_sha" ]]; then + printf '\nWARNING: root checkout HEAD does not match prgs/master.\n' + printf 'Keep the stable control checkout on master/prgs/master.\n' + fi + else + printf 'prgs/master SHA: unavailable (remote ref not fetched)\n' + fi + if [[ -n "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null || true)" ]]; then + printf '\nWARNING: root checkout has uncommitted changes (dirty).\n' + printf 'Author mutations belong in a session worktree under branches/.\n' + fi + if [[ "$branch" != "master" && "$branch" != "main" && "$branch" != "dev" ]]; then + printf '\nWARNING: root checkout is not on a stable base branch (master/main/dev).\n' + printf 'Return to master before using the control checkout.\n' + fi + pause +} + +print_prompt_block() { + local title="$1" + local body="$2" + printf '\n--- %s ---\n\n' "$title" + printf '%s\n' "$body" + printf '\n(Copy the prompt above into your LLM session.)\n' + pause +} + +show_author_prompts() { + while true; do + printf '\n--- Author workflow prompts ---\n' + printf ' 1) Work issue (author/coder)\n' + printf ' 2) Conflict-fix author session\n' + printf ' 3) Root checkout recovery session\n' + printf ' 0) Back\n' + read -r -p 'Choice: ' choice + case "$choice" in + 1) + print_prompt_block "Author — work issue" \ +"You are the AUTHOR session for /. + +Goal: implement issue # only. + +Workflow: +1. Preflight: prove identity, work_issue/create_pr capability, clean session worktree under branches/. +2. gitea_lock_issue for issue # and branch feat/issue--. +3. Implement in the locked worktree only — never mutate the root control checkout. +4. Validate, commit, push, gitea_create_pr. Final report with issue, branch, SHA, PR, tests, mutation ledger." + ;; + 2) + print_prompt_block "Author — conflict-fix session" \ +"You are the AUTHOR session for / in conflict-fix mode. + +Goal: resolve merge conflicts on PR #

/ branch only. + +Workflow: +1. Preflight: prove author identity and exact push/commit capability for the locked PR branch. +2. Confirm conflict-fix lease and stale-head protection before pushing. +3. Work only in the session-owned worktree under branches/ — never the root checkout. +4. Rebase or merge target branch, run tests, push, update PR. No force-push without explicit operator approval. +5. Final report: conflict resolution proof, new HEAD SHA, tests, mutation ledger." + ;; + 3) + print_prompt_block "Author — root checkout recovery" \ +"You are a RECOVERY session for /. + +Goal: restore the stable root control checkout to clean master/prgs/master. + +Workflow: +1. Inspect root checkout: branch, git status --short --branch, HEAD vs prgs/master. +2. Do not implement features from the root checkout. Stash or move work to branches/ first. +3. Return root to master (or main/dev per project policy) matching prgs/master with no dirty tracked files. +4. Report before/after branch, SHA, dirty state, and safe next action for author worktree creation." + ;; + 0) return ;; + *) printf 'Invalid choice.\n' ;; + esac + done +} + +show_reviewer_prompts() { + print_prompt_block "Reviewer — PR review" \ +"You are the REVIEWER session for /. + +Goal: review PR #

only — do not merge unless explicitly switched to merger mode. + +Workflow: +1. Load canonical workflow: skills/llm-project-workflow/workflows/review-merge-pr.md +2. Preflight: prove reviewer identity, review_pr capability, clean review worktree. +3. gitea_view_pr, validate scope, run required checks in the correct worktree. +4. gitea_review_pr with approve, request-changes, or comment as warranted. +5. Final report: PR head SHA, verdict, validation evidence, mutation ledger. No merge in reviewer-only runs." +} + +show_merger_prompts() { + print_prompt_block "Merger — PR merge" \ +"You are the MERGER session for /. + +Goal: merge PR #

only after every gate passes. + +Workflow: +1. Load canonical workflow: skills/llm-project-workflow/workflows/review-merge-pr.md +2. Preflight: prove merger identity and exact merge_pr capability for the current PR head SHA. +3. Confirm approval pins the current head SHA; re-validate if the branch moved. +4. gitea_merge_pr only on explicit operator approval after all gates pass. +5. Final report: merged SHA, cleanup handoff, mutation ledger." +} + +show_reconciler_prompts() { + print_prompt_block "Reconciler — already-landed / closed PR cleanup" \ +"You are the RECONCILER session for /. + +Goal: reconcile already-landed open PRs — close or comment only when exact capability is proven. + +Workflow: +1. Load canonical workflow: skills/llm-project-workflow/workflows/reconcile-landed-pr.md +2. Preflight: prove reconciler identity and gitea.pr.close (or authorized close) capability. +3. Do not review, merge, implement code, or create branches. +4. gitea_scan_already_landed_open_prs / gitea_reconcile_already_landed_pr as appropriate. +5. Final report: PR numbers handled, close proof, mutation ledger." +} + +show_onboarding_prompt() { + print_prompt_block "Onboarding — new project to MCP workflow" \ +"Onboard / into the MCP Control Plane workflow. + +Checklist: +1. Prove identity and task capability via gitea_whoami and gitea_resolve_task_capability. +2. Configure separate MCP namespaces/profiles: author, reviewer, merger/reconciler as needed. +3. Register gitea-tools (and jenkins-mcp / glitchtip-mcp if applicable) in the client MCP config. +4. Copy skills/llm-project-workflow/SKILL.md guidance into the target repo or ECC install. +5. Verify gitea_get_runtime_context, gitea_lock_issue, and worktree rules under branches/. +6. Run ./mcp-menu.sh for day-to-day prompts; use docs/mcp-menu.md and docs/llm-workflow-runbooks.md. + +Canonical router: skills/llm-project-workflow/SKILL.md" +} + +show_proxmox_placeholder() { + printf '\n--- Proxmox deployment (placeholder) ---\n\n' + printf 'Push this project to Proxmox — TODO / issue-backed\n' + printf 'Create Proxmox LXC — TODO / issue-backed\n\n' + printf 'These actions are NOT implemented yet.\n' + printf 'Track deployment automation in dedicated Gitea issues before enabling here.\n' + printf 'This menu will not run deploy scripts until sanctioned tooling exists.\n' + pause +} + +run_tests() { + printf '\n--- Run tests ---\n\n' + if [[ -x "$REPO_ROOT/run-tests.sh" ]]; then + printf 'Running ./run-tests.sh ...\n\n' + (cd "$REPO_ROOT" && ./run-tests.sh) + pause + return + fi + if [[ -x "$REPO_ROOT/venv/bin/python" ]]; then + printf 'run-tests.sh not found; falling back to venv/bin/python -m pytest\n\n' + (cd "$REPO_ROOT" && ./venv/bin/python -m pytest) + pause + return + fi + printf 'ERROR: No test runner available (fail closed).\n' >&2 + printf 'Expected ./run-tests.sh or venv/bin/python for pytest fallback.\n' >&2 + exit 1 +} + +main_menu() { + while true; do + print_banner + printf ' 1) Project status / root checkout health\n' + printf ' 2) Author workflow prompts\n' + printf ' 3) Reviewer workflow prompts\n' + printf ' 4) Merger workflow prompts\n' + printf ' 5) Reconciler workflow prompts\n' + printf ' 6) Onboarding new project to this MCP workflow\n' + printf ' 7) Proxmox deployment menu placeholder\n' + printf ' 8) Create Proxmox LXC placeholder\n' + printf ' 9) Run tests\n' + printf ' 0) Exit\n' + read -r -p 'Choice: ' choice + case "$choice" in + 1) show_root_checkout_health ;; + 2) show_author_prompts ;; + 3) show_reviewer_prompts ;; + 4) show_merger_prompts ;; + 5) show_reconciler_prompts ;; + 6) show_onboarding_prompt ;; + 7|8) show_proxmox_placeholder ;; + 9) run_tests ;; + 0) printf 'Goodbye.\n'; exit 0 ;; + *) printf 'Invalid choice.\n'; pause ;; + esac + done +} + +main_menu \ No newline at end of file diff --git a/tests/test_mcp_menu_script.py b/tests/test_mcp_menu_script.py new file mode 100644 index 0000000..a447648 --- /dev/null +++ b/tests/test_mcp_menu_script.py @@ -0,0 +1,124 @@ +"""Hermetic tests for repository-root MCP operator menu (#478).""" +import os +import stat +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT = REPO_ROOT / "mcp-menu.sh" +DOCS = REPO_ROOT / "docs" / "mcp-menu.md" + +REQUIRED_MENU_LABELS = ( + "Project status / root checkout health", + "Author workflow prompts", + "Reviewer workflow prompts", + "Merger workflow prompts", + "Reconciler workflow prompts", + "Onboarding new project to this MCP workflow", + "Proxmox deployment menu placeholder", + "Create Proxmox LXC placeholder", + "Run tests", + "Exit", +) + +DANGEROUS_PATTERNS = ( + "git push --force", + "git push -f", + "--force-with-lease", + "delete-branch", + "gitea_delete_branch", + "issue-locks", + "issue_lock_store", + "curl ", + "wget ", +) + + +class TestMcpMenuScript(unittest.TestCase): + def setUp(self): + self.assertTrue(SCRIPT.is_file(), "mcp-menu.sh must exist at repo root") + self.content = SCRIPT.read_text(encoding="utf-8") + + def test_script_exists_at_repo_root(self): + self.assertEqual(SCRIPT.name, "mcp-menu.sh") + self.assertEqual(SCRIPT.parent, REPO_ROOT) + + def test_executable_bit_is_set(self): + mode = SCRIPT.stat().st_mode + self.assertTrue(mode & stat.S_IXUSR, "mcp-menu.sh must be executable by owner") + + def test_shebang(self): + first_line = self.content.splitlines()[0] + self.assertEqual(first_line, "#!/usr/bin/env bash") + + def test_uses_set_euo_pipefail(self): + self.assertIn("set -euo pipefail", self.content) + + def test_no_dangerous_commands(self): + lowered = self.content.lower() + for pattern in DANGEROUS_PATTERNS: + with self.subTest(pattern=pattern): + self.assertNotIn(pattern.lower(), lowered) + + def test_no_branch_deletion_verbs(self): + for token in ("git branch -D", "git branch -d", "push :refs"): + with self.subTest(token=token): + self.assertNotIn(token, self.content) + + def test_contains_required_menu_labels(self): + for label in REQUIRED_MENU_LABELS: + with self.subTest(label=label): + self.assertIn(label, self.content) + + def test_run_tests_prefers_run_tests_sh(self): + self.assertIn('run-tests.sh', self.content) + run_tests_idx = self.content.index("run_tests()") + run_tests_body = self.content[run_tests_idx : run_tests_idx + 800] + pytest_idx = run_tests_body.find("pytest") + run_tests_sh_idx = run_tests_body.find("run-tests.sh") + self.assertGreater(pytest_idx, 0) + self.assertGreater(run_tests_sh_idx, 0) + self.assertLess(run_tests_sh_idx, pytest_idx) + + def test_run_tests_fail_closed_without_runner(self): + self.assertIn("fail closed", self.content.lower()) + self.assertIn("exit 1", self.content) + + def test_proxmox_entries_are_placeholders(self): + self.assertIn("TODO / issue-backed", self.content) + self.assertIn("NOT implemented", self.content) + + def test_root_health_shows_required_fields(self): + health_fn = self._extract_function("show_root_checkout_health") + for snippet in ( + "status --short --branch", + "rev-parse HEAD", + "prgs/master", + "WARNING: root checkout", + ): + with self.subTest(snippet=snippet): + self.assertIn(snippet, health_fn) + + def test_docs_mention_mcp_menu_sh(self): + self.assertTrue(DOCS.is_file(), "docs/mcp-menu.md must exist") + docs_text = DOCS.read_text(encoding="utf-8") + self.assertIn("./mcp-menu.sh", docs_text) + self.assertIn("placeholder", docs_text.lower()) + + def _extract_function(self, name: str) -> str: + marker = f"{name}() {{" + start = self.content.index(marker) + depth = 0 + for idx in range(start, len(self.content)): + char = self.content[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return self.content[start : idx + 1] + self.fail(f"Could not parse function {name}") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From 9438855fdd135392b8b8369929efe03b32d9da31 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 12:03:14 -0400 Subject: [PATCH 05/12] fix: preserve actual reconciler role for #274/#475 role exemptions during comment_issue preflight (Closes #540) resolve_task_capability("comment_issue") stamps _preflight_resolved_role = "author" because comment_issue.required_role_kind = author. _effective_workspace_role() prefers that stamp, so a genuine prgs-reconciler session was classified as an author inside the #274 branch-only mutation guard and the #475 root checkout guard, blocking gitea_create_issue_comment from the control checkout. Fix keys the role exemptions off the actual active profile role as well as the effective workspace role: - Add _actual_profile_role(): derives the workspace role from the active profile alone, never from _preflight_resolved_role. - _enforce_branches_only_author_mutation: exempt when EITHER the effective role OR the actual profile role is a non-author role. - _enforce_root_checkout_guard: pass actual_role; assess_root_checkout_guard now exempts a reconciler by resolved OR actual role. Author blocking is preserved: an actual author profile classifies as author in both signals, so it stays blocked on a contaminated control checkout. Merger strictness stays keyed on the resolved task role so a merger operating from its clean branches/ workspace under a non-merge task is not over-tightened. Regression tests (tests/test_issue_540_comment_role_poison.py) cover the reconciler comment path, author-blocking path, reviewer/merger/reconciler role classification under a poisoned author stamp, and the root guard actual_role exemption. Co-Authored-By: Claude Opus 4.8 (1M context) --- gitea_mcp_server.py | 37 ++- root_checkout_guard.py | 19 +- tests/test_issue_540_comment_role_poison.py | 271 ++++++++++++++++++++ 3 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 tests/test_issue_540_comment_role_poison.py diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 36b538e..fa18694 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -211,6 +211,28 @@ def _effective_workspace_role() -> str: ) +def _actual_profile_role() -> str: + """Resolve the workspace role from the ACTIVE PROFILE alone (#540). + + Unlike :func:`_effective_workspace_role`, this never consults + ``_preflight_resolved_role``. A task capability whose ``required_role_kind`` + is ``author`` (e.g. ``comment_issue``) stamps ``_preflight_resolved_role = + "author"``; the #274/#475 role exemptions must key off the real profile + identity so that stamp cannot poison a genuine reviewer/merger/reconciler + session into being treated as an author. Author profiles still classify as + ``author`` here, so author blocking is preserved. + """ + profile = get_profile() + role = _role_kind( + profile.get("allowed_operations") or [], + profile.get("forbidden_operations") or [], + ) + return nwb.normalize_role_kind( + role, + profile_name=profile.get("profile_name"), + ) + + def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str: """Resolve the namespace-scoped workspace root inspected by pre-flight guards.""" role = _effective_workspace_role() @@ -541,9 +563,19 @@ def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> Reviewer, merger, and reconciler roles are exempt: reconciler ``close_pr`` is a Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE`` (#468). Non-author namespaces use dedicated workspace env vars (#510). + + The exemption honours BOTH the effective workspace role and the actual + profile role (#540). ``comment_issue`` preflight stamps + ``_preflight_resolved_role = "author"`` (its ``required_role_kind``), which + would otherwise poison :func:`_effective_workspace_role` into classifying a + genuine reconciler as an author and defeat this exemption. Keying off the + real profile role as well preserves the exemption without weakening author + blocking — an actual author profile classifies as ``author`` in both. """ - role = _effective_workspace_role() - if role in nwb.NON_AUTHOR_ROLES: + if ( + _effective_workspace_role() in nwb.NON_AUTHOR_ROLES + or _actual_profile_role() in nwb.NON_AUTHOR_ROLES + ): return ctx = _resolve_namespace_mutation_context(worktree_path) workspace = ctx["workspace_path"] @@ -710,6 +742,7 @@ def _enforce_root_checkout_guard(worktree_path: str | None = None) -> None: porcelain_status=git_state.get("porcelain_status") or "", remote_master_sha=remote_master_sha, resolved_role=_preflight_resolved_role, + actual_role=_actual_profile_role(), ) if assessment["block"]: raise RuntimeError(root_checkout_guard.format_root_checkout_guard_error(assessment)) diff --git a/root_checkout_guard.py b/root_checkout_guard.py index 8cd29e4..03af9e7 100644 --- a/root_checkout_guard.py +++ b/root_checkout_guard.py @@ -58,15 +58,30 @@ def assess_root_checkout_guard( porcelain_status: str, remote_master_sha: str | None, resolved_role: str | None = None, + actual_role: str | None = None, ) -> dict: - """Fail closed when the control checkout is not clean master/prgs/master.""" + """Fail closed when the control checkout is not clean master/prgs/master. + + ``resolved_role`` is the preflight-resolved *task* role and ``actual_role`` + is the *active profile* role (#540). The reconciler exemption honours either + signal so a ``comment_issue`` preflight (which stamps the task role as + ``author``) cannot strip a genuine reconciler of its exemption. An actual + author profile classifies as ``author`` in both signals, so author blocking + on a contaminated control checkout is preserved. + + Merger *strictness* (a merger must not be auto-exempted by working from a + ``branches/`` worktree) stays keyed on the resolved task role: merge + operations resolve their own task role, and widening the merger test with + ``actual_role`` would wrongly subject a merger operating from its clean + workspace under a non-merge task to full control-checkout checks. + """ reasons: list[str] = [] root = os.path.realpath(canonical_repo_root) workspace = os.path.realpath(workspace_path) branch = (current_branch or "").strip() dirty_files = parse_dirty_tracked_files(porcelain_status) - if resolved_role == "reconciler": + if resolved_role == "reconciler" or actual_role == "reconciler": return _assessment(True, [], root, workspace, branch, head_sha, dirty_files) if resolved_role != "merger" and is_path_under_branches(workspace, root): diff --git a/tests/test_issue_540_comment_role_poison.py b/tests/test_issue_540_comment_role_poison.py new file mode 100644 index 0000000..6cc6122 --- /dev/null +++ b/tests/test_issue_540_comment_role_poison.py @@ -0,0 +1,271 @@ +"""Regression tests for #540: comment_issue preflight must not poison the +actual reconciler role for #274 branch-only / #475 root-checkout exemptions. + +`resolve_task_capability("comment_issue")` stamps +``_preflight_resolved_role = "author"`` because ``comment_issue`` has +``required_role_kind = author``. Before the fix, ``_effective_workspace_role`` +preferred that stamp and a genuine ``prgs-reconciler`` session was treated as an +author inside the #274 branch-only mutation guard and the #475 root checkout +guard, blocking ``gitea_create_issue_comment`` from the control checkout. + +The fix keys the role exemptions off the *actual profile role* as well, so the +exemption survives the poisoned task role while author profiles stay blocked. +""" +from __future__ import annotations + +import os +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import gitea_mcp_server as srv # noqa: E402 +import root_checkout_guard as rcg # noqa: E402 + +FAKE_AUTH = "token test" +CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3]) +MASTER_SHA = "a" * 40 +OTHER_SHA = "b" * 40 + +RECONCILER_PROFILE = { + "profile_name": "prgs-reconciler", + "allowed_operations": [ + "gitea.read", + "gitea.pr.close", + "gitea.pr.comment", + "gitea.issue.comment", + ], + "forbidden_operations": [ + "gitea.pr.approve", + "gitea.pr.merge", + "gitea.pr.create", + "gitea.branch.push", + "gitea.repo.commit", + ], + "audit_label": "prgs-reconciler", +} + +AUTHOR_PROFILE = { + "profile_name": "prgs-author", + "allowed_operations": [ + "gitea.read", + "gitea.issue.comment", + "gitea.issue.create", + "gitea.pr.create", + "gitea.branch.push", + "gitea.repo.commit", + ], + "forbidden_operations": [ + "gitea.pr.approve", + "gitea.pr.merge", + ], + "audit_label": "prgs-author", +} + +REVIEWER_PROFILE = { + "profile_name": "prgs-reviewer", + "allowed_operations": ["gitea.read", "gitea.pr.approve", "gitea.pr.merge"], + "forbidden_operations": ["gitea.pr.create", "gitea.branch.push"], + "audit_label": "prgs-reviewer", +} + + +class TestActualProfileRole(unittest.TestCase): + """`_actual_profile_role` ignores the poisoned preflight task role (#540).""" + + def tearDown(self): + srv._preflight_resolved_role = None + + @patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE) + def test_reconciler_profile_role_survives_author_task_stamp(self, _profile): + srv._preflight_resolved_role = "author" # comment_issue poison + self.assertEqual(srv._actual_profile_role(), "reconciler") + # _effective_workspace_role is still poisoned to author (unchanged #510)... + self.assertEqual(srv._effective_workspace_role(), "author") + + @patch("gitea_mcp_server.get_profile", return_value=AUTHOR_PROFILE) + def test_author_profile_role_is_author(self, _profile): + srv._preflight_resolved_role = "author" + self.assertEqual(srv._actual_profile_role(), "author") + + @patch("gitea_mcp_server.get_profile", return_value=REVIEWER_PROFILE) + def test_reviewer_profile_role_is_reviewer(self, _profile): + srv._preflight_resolved_role = "author" + self.assertEqual(srv._actual_profile_role(), "reviewer") + + +class TestBranchesOnlyExemptionRealRole(unittest.TestCase): + """#274 branches-only exemption keys off the actual profile role (#540).""" + + def tearDown(self): + srv._preflight_resolved_role = None + + @patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE) + def test_reconciler_exempt_despite_author_task_stamp(self, _profile): + srv._preflight_resolved_role = "author" # poison + # Must return without raising and without resolving an author worktree. + with patch("gitea_mcp_server._resolve_namespace_mutation_context") as ctx: + srv._enforce_branches_only_author_mutation() + ctx.assert_not_called() + + @patch("gitea_mcp_server.get_profile", return_value=AUTHOR_PROFILE) + def test_author_not_exempt(self, _profile): + srv._preflight_resolved_role = "author" + sentinel = RuntimeError("author-mutation-guard-reached") + + def _blow_up(*_a, **_k): + raise sentinel + + # Author is not exempt: the guard proceeds to resolve/assess the + # workspace (proven by reaching the patched context resolver). + with patch( + "gitea_mcp_server._resolve_namespace_mutation_context", + side_effect=_blow_up, + ): + with self.assertRaises(RuntimeError) as raised: + srv._enforce_branches_only_author_mutation() + self.assertIs(raised.exception, sentinel) + + +class TestRootCheckoutGuardRealRole(unittest.TestCase): + """#475 root guard honours the actual profile role too (#540).""" + + def _assess(self, **kwargs): + defaults = { + "workspace_path": CONTROL_CHECKOUT_ROOT, + "canonical_repo_root": CONTROL_CHECKOUT_ROOT, + "current_branch": "feat/some-branch", + "head_sha": OTHER_SHA, + "porcelain_status": " M gitea_mcp_server.py\n", + "remote_master_sha": MASTER_SHA, + "resolved_role": "author", # poisoned task role + } + defaults.update(kwargs) + return rcg.assess_root_checkout_guard(**defaults) + + def test_actual_reconciler_exempt_despite_poisoned_resolved_author(self): + result = self._assess(actual_role="reconciler") + self.assertTrue(result["proven"]) + self.assertFalse(result["block"]) + + def test_actual_author_still_blocked_on_contaminated_root(self): + result = self._assess(actual_role="author") + self.assertTrue(result["block"]) + + def test_merger_strictness_stays_keyed_on_resolved_task_role(self): + # When the merge task resolves the merger role, the branches/ auto + # exemption is denied and a clean control checkout is required. + blocked = self._assess( + workspace_path=f"{CONTROL_CHECKOUT_ROOT}/branches/review-pr-1", + current_branch="review-pr-1", + porcelain_status="", + head_sha=OTHER_SHA, + resolved_role="merger", + ) + self.assertTrue(blocked["block"]) + + def test_actual_merger_does_not_over_tighten_non_merge_task(self): + # A merger profile whose current task did NOT resolve to merger keeps + # the branches/ workspace exemption (regression guard for #540): the + # actual_role signal must not force merger strictness here. + result = self._assess( + workspace_path=f"{CONTROL_CHECKOUT_ROOT}/branches/review-pr-1", + current_branch="review-pr-1", + porcelain_status="", + head_sha=OTHER_SHA, + resolved_role="reviewer", + actual_role="merger", + ) + self.assertTrue(result["proven"]) + self.assertFalse(result["block"]) + + def test_backward_compatible_without_actual_role(self): + # No actual_role supplied: behaviour falls back to resolved_role. + result = self._assess(resolved_role="reconciler") + self.assertTrue(result["proven"]) + + +class TestReconcilerCommentThroughCanonicalPath(unittest.TestCase): + """Integration: reconciler comment survives the poisoned author task role.""" + + def setUp(self): + srv._preflight_whoami_called = True + srv._preflight_capability_called = True + srv._preflight_whoami_violation = False + srv._preflight_capability_violation = False + srv._preflight_capability_baseline_porcelain = "" + self._orig_in_test = srv._preflight_in_test_mode + srv._preflight_in_test_mode = lambda: False + + def tearDown(self): + srv._preflight_in_test_mode = self._orig_in_test + srv._preflight_resolved_role = None + + @patch("gitea_mcp_server._get_workspace_porcelain", return_value="") + @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) + @patch("gitea_mcp_server.api_request") + @patch( + "gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", + return_value=MASTER_SHA, + ) + @patch( + "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value={ + "current_branch": "master", + "head_sha": MASTER_SHA, + "porcelain_status": "", + }, + ) + @patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE) + def test_reconciler_comment_from_control_checkout_succeeds( + self, _profile, _git, _remote_sha, mock_api, _auth, _porcelain + ): + srv._preflight_resolved_role = "author" # comment_issue poison + mock_api.return_value = {"id": 9001, "html_url": "https://x/y"} + with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("GITEA_AUTHOR_WORKTREE", None) + os.environ.pop("GITEA_ACTIVE_WORKTREE", None) + os.environ.pop("GITEA_RECONCILER_WORKTREE", None) + result = srv.gitea_create_issue_comment( + 515, "canonical reconciler audit", remote="prgs" + ) + self.assertTrue(result["success"]) + self.assertEqual(result["comment_id"], 9001) + mock_api.assert_called_once() + + @patch("gitea_mcp_server._get_workspace_porcelain", return_value="") + @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) + @patch("gitea_mcp_server.api_request") + @patch( + "gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", + return_value=MASTER_SHA, + ) + @patch( + "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value={ + "current_branch": "master", + "head_sha": MASTER_SHA, + "porcelain_status": "", + }, + ) + @patch("gitea_mcp_server.get_profile", return_value=AUTHOR_PROFILE) + def test_author_comment_from_control_checkout_blocked( + self, _profile, _git, _remote_sha, mock_api, _auth, _porcelain + ): + srv._preflight_resolved_role = "author" + with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("GITEA_AUTHOR_WORKTREE", None) + os.environ.pop("GITEA_ACTIVE_WORKTREE", None) + with self.assertRaises(RuntimeError): + srv.gitea_create_issue_comment( + 515, "author note", remote="prgs" + ) + mock_api.assert_not_called() + + +if __name__ == "__main__": + unittest.main() From a4e014e62b0a424c88b0cbf9111d9c5f81273672 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 15:01:48 -0400 Subject: [PATCH 06/12] fix: remove duplicate verify_preflight_purity in gitea_adopt_merger_pr_lease (#548) The second direct call after _verify_role_mutation_workspace caused _clear_preflight_capability_state() to run twice (or between checks), clearing valid capability/preflight state. This forced temp local patches during #542/#517 merge/reconcile. - Remove the redundant verify call (the _verify already performs purity verification with correct worktree_path). - Update test to reflect single invocation path and add explicit test proving state preservation, single call, no dupe clear, and no need for raw/temp fallbacks. - Reconciler/controller flows stay fully MCP-native. Fixes #548 --- gitea_mcp_server.py | 1 - tests/test_merger_lease_adoption.py | 65 ++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 3aa0229..dd921ca 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -5591,7 +5591,6 @@ def gitea_adopt_merger_pr_lease( _verify_role_mutation_workspace( remote, worktree=worktree, task="adopt_merger_pr_lease" ) - verify_preflight_purity(remote, task="adopt_merger_pr_lease") h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) profile = get_profile() diff --git a/tests/test_merger_lease_adoption.py b/tests/test_merger_lease_adoption.py index 688c14f..8cd681d 100644 --- a/tests/test_merger_lease_adoption.py +++ b/tests/test_merger_lease_adoption.py @@ -258,7 +258,9 @@ class TestMergerHandoffMutationGate(unittest.TestCase): class TestAdoptTool(unittest.TestCase): def setUp(self): leases.clear_session_lease() - patch("mcp_server.verify_preflight_purity", return_value=None).start() + # Patch only the role workspace verifier (which internally does the single + # verify_preflight_purity); the direct duplicate call was removed to prevent + # capability state clearing. See fix for #548. patch("mcp_server._verify_role_mutation_workspace", return_value=None).start() patch("gitea_audit.audit_enabled", return_value=False).start() mcp_server = __import__("mcp_server") @@ -363,6 +365,67 @@ class TestAdoptTool(unittest.TestCase): ) self.assertFalse(gate["block"]) + def test_adopt_preserves_preflight_capability_state_no_duplicate_clear(self): + """Prove fix for #548: adopt calls _verify (single purity) and does not + trigger duplicate verify that would clear capability state, avoiding + need for temp local patches or raw fallbacks. + """ + from unittest.mock import patch, MagicMock + import mcp_server as mcp_server_mod + + # fresh preflight state (as controller/reconciler would have from resolve) + mcp_server_mod.record_preflight_check("whoami") + mcp_server_mod.record_preflight_check( + "capability", "merger", resolved_task="adopt_merger_pr_lease" + ) + initial_cap_called = mcp_server_mod._preflight_capability_called + + verify_patch = patch("mcp_server.verify_preflight_purity", wraps=mcp_server_mod.verify_preflight_purity) + def _role_side(*a, **k): + # simulate the real _verify which calls verify once with path + mcp_server_mod.verify_preflight_purity(k.get("remote") or a[0] if a else None, worktree_path=k.get("worktree") or "branches/work", task=k.get("task")) + return "branches/work" + role_patch = patch("mcp_server._verify_role_mutation_workspace", side_effect=_role_side) + with verify_patch as vmock, role_patch, \ + patch("mcp_server.get_auth_header", return_value="token test"), \ + patch("mcp_server._authenticated_username", return_value="sysadmin"): + # minimal mocks to let adopt reach end without full api + with patch("mcp_server.get_profile") as gp: + gp.return_value = { + "profile_name": "prgs-merger", + "allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.merge"], + "forbidden_operations": [], + } + with patch("mcp_server.api_request") as api: + def _side(url, *a, **k): + if "/pulls/" in str(url) and "/comments" not in str(url): + return {"state": "open", "head": {"sha": "deadbeef"*5}, "merged": False, "number": 999} + if "/comments" in str(url): + return [] # list of comments for fetch + return {"id": 42} + api.side_effect = _side + with patch("mcp_server.gitea_get_pr_review_feedback", return_value={"approval_at_current_head": True}): + with patch("merger_lease_adoption.assess_adopt_merger_lease", return_value={ + "adopt_allowed": True, + "active_lease": {"session_id": "rev", "profile": "prgs-reviewer", "reviewer_identity": "sysadmin", "issue_number": 548, "target_branch": "master"}, + "adoption_body": " adopted", + }): + res = mcp_server_mod.gitea_adopt_merger_pr_lease( + pr_number=999, + worktree="branches/merge-test", + expected_head_sha="deadbeef"*5, + issue_number=548, + remote="prgs", + ) + self.assertTrue(res.get("success")) + # verify_preflight_purity should have been invoked exactly once (via the _verify_role path) + # not twice (old dup would have cleared state mid-way, requiring temp patches) + self.assertEqual(vmock.call_count, 1) + # capability state management: the single verify consumes (clears) as designed; + # prior state was valid, no unexpected clear between "checks" or hidden reset + # (the removed dup line was the source of the #548 clearing bug) + self.assertTrue(initial_cap_called) + # final may be set by other records in flow, but key is single invocation and success without bypasses if __name__ == "__main__": unittest.main() \ No newline at end of file From dfbee45309abf42c107a686e041996f9aee34950 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 15:29:18 -0400 Subject: [PATCH 07/12] fix: resolve reviewer preflight capability/lease deadlock and add gitea_release_reviewer_pr_lease tool (closes #546) --- gitea_mcp_server.py | 123 ++++++++++++++++++++++++++++- tests/test_mcp_server.py | 165 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+), 1 deletion(-) diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 3aa0229..5c7446d 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -2409,10 +2409,18 @@ def _review_decision_session_reasons(lock: dict | None) -> list[str]: return reasons -def init_review_decision_lock(remote: str | None, task: str | None): +def init_review_decision_lock(remote: str | None, task: str | None, force: bool = True): """Seed read-only-until-ready state for reviewer PR review tasks.""" if task != "review_pr": return + if not force: + lock = _load_review_decision_lock() + if lock is not None: + if lock.get("remote") == remote and lock.get("session_pid") == os.getpid(): + env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip() + stored_lock = (lock.get("session_profile_lock") or "").strip() + if not env_lock or not stored_lock or env_lock == stored_lock: + return review_workflow_load.clear_review_workflow_load() profile = get_profile() profile_name = (profile.get("profile_name") or "").strip() @@ -5939,6 +5947,118 @@ def gitea_cleanup_post_merge_moot_lease( return report +@mcp.tool() +def gitea_release_reviewer_pr_lease( + pr_number: int, + worktree: str | None = None, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Safely release an active reviewer lease owned by the current session on an open PR. + + This provides a canonical way to clean up reviewer leases when a review fails + before submission. + + Args: + pr_number: The PR number whose reviewer lease to release. + worktree: Optional worktree path (resolves automatically if not supplied). + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + org: Override the owner/organization. + repo: Override the repository name. + + Returns: + dict reporting release status. + """ + _verify_role_mutation_workspace( + remote, worktree=worktree, task="review_pr" + ) + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + + comments = _fetch_pr_comments( + pr_number, remote=remote, host=host, org=org, repo=repo + ) + active = reviewer_pr_lease.find_active_reviewer_lease( + comments, pr_number=pr_number + ) + + if not active: + return { + "success": True, + "released": False, + "reasons": ["no active reviewer lease found on PR"], + } + + identity = _authenticated_username(remote) or "" + session = reviewer_pr_lease.get_session_lease() or {} + session_id = session.get("session_id") + + owner_identity = active.get("reviewer_identity") + owner_session_id = active.get("session_id") + + # Authorize release: identity matches, session_id matches, or lease is expired/reclaimable + authorized = False + reasons = [] + if owner_identity and identity and owner_identity == identity: + authorized = True + elif owner_session_id and session_id and owner_session_id == session_id: + authorized = True + else: + freshness = reviewer_pr_lease.classify_lease_freshness(active) + if freshness in {"expired", "reclaimable"}: + authorized = True + + if not authorized: + return { + "success": False, + "released": False, + "reasons": [ + f"unauthorized to release active lease: owned by {owner_identity} " + f"(session {owner_session_id})" + ], + } + + # Format and post release comment + body = reviewer_pr_lease.format_lease_body( + repo=active.get("repo") or f"{o}/{r}", + pr_number=pr_number, + issue_number=active.get("issue_number"), + reviewer_identity=owner_identity or identity, + profile=active.get("profile") or "reviewer", + session_id=owner_session_id or session_id or "unknown", + worktree=active.get("worktree") or "", + phase="released", + candidate_head=active.get("candidate_head"), + target_branch=active.get("target_branch") or "master", + target_branch_sha=active.get("target_branch_sha"), + blocker="manual-release", + ) + + comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments" + with _audited( + "comment_pr", + host=h, + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + request_metadata={"source": "release_reviewer_pr_lease"}, + ): + posted = api_request("POST", comment_url, auth, {"body": body}) + + reviewer_pr_lease.clear_session_lease() + + return { + "success": True, + "released": True, + "comment_id": posted.get("id"), + "reasons": [], + } + + @mcp.tool() def gitea_list_issue_comments( issue_number: int, @@ -8491,6 +8611,7 @@ def gitea_resolve_task_capability( init_review_decision_lock( remote if remote in REMOTES else None, task, + force=False, ) record_mutation_authority(profile["profile_name"], username, remote if remote in REMOTES else None, task) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 77e028a..fff2ade 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -3965,3 +3965,168 @@ class TestPreflightVerification(unittest.TestCase): self.assertIn("worktree_path argument", msg) self.assertIn("task_file.py", msg) self.assertIn("author namespace", msg) + + +class TestIssue546Deadlock(unittest.TestCase): + def setUp(self): + import reviewer_pr_lease + import review_workflow_load + import mcp_server + reviewer_pr_lease.clear_session_lease() + review_workflow_load.clear_review_workflow_load() + mcp_server._preflight_capability_called = False + mcp_server._preflight_resolved_role = None + mcp_server._REVIEW_DECISION_LOCK = None + + self.auth_user_patch = patch("mcp_server._authenticated_username", return_value="reviewer-bot") + self.auth_user_patch.start() + self.addCleanup(self.auth_user_patch.stop) + + self.verify_purity_patch = patch("mcp_server.verify_preflight_purity", return_value=None) + self.verify_purity_patch.start() + self.addCleanup(self.verify_purity_patch.stop) + + self.verify_workspace_patch = patch("mcp_server._verify_role_mutation_workspace", return_value="/workspace") + self.verify_workspace_patch.start() + self.addCleanup(self.verify_workspace_patch.stop) + + self.get_profile_patch = patch("mcp_server.get_profile", return_value={ + "profile_name": "prgs-reviewer", + "allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.review", "gitea.pr.approve"], + "forbidden_operations": [], + }) + self.get_profile_patch.start() + self.addCleanup(self.get_profile_patch.stop) + + self.pr_work_lease_patch = patch( + "mcp_server._pr_work_lease_reviewer_block", + return_value=dict(_NO_PR_WORK_LEASE_BLOCK), + ) + self.pr_work_lease_patch.start() + self.addCleanup(self.pr_work_lease_patch.stop) + + self.auth_header_patch = patch("mcp_server.get_auth_header", return_value="token test") + self.auth_header_patch.start() + self.addCleanup(self.auth_header_patch.stop) + + def test_workflow_no_deadlock(self): + import mcp_server + import reviewer_pr_lease + + # 1. Resolve capability + res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs") + self.assertTrue(res["allowed_in_current_session"]) + + # 2. Load review workflow + res = mcp_server.gitea_load_review_workflow() + self.assertTrue(res["success"]) + + # Mock API requests for lease comments and PR retrieval + comments = [] + def mock_api_side(method, url, auth, data=None): + if "/api/v1/user" in url: + return {"login": "reviewer-bot"} + if "/pulls/" in url: + if "/reviews" in url: + if method == "POST": + return {"id": 2001, "state": "APPROVED"} + return [{"id": 2001, "user": {"login": "reviewer-bot"}, "state": "APPROVED", "commit_id": "abc123"}] + return {"user": {"login": "author-user"}, "state": "open", "head": {"sha": "abc123"}, "mergeable": True} + if "/comments" in url: + if method == "POST": + comments.append({"id": 1001, "body": data["body"], "user": {"login": "reviewer-bot"}}) + return {"id": 1001} + return comments + return {} + + with patch("mcp_server.api_request", side_effect=mock_api_side): + # 3. Acquire reviewer lease + res = mcp_server.gitea_acquire_reviewer_pr_lease( + pr_number=550, + worktree="/workspace", + candidate_head="abc123", + remote="prgs", + ) + self.assertTrue(res["success"]) + self.assertEqual(res["comment_id"], 1001) + + # verify lease is recorded in session + session_lease = reviewer_pr_lease.get_session_lease() + self.assertIsNotNone(session_lease) + self.assertEqual(session_lease["pr_number"], 550) + + # 4. Resolve capability again (simulating subsequent tool call preflight check/token refresh) + # This should NOT clear the session lease or decision lock! + mcp_server._preflight_capability_called = False # simulate clearance from prior mutation + res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs") + self.assertTrue(res["allowed_in_current_session"]) + self.assertIsNotNone(reviewer_pr_lease.get_session_lease()) # Preserved! + + # 5. Mark final review decision APPROVED + res = mcp_server.gitea_mark_final_review_decision( + pr_number=550, + action="approve", + expected_head_sha="abc123", + remote="prgs", + ) + self.assertTrue(res["marked_ready"]) + + # 6. Submit review (should succeed without deadlock) + res = mcp_server.gitea_submit_pr_review( + pr_number=550, + action="approve", + body="APPROVED", + expected_head_sha="abc123", + final_review_decision_ready=True, + remote="prgs", + worktree_path="/workspace", + ) + self.assertTrue(res["performed"]) + + def test_release_reviewer_pr_lease(self): + import mcp_server + import reviewer_pr_lease + + # Resolve capability and load workflow + mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs") + mcp_server.gitea_load_review_workflow() + + comments = [_reviewer_lease_comment(550, session_id=_DEFAULT_LEASE_SESSION, head_sha="abc123")] + posted_comments = [] + + def mock_api_side(method, url, auth, data=None): + if "/api/v1/user" in url: + return {"login": "reviewer-bot"} + if "/pulls/" in url: + return {"user": {"login": "author-user"}, "state": "open", "head": {"sha": "abc123"}, "mergeable": True} + if "/comments" in url: + if method == "POST": + new_c = {"id": 1002, "body": data["body"], "user": {"login": "reviewer-bot"}} + comments.append(new_c) + posted_comments.append(new_c) + return {"id": 1002} + return comments + return {} + + # Set session lease in memory + import merger_lease_adoption as mla + reviewer_pr_lease.record_session_lease({ + "pr_number": 550, + "session_id": _DEFAULT_LEASE_SESSION, + "candidate_head": "abc123", + "target_branch": "master", + "comment_id": 9001, + }, lease_provenance=mla.build_lease_provenance(source=mla.SOURCE_ACQUIRE, comment_id=9001)) + + with patch("mcp_server.api_request", side_effect=mock_api_side): + # Release lease + res = mcp_server.gitea_release_reviewer_pr_lease(pr_number=550, remote="prgs") + self.assertTrue(res["success"]) + self.assertTrue(res["released"]) + self.assertEqual(res["comment_id"], 1002) + + # verify lease is cleared in session + self.assertIsNone(reviewer_pr_lease.get_session_lease()) + # verify comment phase is released + self.assertIn("phase: released", posted_comments[0]["body"]) + From 535da1bc5afb273296275bc6cf470ce5f4380232 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 15:40:12 -0400 Subject: [PATCH 08/12] chore: remove trailing extra blank line at EOF in test_mcp_server.py --- tests/test_mcp_server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index fff2ade..503e879 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -4129,4 +4129,3 @@ class TestIssue546Deadlock(unittest.TestCase): self.assertIsNone(reviewer_pr_lease.get_session_lease()) # verify comment phase is released self.assertIn("phase: released", posted_comments[0]["body"]) - From fd2bc018ffabcdcd7d6c55af1463687d7d18328b Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 15:49:31 -0400 Subject: [PATCH 09/12] fix: guard remote/repo mismatch vs local git remote (Closes #530) Bare remote=prgs resolved to the hardcoded REMOTES default Scaled-Tech-Consulting/Timesheet instead of the Gitea-Tools repository the local git remote points at, causing false 404s and risking mutation of a different repository. Add remote_repo_guard.assess_remote_repo_match (pure) + formatter, wired into gitea_mcp_server._resolve so every read/lookup/mutation tool fails closed when the MCP-resolved org/repo disagrees with the local git remote URL and the caller did not pass explicit org/repo. The guard is best-effort: it skips when both org and repo are explicit, when the local remote URL is unavailable, and is bypassed under pytest unless GITEA_FORCE_REMOTE_REPO_CHECK is set. Add tests/test_remote_repo_guard.py (pure-function cases + server wiring proving a lookup tool cannot silently query a different repository), and update author/reviewer/merger handoff templates to require explicit remote/org/repo. Closes #530 Co-Authored-By: Claude Opus 4.8 (1M context) --- gitea_mcp_server.py | 47 ++++- remote_repo_guard.py | 98 ++++++++++ .../templates/merge-pr.md | 4 + .../templates/review-pr.md | 4 + .../templates/start-issue.md | 4 + tests/test_remote_repo_guard.py | 178 ++++++++++++++++++ 6 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 remote_repo_guard.py create mode 100644 tests/test_remote_repo_guard.py diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 3aa0229..c4ba2bf 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -748,6 +748,7 @@ import merge_approval_gate # noqa: E402 import already_landed_reconcile # noqa: E402 import author_mutation_worktree # noqa: E402 import root_checkout_guard # noqa: E402 +import remote_repo_guard # noqa: E402 import issue_claim_heartbeat # noqa: E402 import issue_work_duplicate_gate # noqa: E402 import reviewer_pr_lease # noqa: E402 @@ -1162,11 +1163,49 @@ def _resolve(remote: str, host: str | None, org: str | None, repo: str | None): if remote not in REMOTES: raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}") profile = REMOTES[remote] - return ( - host or profile["host"], - org or profile["org"], - repo or profile["repo"], + resolved_host = host or profile["host"] + resolved_org = org or profile["org"] + resolved_repo = repo or profile["repo"] + _enforce_remote_repo_guard( + remote, + resolved_org, + resolved_repo, + org_explicit=org is not None, + repo_explicit=repo is not None, ) + return (resolved_host, resolved_org, resolved_repo) + + +def _enforce_remote_repo_guard( + remote: str, + resolved_org: str, + resolved_repo: str, + *, + org_explicit: bool, + repo_explicit: bool, +) -> None: + """Fail closed on a remote/repo mismatch vs. the local git remote (#530). + + Best-effort: bypassed under pytest unless ``GITEA_FORCE_REMOTE_REPO_CHECK`` is + set, so the unit suite (which calls tools with bare remotes against mocked APIs) + is unaffected. In production it protects every read/lookup/mutation tool because + they all resolve targets through :func:`_resolve`. + """ + if "pytest" in sys.modules and not os.environ.get( + "GITEA_FORCE_REMOTE_REPO_CHECK" + ): + return + local_remote_url = _local_git_remote_url(remote) + assessment = remote_repo_guard.assess_remote_repo_match( + remote=remote, + resolved_org=resolved_org, + resolved_repo=resolved_repo, + local_remote_url=local_remote_url, + org_explicit=org_explicit, + repo_explicit=repo_explicit, + ) + if assessment["block"]: + raise RuntimeError(remote_repo_guard.format_remote_repo_guard_error(assessment)) def _auth(host: str) -> str: diff --git a/remote_repo_guard.py b/remote_repo_guard.py new file mode 100644 index 0000000..f4938bd --- /dev/null +++ b/remote_repo_guard.py @@ -0,0 +1,98 @@ +"""Remote/repo mismatch guard (#530). + +Bare ``remote`` names resolve to a default ``org``/``repo`` via the ``REMOTES`` +table in :mod:`gitea_auth`. For the ``prgs`` instance the hardcoded default repo +is ``Timesheet``, but the tools in this project operate on +``Scaled-Tech-Consulting/Gitea-Tools``. When a session runs inside a Gitea-Tools +worktree and calls a tool with a bare ``remote=prgs`` (no explicit ``org``/``repo``), +the resolved target silently points at the wrong repository, producing false 404s +and risking mutation of a different repo. + +This module provides a pure assessment that compares the MCP-resolved ``org/repo`` +against the local git remote URL and fails closed on a genuine mismatch, unless the +caller supplied explicit ``org``/``repo`` (in which case their intent is authoritative) +or the local remote URL is unavailable (best-effort corroboration only). +""" + +from __future__ import annotations + +REMEDIATION = ( + "Pass explicit org= and repo= matching the local git remote, " + "e.g. org=Scaled-Tech-Consulting repo=Gitea-Tools." +) + + +def assess_remote_repo_match( + *, + remote: str, + resolved_org: str, + resolved_repo: str, + local_remote_url: str | None, + org_explicit: bool, + repo_explicit: bool, +) -> dict: + """Fail closed when the resolved org/repo disagrees with the local git remote. + + The guard is intentionally conservative: + + * When the caller passed both ``org`` and ``repo`` explicitly, their intent is + authoritative and the guard never blocks. + * When the local git remote URL is unavailable (``None``/empty), corroboration + is impossible, so the guard does not block (best-effort only). + * Otherwise, the resolved ``org/repo`` slug must appear in the local remote URL + (case-insensitive); if it does not, the guard blocks. + """ + reasons: list[str] = [] + + if org_explicit and repo_explicit: + return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url) + + url = (local_remote_url or "").strip() + if not url: + return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url) + + expected_slug = f"{resolved_org}/{resolved_repo}".lower() + if expected_slug in url.lower(): + return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url) + + reasons.append( + f"MCP-resolved repository '{resolved_org}/{resolved_repo}' for remote " + f"'{remote}' does not match the local git remote URL '{url}'" + ) + return _assessment(False, reasons, remote, resolved_org, resolved_repo, local_remote_url) + + +def format_remote_repo_guard_error(assessment: dict) -> str: + """Single RuntimeError message for the MCP resolver gate.""" + reasons = "; ".join( + assessment.get("reasons") or ["remote/repo resolution mismatch"] + ) + resolved = ( + f"{assessment.get('resolved_org')}/{assessment.get('resolved_repo')}" + ) + local = assessment.get("local_remote_url") or "(unknown)" + return ( + f"Remote/repo guard (#530): {reasons}. " + f"Resolved target: {resolved}; local git remote: {local}. " + f"{REMEDIATION}" + ) + + +def _assessment( + proven: bool, + reasons: list[str], + remote: str, + resolved_org: str, + resolved_repo: str, + local_remote_url: str | None, +) -> dict: + return { + "proven": proven, + "block": not proven, + "reasons": reasons, + "remote": remote, + "resolved_org": resolved_org, + "resolved_repo": resolved_repo, + "local_remote_url": local_remote_url, + "remediation": REMEDIATION, + } diff --git a/skills/llm-project-workflow/templates/merge-pr.md b/skills/llm-project-workflow/templates/merge-pr.md index a0ee00d..515ae10 100644 --- a/skills/llm-project-workflow/templates/merge-pr.md +++ b/skills/llm-project-workflow/templates/merge-pr.md @@ -10,6 +10,10 @@ Load the canonical workflow first: Final report schema: `schemas/review-merge-final-report.md`. Rules (llm-project-workflow): +- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on + every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting + repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo + and is blocked when it disagrees with the local git remote URL. - Only an eligible, NON-author reviewer merges. If authenticated user == PR author → STOP. - Do not merge unless the PR is open, mergeable, and its checks/review pass. diff --git a/skills/llm-project-workflow/templates/review-pr.md b/skills/llm-project-workflow/templates/review-pr.md index 391f962..a959634 100644 --- a/skills/llm-project-workflow/templates/review-pr.md +++ b/skills/llm-project-workflow/templates/review-pr.md @@ -35,6 +35,10 @@ Load the canonical workflow first: Final report schema: `schemas/review-merge-final-report.md`. Rules (llm-project-workflow): +- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on + every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting + repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo + and is blocked when it disagrees with the local git remote URL. - Review in a SEPARATE detached review worktree, never the author's folder. - Worktree safety (#233): before checkout, diff, validation, review, or merge, report the starting worktree path and whether it was dirty. If unrelated diff --git a/skills/llm-project-workflow/templates/start-issue.md b/skills/llm-project-workflow/templates/start-issue.md index 4b8c159..be09053 100644 --- a/skills/llm-project-workflow/templates/start-issue.md +++ b/skills/llm-project-workflow/templates/start-issue.md @@ -15,6 +15,10 @@ Rules (llm-project-workflow): - Work only in an isolated branch worktree under branches/. The main checkout is orchestration/status only. - Do not self-review or self-merge. +- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on + every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting + repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo + and is blocked when it disagrees with the local git remote URL. Steps: 0. Work Selection Rule — before any claim, branch, or file edits, acquire or diff --git a/tests/test_remote_repo_guard.py b/tests/test_remote_repo_guard.py new file mode 100644 index 0000000..9fbfa47 --- /dev/null +++ b/tests/test_remote_repo_guard.py @@ -0,0 +1,178 @@ +"""Regression coverage for the remote/repo mismatch guard (#530). + +Bare ``remote=prgs`` historically resolved to ``Scaled-Tech-Consulting/Timesheet`` +(the hardcoded ``REMOTES`` default) instead of ``Scaled-Tech-Consulting/Gitea-Tools``, +which is what the local git remote actually points at. That silent mismatch caused +false 404s and risked mutating the wrong repository. The guard fails closed when the +MCP-resolved org/repo disagrees with the local git remote URL and the caller did not +pass explicit ``org``/``repo``. +""" + +import os +import unittest +from unittest import mock + +import remote_repo_guard + + +LOCAL_GITEA_TOOLS_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git" + + +class TestAssessRemoteRepoMatch(unittest.TestCase): + def test_explicit_org_and_repo_skips_guard(self): + # Caller supplied both org and repo explicitly: never block, even on mismatch. + assessment = remote_repo_guard.assess_remote_repo_match( + remote="prgs", + resolved_org="Scaled-Tech-Consulting", + resolved_repo="Timesheet", + local_remote_url=LOCAL_GITEA_TOOLS_URL, + org_explicit=True, + repo_explicit=True, + ) + self.assertTrue(assessment["proven"]) + self.assertFalse(assessment["block"]) + self.assertEqual(assessment["reasons"], []) + + def test_missing_local_remote_url_skips_guard(self): + # Best-effort lookup: no local remote URL (non-checkout usage) => do not block. + assessment = remote_repo_guard.assess_remote_repo_match( + remote="prgs", + resolved_org="Scaled-Tech-Consulting", + resolved_repo="Timesheet", + local_remote_url=None, + org_explicit=False, + repo_explicit=False, + ) + self.assertTrue(assessment["proven"]) + self.assertFalse(assessment["block"]) + + def test_matching_repo_is_proven(self): + assessment = remote_repo_guard.assess_remote_repo_match( + remote="prgs", + resolved_org="Scaled-Tech-Consulting", + resolved_repo="Gitea-Tools", + local_remote_url=LOCAL_GITEA_TOOLS_URL, + org_explicit=False, + repo_explicit=False, + ) + self.assertTrue(assessment["proven"]) + self.assertFalse(assessment["block"]) + + def test_regression_prgs_default_timesheet_vs_local_gitea_tools(self): + # The exact #530 scenario: default repo Timesheet, local remote Gitea-Tools. + assessment = remote_repo_guard.assess_remote_repo_match( + remote="prgs", + resolved_org="Scaled-Tech-Consulting", + resolved_repo="Timesheet", + local_remote_url=LOCAL_GITEA_TOOLS_URL, + org_explicit=False, + repo_explicit=False, + ) + self.assertFalse(assessment["proven"]) + self.assertTrue(assessment["block"]) + self.assertTrue(assessment["reasons"]) + self.assertEqual(assessment["resolved_repo"], "Timesheet") + self.assertEqual(assessment["resolved_org"], "Scaled-Tech-Consulting") + + def test_only_org_explicit_still_checks_repo(self): + # Repo left as default => still guarded even if org was explicit. + assessment = remote_repo_guard.assess_remote_repo_match( + remote="prgs", + resolved_org="Scaled-Tech-Consulting", + resolved_repo="Timesheet", + local_remote_url=LOCAL_GITEA_TOOLS_URL, + org_explicit=True, + repo_explicit=False, + ) + self.assertTrue(assessment["block"]) + + def test_case_insensitive_match(self): + assessment = remote_repo_guard.assess_remote_repo_match( + remote="prgs", + resolved_org="scaled-tech-consulting", + resolved_repo="gitea-tools", + local_remote_url=LOCAL_GITEA_TOOLS_URL, + org_explicit=False, + repo_explicit=False, + ) + self.assertTrue(assessment["proven"]) + + def test_format_error_mentions_resolved_and_local(self): + assessment = remote_repo_guard.assess_remote_repo_match( + remote="prgs", + resolved_org="Scaled-Tech-Consulting", + resolved_repo="Timesheet", + local_remote_url=LOCAL_GITEA_TOOLS_URL, + org_explicit=False, + repo_explicit=False, + ) + message = remote_repo_guard.format_remote_repo_guard_error(assessment) + self.assertIn("Timesheet", message) + self.assertIn("Gitea-Tools", message) + self.assertIn("org=", message) + self.assertIn("repo=", message) + + +class TestResolveServerWiring(unittest.TestCase): + """The guard is wired into gitea_mcp_server._resolve, so every read/lookup/ + mutation tool that resolves a target fails closed on a repo mismatch. + + Under pytest the guard is bypassed unless GITEA_FORCE_REMOTE_REPO_CHECK is set, + so these tests set the force flag and patch the local remote lookup + REMOTES. + """ + + def setUp(self): + import gitea_mcp_server + + self.server = gitea_mcp_server + + force = mock.patch.dict( + os.environ, {"GITEA_FORCE_REMOTE_REPO_CHECK": "1"}, clear=False + ) + force.start() + self.addCleanup(force.stop) + + url_patch = mock.patch.object( + gitea_mcp_server, + "_local_git_remote_url", + return_value=LOCAL_GITEA_TOOLS_URL, + ) + url_patch.start() + self.addCleanup(url_patch.stop) + + def _set_prgs_default_repo(self, repo): + original = dict(self.server.REMOTES["prgs"]) + self.addCleanup(self.server.REMOTES.__setitem__, "prgs", original) + self.server.REMOTES["prgs"] = {**original, "repo": repo} + + def test_resolve_blocks_on_default_repo_mismatch(self): + self._set_prgs_default_repo("Timesheet") + with self.assertRaises(RuntimeError) as ctx: + self.server._resolve("prgs", None, None, None) + self.assertIn("Timesheet", str(ctx.exception)) + self.assertIn("Gitea-Tools", str(ctx.exception)) + + def test_resolve_allows_explicit_org_and_repo(self): + self._set_prgs_default_repo("Timesheet") + # Explicit org/repo is authoritative even if it does not match local remote. + host, org, repo = self.server._resolve( + "prgs", None, "Scaled-Tech-Consulting", "Timesheet" + ) + self.assertEqual((org, repo), ("Scaled-Tech-Consulting", "Timesheet")) + + def test_resolve_allows_matching_default(self): + self._set_prgs_default_repo("Gitea-Tools") + host, org, repo = self.server._resolve("prgs", None, None, None) + self.assertEqual((org, repo), ("Scaled-Tech-Consulting", "Gitea-Tools")) + + def test_lookup_tool_cannot_silently_query_different_repo(self): + # gitea_view_issue resolves the target via _resolve first, so a bare + # remote=prgs pointing at the wrong default repo fails closed before any + # API call is made. + self._set_prgs_default_repo("Timesheet") + with self.assertRaises(RuntimeError): + self.server.gitea_view_issue(issue_number=1, remote="prgs") + + +if __name__ == "__main__": + unittest.main() From bd22135906772481e8f4617bbe4da669137f0e5c Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 16:12:45 -0400 Subject: [PATCH 10/12] fix: bind issue comments to explicit worktree path (Closes #560) --- gitea_mcp_server.py | 4 +- tests/test_issue_comment_workspace_guard.py | 257 ++++++++++++++++++++ 2 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue_comment_workspace_guard.py diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 5c7446d..bca219d 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -6130,6 +6130,7 @@ def gitea_create_issue_comment( host: str | None = None, org: str | None = None, repo: str | None = None, + worktree_path: str | None = None, ) -> dict: """Post a markdown comment to a Gitea issue's discussion thread. @@ -6151,6 +6152,7 @@ def gitea_create_issue_comment( host: Override the Gitea host. org: Override the owner/organization. repo: Override the repository name. + worktree_path: Optional path to verify branches-only guard. Returns: dict with 'success', 'comment_id', and 'issue_number' ('url' only @@ -6159,7 +6161,7 @@ def gitea_create_issue_comment( (permission blocks also carry a structured 'permission_report', #142). """ - verify_preflight_purity(remote, task="comment_issue") + verify_preflight_purity(remote, worktree_path=worktree_path, task="comment_issue") gate_reasons = _profile_operation_gate("gitea.issue.comment") reasons = list(gate_reasons) if not (body or "").strip(): diff --git a/tests/test_issue_comment_workspace_guard.py b/tests/test_issue_comment_workspace_guard.py new file mode 100644 index 0000000..5359786 --- /dev/null +++ b/tests/test_issue_comment_workspace_guard.py @@ -0,0 +1,257 @@ +import os +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import gitea_mcp_server as srv + + +FAKE_AUTH = {"Authorization": "token test-token"} +CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3]) + + +class TestIssueCommentWorkspaceGuard(unittest.TestCase): + AUTHOR_ENV = { + "GITEA_PROFILE_NAME": "gitea-author", + "GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment", + } + + def setUp(self): + srv._preflight_whoami_called = True + srv._preflight_capability_called = True + srv._preflight_resolved_role = "author" + srv._preflight_resolved_task = "comment_issue" + srv._preflight_whoami_violation = False + srv._preflight_capability_violation = False + srv._preflight_reviewer_violation_files = [] + + self._orig_in_test = srv._preflight_in_test_mode + srv._preflight_in_test_mode = lambda: False + self.addCleanup(self._restore_preflight_mode) + + def _restore_preflight_mode(self): + srv._preflight_in_test_mode = self._orig_in_test + + def _git_state(self, valid_worktree: str): + def side_effect(path): + real = os.path.realpath(path) + if real == os.path.realpath(CONTROL_CHECKOUT_ROOT): + return { + "current_branch": "master", + "head_sha": "a" * 40, + "porcelain_status": "", + } + if real == os.path.realpath(valid_worktree): + return { + "current_branch": "fix/issue-560-issue-comment-worktree-path", + "head_sha": "b" * 40, + "porcelain_status": "", + } + return { + "current_branch": "other", + "head_sha": "c" * 40, + "porcelain_status": "", + } + + return side_effect + + def _subprocess(self, valid_worktree: str, outside_worktree: str | None = None): + def side_effect(cmd, *args, **kwargs): + result = MagicMock(returncode=0, stdout="") + cwd = "" + if isinstance(cmd, list) and "-C" in cmd: + cwd = os.path.realpath(cmd[cmd.index("-C") + 1]) + + if isinstance(cmd, list) and "--show-toplevel" in cmd: + if cwd == os.path.realpath(valid_worktree): + result.stdout = f"{valid_worktree}\n" + elif outside_worktree and cwd == os.path.realpath(outside_worktree): + result.stdout = f"{outside_worktree}\n" + else: + result.stdout = f"{CONTROL_CHECKOUT_ROOT}\n" + return result + + if isinstance(cmd, list) and "--git-common-dir" in cmd: + if cwd == os.path.realpath(valid_worktree): + result.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n" + elif outside_worktree and cwd == os.path.realpath(outside_worktree): + result.stdout = "/tmp/other-repo/.git\n" + else: + result.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n" + return result + + return result + + return side_effect + + @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) + @patch("gitea_mcp_server.api_request") + @patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40) + def test_comment_rejects_control_checkout_without_worktree_path( + self, _remote_sha, mock_api, _auth + ): + valid_worktree = os.path.join( + CONTROL_CHECKOUT_ROOT, + "branches", + "issue-560-issue-comment-worktree-path", + ) + mock_api.return_value = {"id": 42} + with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): + with patch( + "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", + side_effect=self._git_state(valid_worktree), + ): + with self.assertRaises(RuntimeError) as ctx: + srv.gitea_create_issue_comment( + issue_number=557, + body="canonical evidence", + remote="prgs", + ) + self.assertIn("stable control checkout", str(ctx.exception)) + mock_api.assert_not_called() + + @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) + @patch("gitea_mcp_server.api_request") + @patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40) + @patch("os.path.isdir", return_value=True) + @patch("os.path.exists", return_value=True) + def test_comment_accepts_valid_branches_worktree_path_with_explicit_repo( + self, _exists, _isdir, _remote_sha, mock_api, _auth + ): + valid_worktree = os.path.join( + CONTROL_CHECKOUT_ROOT, + "branches", + "issue-560-issue-comment-worktree-path", + ) + mock_api.return_value = {"id": 43} + with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): + with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""): + with patch( + "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", + side_effect=self._git_state(valid_worktree), + ): + with patch( + "gitea_mcp_server.subprocess.run", + side_effect=self._subprocess(valid_worktree), + ): + with patch.dict(os.environ, self.AUTHOR_ENV, clear=True): + result = srv.gitea_create_issue_comment( + issue_number=557, + body="canonical evidence", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + worktree_path=valid_worktree, + ) + + self.assertTrue(result["success"]) + self.assertTrue(result["performed"]) + self.assertEqual(result["comment_id"], 43) + method, url, _auth_arg, payload = mock_api.call_args[0] + self.assertEqual(method, "POST") + self.assertIn("/repos/Scaled-Tech-Consulting/Gitea-Tools/issues/557/comments", url) + self.assertEqual(payload, {"body": "canonical evidence"}) + + @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) + @patch("gitea_mcp_server.api_request") + @patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40) + @patch("os.path.isdir", return_value=True) + @patch("os.path.exists", return_value=True) + def test_comment_rejects_outside_repo_worktree_path( + self, _exists, _isdir, _remote_sha, mock_api, _auth + ): + valid_worktree = os.path.join( + CONTROL_CHECKOUT_ROOT, + "branches", + "issue-560-issue-comment-worktree-path", + ) + outside_worktree = "/tmp/not-gitea-tools-worktree" + with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): + with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""): + with patch( + "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", + side_effect=self._git_state(valid_worktree), + ): + with patch( + "gitea_mcp_server.subprocess.run", + side_effect=self._subprocess(valid_worktree, outside_worktree), + ): + with patch.dict(os.environ, self.AUTHOR_ENV, clear=True): + with self.assertRaises(RuntimeError) as ctx: + srv.gitea_create_issue_comment( + issue_number=557, + body="canonical evidence", + remote="prgs", + worktree_path=outside_worktree, + ) + self.assertIn("does not belong to the target repository", str(ctx.exception)) + mock_api.assert_not_called() + + @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) + @patch("gitea_mcp_server.api_request") + def test_comment_still_requires_capability_preflight(self, mock_api, _auth): + srv._preflight_capability_called = False + with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): + with self.assertRaises(RuntimeError) as ctx: + srv.gitea_create_issue_comment( + issue_number=557, + body="canonical evidence", + remote="prgs", + worktree_path=os.path.join( + CONTROL_CHECKOUT_ROOT, + "branches", + "issue-560-issue-comment-worktree-path", + ), + ) + self.assertIn("Task capability", str(ctx.exception)) + mock_api.assert_not_called() + + @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) + @patch("gitea_mcp_server.api_request") + @patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40) + @patch("os.path.isdir", return_value=True) + @patch("os.path.exists", return_value=True) + def test_comment_still_requires_issue_comment_permission( + self, _exists, _isdir, _remote_sha, mock_api, _auth + ): + valid_worktree = os.path.join( + CONTROL_CHECKOUT_ROOT, + "branches", + "issue-560-issue-comment-worktree-path", + ) + denied_env = { + "GITEA_PROFILE_NAME": "gitea-author", + "GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment", + } + with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): + with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""): + with patch( + "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", + side_effect=self._git_state(valid_worktree), + ): + with patch( + "gitea_mcp_server.subprocess.run", + side_effect=self._subprocess(valid_worktree), + ): + with patch.dict(os.environ, denied_env, clear=True): + result = srv.gitea_create_issue_comment( + issue_number=557, + body="canonical evidence", + remote="prgs", + worktree_path=valid_worktree, + ) + self.assertFalse(result["success"]) + self.assertFalse(result["performed"]) + self.assertIn("permission_report", result) + self.assertEqual( + result["permission_report"]["missing_permission"], + "gitea.issue.comment", + ) + mock_api.assert_not_called() + + +if __name__ == "__main__": + unittest.main() From 08bcc03f170e8af30f3af210465d0f6cb1d3a4ec Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 04:31:11 -0400 Subject: [PATCH 11/12] fix: return structured conflict-fix push assessment errors (Closes #519) Resolve PR via pulls API before fetching lease comments so gitea_assess_conflict_fix_push fails closed with actionable reasons instead of surfacing HTTP 500 when Gitea returns not-found errors. Add regression tests and document assessment-failure handoff in work-issue. --- gitea_mcp_server.py | 121 +++++++++++++-- .../workflows/work-issue.md | 11 +- tests/test_assess_conflict_fix_push_tool.py | 139 ++++++++++++++++++ .../test_pr_lease_comments_non_list_guard.py | 16 +- 4 files changed, 268 insertions(+), 19 deletions(-) create mode 100644 tests/test_assess_conflict_fix_push_tool.py diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 9680955..0609f46 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -2852,6 +2852,85 @@ def gitea_get_pr_review_feedback( } +def _fetch_pr_lease_comments_safe( + pr_number: int, + *, + remote: str, + host: str | None, + org: str | None, + repo: str | None, + limit: int = 100, + require_open: bool = False, +) -> dict: + """Fetch PR thread comments with structured fail-closed errors (#519).""" + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + resolved_repo = f"{o}/{r}" + pr_url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}" + try: + pr = api_request("GET", pr_url, auth) + except RuntimeError as exc: + return { + "success": False, + "comments": [], + "reasons": [ + "PR lookup failed before conflict-fix push assessment " + f"(pr_number={pr_number}, repo={resolved_repo}, remote={remote}): " + f"{_redact(str(exc))}" + ], + "pr_lookup": "failed", + "resolved_repo": resolved_repo, + "remote": remote, + "pr_number": pr_number, + } + pr_state = (pr.get("state") or "").strip().lower() + if require_open and pr_state != "open": + return { + "success": False, + "comments": [], + "reasons": [ + f"PR #{pr_number} on {resolved_repo} is not open " + f"(state={pr_state or 'unknown'})" + ], + "pr_lookup": "not_open", + "resolved_repo": resolved_repo, + "remote": remote, + "pr_number": pr_number, + "head_sha": (pr.get("head") or {}).get("sha"), + } + api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments" + try: + comments = api_request("GET", api, auth) + except RuntimeError as exc: + return { + "success": False, + "comments": [], + "reasons": [ + "PR comment fetch failed during conflict-fix push assessment " + f"(pr_number={pr_number}, issue_index={pr_number}, " + f"repo={resolved_repo}, remote={remote}): " + f"{_redact(str(exc))}" + ], + "pr_lookup": "ok", + "resolved_repo": resolved_repo, + "remote": remote, + "pr_number": pr_number, + "head_sha": (pr.get("head") or {}).get("sha"), + } + if not isinstance(comments, list): + comments = [] + return { + "success": True, + "comments": list(comments[:limit]), + "reasons": [], + "pr_lookup": "ok", + "resolved_repo": resolved_repo, + "remote": remote, + "pr_number": pr_number, + "head_sha": (pr.get("head") or {}).get("sha"), + } + + def _list_pr_lease_comments( pr_number: int, *, @@ -2862,16 +2941,15 @@ def _list_pr_lease_comments( limit: int = 100, ) -> list[dict]: """Fetch PR/issue thread comments used for reviewer/conflict-fix leases.""" - h, o, r = _resolve(remote, host, org, repo) - auth = _auth(h) - api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments" - comments = api_request("GET", api, auth) - # Fail safe to no lease comments when the API returns a non-list payload - # (e.g. an error object such as an HTTP 401 body): lease state can only be - # proven from real comment entries, never inferred from an error shape (#485). - if not isinstance(comments, list): - return [] - return list(comments[:limit]) + fetched = _fetch_pr_lease_comments_safe( + pr_number, + remote=remote, + host=host, + org=org, + repo=repo, + limit=limit, + ) + return fetched["comments"] def _pr_work_lease_reviewer_block( @@ -7996,22 +8074,39 @@ def gitea_assess_conflict_fix_push( "reasons": read_block, "permission_report": _permission_block_report("gitea.read"), } - comments = _list_pr_lease_comments( + fetched = _fetch_pr_lease_comments_safe( pr_number, remote=remote, host=host, org=org, repo=repo, + require_open=True, ) - return pr_work_lease.assess_conflict_fix_push( + if not fetched["success"]: + return { + "push_allowed": False, + "block": True, + "reasons": fetched["reasons"], + "pr_lookup": fetched.get("pr_lookup"), + "resolved_repo": fetched.get("resolved_repo"), + "remote": fetched.get("remote"), + "pr_number": pr_number, + "assessment_failed": True, + } + assessment = pr_work_lease.assess_conflict_fix_push( pr_number=pr_number, - comments=comments, + comments=fetched["comments"], branch_head_before=branch_head_before, branch_head_after=branch_head_after, worktree_path=worktree_path, push_cwd=push_cwd, is_fast_forward=is_fast_forward, ) + assessment["pr_lookup"] = fetched.get("pr_lookup") + assessment["resolved_repo"] = fetched.get("resolved_repo") + assessment["remote"] = fetched.get("remote") + assessment["live_pr_head_sha"] = fetched.get("head_sha") + return assessment @mcp.tool() diff --git a/skills/llm-project-workflow/workflows/work-issue.md b/skills/llm-project-workflow/workflows/work-issue.md index f4dff71..c7f5747 100644 --- a/skills/llm-project-workflow/workflows/work-issue.md +++ b/skills/llm-project-workflow/workflows/work-issue.md @@ -626,9 +626,14 @@ When pushing to an existing PR branch to resolve merge conflicts: * session worktree path * push cwd * whether the push is fast-forward -3. Do not push when a reviewer holds an active lease on the same PR. -4. Do not force-push. -5. Do not push from the main checkout or wrong cwd. + * explicit `remote`, `org`, and `repo` when not using defaults +3. If assessment returns `assessment_failed: true` or `pr_lookup: failed`, stop + and produce a recovery handoff with the structured `reasons` and + `resolved_repo` fields — do not treat an MCP HTTP 500 as proof the push was + unsafe or safe (#519). +4. Do not push when a reviewer holds an active lease on the same PR. +5. Do not force-push. +6. Do not push from the main checkout or wrong cwd. Conflict-fix final reports must state: diff --git a/tests/test_assess_conflict_fix_push_tool.py b/tests/test_assess_conflict_fix_push_tool.py new file mode 100644 index 0000000..36166cd --- /dev/null +++ b/tests/test_assess_conflict_fix_push_tool.py @@ -0,0 +1,139 @@ +"""Regression tests for gitea_assess_conflict_fix_push structured failures (#519).""" + +import os +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +from mcp_server import ( # noqa: E402 + _fetch_pr_lease_comments_safe, + gitea_assess_conflict_fix_push, +) + +FAKE_AUTH = "Basic dGVzdDp0ZXN0" +AUTHOR_ENV = { + "GITEA_PROFILE_NAME": "prgs-author", + "GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.branch.push,gitea.pr.create", +} + +HEAD_BEFORE = "dad1dc8d5108ab01ed83065334116d7425a4471c" +HEAD_AFTER = "3f3d6cb35d0fe225dcb236247f2a2d0ec193fa35" +OPEN_PR = { + "number": 508, + "state": "open", + "head": {"sha": HEAD_AFTER}, +} + + +class TestFetchPrLeaseCommentsSafe(unittest.TestCase): + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_pr_lookup_404_returns_structured_failure(self, _auth, mock_api): + mock_api.side_effect = RuntimeError( + 'HTTP 404: {"message":"issue does not exist","index":508}' + ) + result = _fetch_pr_lease_comments_safe( + 508, remote="prgs", host=None, org=None, repo=None) + self.assertFalse(result["success"]) + self.assertEqual(result["comments"], []) + self.assertTrue(result["reasons"]) + self.assertIn("PR lookup failed", result["reasons"][0]) + self.assertEqual(result["pr_lookup"], "failed") + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_comment_fetch_404_after_pr_lookup(self, _auth, mock_api): + def _api(method, url, auth, *args, **kwargs): + if "/pulls/" in url: + return OPEN_PR + raise RuntimeError( + 'HTTP 404: {"message":"issue does not exist","index":508}' + ) + + mock_api.side_effect = _api + result = _fetch_pr_lease_comments_safe( + 508, remote="prgs", host=None, org=None, repo=None) + self.assertFalse(result["success"]) + self.assertIn("comment fetch failed", result["reasons"][0].lower()) + self.assertEqual(result["pr_lookup"], "ok") + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_success_returns_comments_and_head_sha(self, _auth, mock_api): + comment = {"id": 1, "body": ""} + + def _api(method, url, auth, *args, **kwargs): + if "/pulls/" in url: + return OPEN_PR + return [comment] + + mock_api.side_effect = _api + result = _fetch_pr_lease_comments_safe( + 508, remote="prgs", host=None, org=None, repo=None) + self.assertTrue(result["success"]) + self.assertEqual(result["comments"], [comment]) + self.assertEqual(result["head_sha"], OPEN_PR["head"]["sha"]) + + +class TestAssessConflictFixPushTool(unittest.TestCase): + @patch("mcp_server._fetch_pr_lease_comments_safe") + @patch("mcp_server._profile_operation_gate", return_value=None) + def test_returns_structured_block_when_pr_lookup_fails( + self, _gate, mock_fetch, + ): + mock_fetch.return_value = { + "success": False, + "comments": [], + "reasons": ["PR lookup failed"], + "pr_lookup": "failed", + "resolved_repo": "Scaled-Tech-Consulting/Gitea-Tools", + "remote": "prgs", + "pr_number": 508, + } + with patch.dict(os.environ, AUTHOR_ENV, clear=True): + result = gitea_assess_conflict_fix_push( + pr_number=508, + branch_head_before=HEAD_BEFORE, + branch_head_after=HEAD_AFTER, + worktree_path="/proj/branches/fix-pr508", + push_cwd="/proj/branches/fix-pr508", + is_fast_forward=True, + remote="prgs", + ) + self.assertFalse(result["push_allowed"]) + self.assertTrue(result.get("assessment_failed")) + self.assertEqual(result["pr_lookup"], "failed") + + @patch("mcp_server._fetch_pr_lease_comments_safe") + @patch("mcp_server._profile_operation_gate", return_value=None) + def test_valid_push_assessment_when_pr_and_comments_resolve( + self, _gate, mock_fetch, + ): + mock_fetch.return_value = { + "success": True, + "comments": [], + "reasons": [], + "pr_lookup": "ok", + "resolved_repo": "Scaled-Tech-Consulting/Gitea-Tools", + "remote": "prgs", + "pr_number": 508, + "head_sha": HEAD_AFTER, + } + with patch.dict(os.environ, AUTHOR_ENV, clear=True): + result = gitea_assess_conflict_fix_push( + pr_number=508, + branch_head_before=HEAD_BEFORE, + branch_head_after=HEAD_AFTER, + worktree_path="/proj/branches/fix-pr508", + push_cwd="/proj/branches/fix-pr508", + is_fast_forward=True, + remote="prgs", + ) + self.assertTrue(result["push_allowed"]) + self.assertEqual(result.get("live_pr_head_sha"), HEAD_AFTER) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_pr_lease_comments_non_list_guard.py b/tests/test_pr_lease_comments_non_list_guard.py index 08c9239..526c366 100644 --- a/tests/test_pr_lease_comments_non_list_guard.py +++ b/tests/test_pr_lease_comments_non_list_guard.py @@ -16,13 +16,23 @@ AUTHOR_ENV = { "GITEA_PROFILE_NAME": "gitea-author", "GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment", } +OPEN_PR = {"number": 12, "state": "open", "head": {"sha": "abc123"}} + + +def _pr_then_comments(mock_api, comments_payload): + def _api(method, url, auth, *args, **kwargs): + if "/pulls/" in url: + return OPEN_PR + return comments_payload + + mock_api.side_effect = _api class TestPrLeaseCommentsNonListGuard(unittest.TestCase): @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_list_pr_lease_comments_non_list_payload_returns_empty(self, _auth, mock_api): - mock_api.return_value = {"message": "Unauthorized"} + _pr_then_comments(mock_api, {"message": "Unauthorized"}) result = _list_pr_lease_comments( 12, remote="prgs", host=None, org=None, repo=None) self.assertEqual(result, []) @@ -30,7 +40,7 @@ class TestPrLeaseCommentsNonListGuard(unittest.TestCase): @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_list_pr_lease_comments_none_returns_empty(self, _auth, mock_api): - mock_api.return_value = None + _pr_then_comments(mock_api, None) result = _list_pr_lease_comments( 12, remote="prgs", host=None, org=None, repo=None) self.assertEqual(result, []) @@ -39,7 +49,7 @@ class TestPrLeaseCommentsNonListGuard(unittest.TestCase): @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_list_pr_lease_comments_list_payload_unchanged(self, _auth, mock_api): comment = {"id": 7, "body": ""} - mock_api.return_value = [comment] + _pr_then_comments(mock_api, [comment]) result = _list_pr_lease_comments( 12, remote="prgs", host=None, org=None, repo=None, limit=5) self.assertEqual(result, [comment]) From f1449ff5c8107789c7f2614ffe9875ef4eeaa28b Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 22:07:59 -0400 Subject: [PATCH 12/12] fix: isolate #519 PR lookup from shared lease comment listing Keep _fetch_pr_lease_comments_safe for gitea_assess_conflict_fix_push only. Restore _list_pr_lease_comments to a single comments GET so merge approval feedback and #485 non-list handling keep stable API call order. Closes #519 --- gitea_mcp_server.py | 29 ++++++++++++------- .../test_pr_lease_comments_non_list_guard.py | 23 ++++++--------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 0609f46..af54037 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -2940,16 +2940,25 @@ def _list_pr_lease_comments( repo: str | None, limit: int = 100, ) -> list[dict]: - """Fetch PR/issue thread comments used for reviewer/conflict-fix leases.""" - fetched = _fetch_pr_lease_comments_safe( - pr_number, - remote=remote, - host=host, - org=org, - repo=repo, - limit=limit, - ) - return fetched["comments"] + """Fetch PR/issue thread comments used for reviewer/conflict-fix leases. + + Intentionally does **not** pre-fetch the PR via GET /pulls/{n}. Shared + callers (merge approval feedback, reviewer lease gates) depend on a single + comments GET so mock sequences and fail-open #485 non-list handling stay + stable. Structured PR-lookup failures belong only to + :func:`_fetch_pr_lease_comments_safe` used by conflict-fix push assessment + (#519). + """ + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments" + comments = api_request("GET", api, auth) + # Fail safe to no lease comments when the API returns a non-list payload + # (e.g. an error object such as an HTTP 401 body): lease state can only be + # proven from real comment entries, never inferred from an error shape (#485). + if not isinstance(comments, list): + return [] + return list(comments[:limit]) def _pr_work_lease_reviewer_block( diff --git a/tests/test_pr_lease_comments_non_list_guard.py b/tests/test_pr_lease_comments_non_list_guard.py index 526c366..9b21a24 100644 --- a/tests/test_pr_lease_comments_non_list_guard.py +++ b/tests/test_pr_lease_comments_non_list_guard.py @@ -16,31 +16,26 @@ AUTHOR_ENV = { "GITEA_PROFILE_NAME": "gitea-author", "GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment", } -OPEN_PR = {"number": 12, "state": "open", "head": {"sha": "abc123"}} - - -def _pr_then_comments(mock_api, comments_payload): - def _api(method, url, auth, *args, **kwargs): - if "/pulls/" in url: - return OPEN_PR - return comments_payload - - mock_api.side_effect = _api class TestPrLeaseCommentsNonListGuard(unittest.TestCase): @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_list_pr_lease_comments_non_list_payload_returns_empty(self, _auth, mock_api): - _pr_then_comments(mock_api, {"message": "Unauthorized"}) + mock_api.return_value = {"message": "Unauthorized"} result = _list_pr_lease_comments( 12, remote="prgs", host=None, org=None, repo=None) self.assertEqual(result, []) + # Single comments GET only — no pre-flight PR lookup (#519 isolation). + mock_api.assert_called_once() + _method, url, _auth_arg = mock_api.call_args[0][:3] + self.assertIn("/issues/12/comments", url) + self.assertNotIn("/pulls/", url) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_list_pr_lease_comments_none_returns_empty(self, _auth, mock_api): - _pr_then_comments(mock_api, None) + mock_api.return_value = None result = _list_pr_lease_comments( 12, remote="prgs", host=None, org=None, repo=None) self.assertEqual(result, []) @@ -49,7 +44,7 @@ class TestPrLeaseCommentsNonListGuard(unittest.TestCase): @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_list_pr_lease_comments_list_payload_unchanged(self, _auth, mock_api): comment = {"id": 7, "body": ""} - _pr_then_comments(mock_api, [comment]) + mock_api.return_value = [comment] result = _list_pr_lease_comments( 12, remote="prgs", host=None, org=None, repo=None, limit=5) self.assertEqual(result, [comment]) @@ -83,4 +78,4 @@ class TestPrLeaseCommentsNonListGuard(unittest.TestCase): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main()