fix(mcp): honor the create-issue bootstrap in anti-stomp preflight (Closes #757)

The sanctioned create_issue bootstrap from #749/#750 was unreachable in
production. Two guards assessed the same workspace for the same task and
reached opposite conclusions: the #274 branches-only guard consulted the
bootstrap and permitted a clean canonical control checkout, then the #604
anti-stomp preflight -- which never consulted it -- rejected that same
checkout as wrong_worktree.

The defect was wiring, not policy: the bootstrap decision was computed in
one guard and discarded, while the other re-derived a conflicting answer
from a lower-level assessor with no notion of the bootstrap phase.

Fix: one computation site, one interpretation site.

* create_issue_bootstrap.bootstrap_permits_control_checkout() is the single
  predicate both guards use to interpret an assessment. It is fail-closed by
  construction: missing, malformed, refused, incomplete, or contradictory
  evidence returns False and leaves the ordinary block in force. It also
  verifies the assessment describes the exact workspace and canonical root
  being guarded, so a stale or foreign assessment cannot be reused.
* _create_issue_bootstrap_assessment() computes the assessment once per
  preflight from inspected repository state. verify_preflight_purity threads
  that single result into both guards.
* The #604 assessor accepts the assessment and waives ONLY the wrong-worktree
  verdict. Root checkout, repo, role, stale runtime, lease, head-SHA,
  workflow-hash, and contamination checks are evaluated independently and
  still apply.

Evidence is server-derived only and travels an internal path: no MCP tool
signature gains a bootstrap argument, and no caller-controlled boolean can
manufacture eligibility. Behavior is unchanged for callers that supply no
evidence, and for every non-create_issue author mutation.

No issue or PR number is special-cased in production behavior.

Tests: new tests/test_issue_757_bootstrap_guard_agreement.py (38 tests, 26
subtests) covering the shared predicate, the narrow waiver, guard agreement
across the full workspace-state matrix, non-forgeable eligibility, and an
end-to-end native gitea_create_issue run with the #604 gate LIVE. All 38
fail against unfixed sources; the e2e reproduces the production error text
verbatim ("Anti-stomp preflight (#604) blocked mutation [wrong_worktree]").

tests/test_reconciler_close_workspace_guard.py: one case asserted that
create_issue stays blocked on the control checkout, which only held because
the bootstrap-blind #604 guard was overriding #750 -- it encoded the defect.
Re-pointed to lock_issue, which is issue-backed and legitimately still
requires a branches/ worktree. Its teardown now restores the module-level
preflight task/role so test order cannot leak resolved state.

Full suite: 3624 passed, 2 failed, 6 skipped (426 subtests).
Baseline at bde5c5fb on a clean detached worktree: 3586 passed, 2 failed,
6 skipped (400 subtests). The same 2 failures reproduce identically on
pristine master and are unrelated to this change
(test_issue_702_review_findings_f1_f6 F1 worktree recovery;
test_reconciler_supersession_close org/repo forwarding).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-19 02:26:58 -04:00
co-authored by Claude Opus 4.8
parent bde5c5fb20
commit adc61255b2
5 changed files with 810 additions and 33 deletions
+21 -13
View File
@@ -38,11 +38,17 @@ class TestReconcilerCloseWorkspaceGuard(unittest.TestCase):
srv._preflight_capability_violation = False
self._orig_in_test = srv._preflight_in_test_mode
srv._preflight_in_test_mode = lambda: False
self._orig_resolved_task = srv._preflight_resolved_task
self._orig_resolved_role = srv._preflight_resolved_role
self._env_patch = patch.dict(os.environ, {"GITEA_MCP_DISABLE_PARITY_GATE": "1"}, clear=False)
self._env_patch.start()
def tearDown(self):
srv._preflight_in_test_mode = self._orig_in_test
# Preflight task/role are module-level; restore so test order cannot
# leak a resolved task into sibling cases.
srv._preflight_resolved_task = self._orig_resolved_task
srv._preflight_resolved_role = self._orig_resolved_role
self._env_patch.stop()
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@@ -81,26 +87,28 @@ class TestReconcilerCloseWorkspaceGuard(unittest.TestCase):
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={"current_branch": "master"},
)
def test_author_create_issue_still_blocked_on_control_checkout(
def test_author_non_create_issue_still_blocked_on_control_checkout(
self, _git, _get_all, _role, _ns, _prof, _auth
):
"""Author mutations other than create_issue keep the branches-only rule.
#749/#750 sanctioned ``create_issue`` from a clean control checkout, and
#757 made the #604 anti-stomp guard honour that same decision — so
``create_issue`` is no longer a valid probe for this boundary. This case
previously asserted create_issue stayed blocked, which only held because
the bootstrap-blind #604 guard was overriding #750; that is precisely
the defect #757 fixed. ``lock_issue`` is issue-backed and post-ownership,
so it still requires a ``branches/`` worktree.
"""
srv._preflight_resolved_role = "author"
srv._preflight_resolved_task = "lock_issue"
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
try:
res = srv.gitea_create_issue(title="Test", body="body")
srv.verify_preflight_purity(remote="prgs", task="lock_issue")
except RuntimeError as exc:
self.assertIn("stable control checkout", str(exc))
self.assertIn("control checkout", str(exc).lower())
else:
# #683 typed blocker at mutation entrypoint
self.assertFalse(res.get("success"))
blob = " ".join(res.get("reasons") or []) + str(
res.get("blocker_kind") or ""
)
self.assertTrue(
"stable control checkout" in blob
or "missing_issue_worktree" in blob
or "control checkout" in blob.lower()
)
self.fail("lock_issue must stay blocked on the control checkout")
if __name__ == "__main__":