Compare commits

...
Author SHA1 Message Date
sysadminandClaude Opus 4.8 00efda0cfb fix: resolve conflicts for PR #490
Merge prgs/master; unify preflight read-survival tests (#469 + #470 contract).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 04:09:13 -04:00
sysadmin ce61e424f6 Merge pull request 'fix: preserve capability preflight across safe reads (Closes #469)' (#502) from fix/issue-469-preflight-read-survival into master 2026-07-08 03:01:48 -05:00
sysadminandClaude Opus 4.8 2e4ebc6434 fix: resolve conflicts for PR #490
Merge prgs/master into feat/issue-470-preflight-contract, keeping stacked-PR
lock base support (#484) and explicit task binding on lock_issue preflight (#470).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 03:59:51 -04:00
sysadminandClaude Opus 4.8 3bce0a55fb test: reset preflight globals in read-survival setUp (#469)
Prevents cross-test leakage that masked the missing-capability fail-closed case.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 03:23:12 -04:00
sysadminandClaude Opus 4.8 afb4ba563c fix: preserve capability preflight across interleaved read-only whoami (#469)
Read-only gitea_whoami calls no longer clear a valid capability proof when
whoami was already verified clean. Capability is consumed on mutation (one
resolve per mutation), and verify_preflight_purity rejects task mismatches.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 03:23:12 -04:00
sysadminandClaude Opus 4.8 a8bcbdbcf2 fix: clarify capability preflight lifetime across safe reads (Closes #470)
Add preflight_contract helpers with explicit re-resolve error messages,
document the read-only tool set and consumption rules, and extend tests
for close_pr sequencing and task replacement.

Validation: ./venv/bin/python -m pytest tests/test_preflight_read_survival.py tests/test_mcp_server.py::TestPreflightVerification -q

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 02:28:07 -04:00
sysadminandClaude Opus 4.8 2d4ab4e54e test: reset preflight globals in read-survival setUp (#469)
Prevents cross-test leakage that masked the missing-capability fail-closed case.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 02:26:02 -04:00
sysadminandClaude Opus 4.8 514eae84f9 fix: preserve capability preflight across interleaved read-only whoami (#469)
Read-only gitea_whoami calls no longer clear a valid capability proof when
whoami was already verified clean. Capability is consumed on mutation (one
resolve per mutation), and verify_preflight_purity rejects task mismatches.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 02:26:02 -04:00
4 changed files with 351 additions and 32 deletions
+19
View File
@@ -492,6 +492,25 @@ Root-level matches are listed in `.gitignore` so they never get committed.
`gitea_get_runtime_context` and `gitea_lock_issue` surface **warnings** (not `gitea_get_runtime_context` and `gitea_lock_issue` surface **warnings** (not
hard blocks) when these artifacts are still present. hard blocks) when these artifacts are still present.
## Capability preflight lifetime (#470)
After `gitea_resolve_task_capability(task=…)` proves the mutation is allowed,
interleaved **read-only** calls preserve that proof until a gated mutation
consumes it:
- Safe reads: `gitea_whoami`, `gitea_view_pr`, `gitea_view_issue`, `gitea_list_*`,
`gitea_get_runtime_context`, `gitea_check_pr_eligibility`, and related
read-only inventory/eligibility tools (see `preflight_contract.py`).
- Each mutation consumes the proof once; call `gitea_resolve_task_capability`
again immediately before the next mutation on the same task.
- Resolving capability for a **different** task replaces the prior task binding.
- Workspace edits before resolve, profile switches, or a dirty whoami baseline
invalidate proof (fail closed).
If a mutation fails with “capability has not been resolved” or “task mismatch”,
re-run `gitea_resolve_task_capability(task="<mutation>")` immediately before
retrying — do not guess or skip the resolve step.
Implementation work and review work must use separate branch folders. For Implementation work and review work must use separate branch folders. For
example, an implementation branch might live under example, an implementation branch might live under
`branches/fix-issue-123-example`, while a review branch for the resulting PR `branches/fix-issue-123-example`, while a review branch for the resulting PR
+107 -32
View File
@@ -165,6 +165,7 @@ _preflight_capability_called = False
_preflight_whoami_violation = False _preflight_whoami_violation = False
_preflight_capability_violation = False _preflight_capability_violation = False
_preflight_resolved_role = None _preflight_resolved_role = None
_preflight_resolved_task: str | None = None
_process_start_porcelain: str | None = None _process_start_porcelain: str | None = None
_preflight_whoami_baseline_porcelain: str | None = None _preflight_whoami_baseline_porcelain: str | None = None
_preflight_capability_baseline_porcelain: str | None = None _preflight_capability_baseline_porcelain: str | None = None
@@ -379,11 +380,30 @@ def assess_preflight_status(worktree_path: str | None = None) -> dict:
} }
def record_preflight_check(type_name: str, resolved_role: str | None = None): def _clear_preflight_capability_state() -> None:
"""Drop resolved capability proof (consumed by a mutation or fresh resolve)."""
global _preflight_capability_called, _preflight_capability_violation
global _preflight_resolved_task
global _preflight_capability_baseline_porcelain, _preflight_capability_violation_files
global _preflight_reviewer_violation_files
_preflight_capability_called = False
_preflight_capability_violation = False
_preflight_capability_violation_files = []
_preflight_capability_baseline_porcelain = None
_preflight_resolved_task = None
_preflight_reviewer_violation_files = []
def record_preflight_check(
type_name: str,
resolved_role: str | None = None,
resolved_task: str | None = None,
):
"""Record a pre-flight check (whoami or capability) with session-scoped deltas.""" """Record a pre-flight check (whoami or capability) with session-scoped deltas."""
global _preflight_whoami_called, _preflight_capability_called global _preflight_whoami_called, _preflight_capability_called
global _preflight_whoami_violation, _preflight_capability_violation global _preflight_whoami_violation, _preflight_capability_violation
global _preflight_resolved_role global _preflight_resolved_role, _preflight_resolved_task
global _preflight_whoami_baseline_porcelain, _preflight_capability_baseline_porcelain global _preflight_whoami_baseline_porcelain, _preflight_capability_baseline_porcelain
global _preflight_whoami_violation_files, _preflight_capability_violation_files global _preflight_whoami_violation_files, _preflight_capability_violation_files
global _preflight_reviewer_violation_files global _preflight_reviewer_violation_files
@@ -391,13 +411,24 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None):
current = _get_workspace_porcelain() current = _get_workspace_porcelain()
if type_name == "whoami": if type_name == "whoami":
# Fresh whoami restarts the capability step and re-evaluates violations # Re-evaluate whoami violations instead of replaying sticky state (#252).
# instead of replaying a sticky record (#252). # Interleaved read-only whoami must not clear a valid capability proof (#469).
_preflight_capability_called = False saved_capability = None
_preflight_capability_violation = False preserve_capability = (
_preflight_capability_violation_files = [] _preflight_capability_called
_preflight_capability_baseline_porcelain = None and _preflight_whoami_called
_preflight_reviewer_violation_files = [] and not _preflight_whoami_violation
)
if preserve_capability:
saved_capability = (
_preflight_capability_violation,
list(_preflight_capability_violation_files),
_preflight_capability_baseline_porcelain,
_preflight_resolved_role,
_preflight_resolved_task,
)
else:
_clear_preflight_capability_state()
process_start = _ensure_process_start_porcelain() process_start = _ensure_process_start_porcelain()
whoami_delta = _new_tracked_changes_since(process_start, current) whoami_delta = _new_tracked_changes_since(process_start, current)
@@ -405,6 +436,20 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None):
_preflight_whoami_violation_files = whoami_delta _preflight_whoami_violation_files = whoami_delta
_preflight_whoami_baseline_porcelain = current _preflight_whoami_baseline_porcelain = current
_preflight_whoami_called = True _preflight_whoami_called = True
if (
preserve_capability
and saved_capability is not None
and not whoami_delta
):
(
_preflight_capability_violation,
_preflight_capability_violation_files,
_preflight_capability_baseline_porcelain,
_preflight_resolved_role,
_preflight_resolved_task,
) = saved_capability
_preflight_capability_called = True
elif type_name == "capability": elif type_name == "capability":
baseline = _preflight_whoami_baseline_porcelain or "" baseline = _preflight_whoami_baseline_porcelain or ""
capability_delta = _new_tracked_changes_since(baseline, current) capability_delta = _new_tracked_changes_since(baseline, current)
@@ -414,6 +459,8 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None):
_preflight_capability_called = True _preflight_capability_called = True
if resolved_role: if resolved_role:
_preflight_resolved_role = resolved_role _preflight_resolved_role = resolved_role
if resolved_task:
_preflight_resolved_task = resolved_task
def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> None: def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> None:
@@ -439,7 +486,11 @@ def _enforce_branches_only_author_mutation(worktree_path: str | None = None) ->
) )
def verify_preflight_purity(remote: str | None = None, worktree_path: str | None = None): def verify_preflight_purity(
remote: str | None = None,
worktree_path: str | None = None,
task: str | None = None,
):
"""Verify that identity and capability were verified prior to session edits.""" """Verify that identity and capability were verified prior to session edits."""
global _preflight_reviewer_violation_files global _preflight_reviewer_violation_files
@@ -456,7 +507,27 @@ def verify_preflight_purity(remote: str | None = None, worktree_path: str | None
) )
if not _preflight_capability_called: if not _preflight_capability_called:
raise RuntimeError( raise RuntimeError(
"Pre-flight order violation: Task capability (gitea_resolve_task_capability) has not been resolved (fail closed)" preflight_contract.format_missing_capability_error(task)
)
if (
task is not None
and _preflight_resolved_task is not None
and task != _preflight_resolved_task
):
raise RuntimeError(
preflight_contract.format_task_mismatch_error(
_preflight_resolved_task, task
)
)
if (
task is not None
and _preflight_resolved_task is not None
and task != _preflight_resolved_task
):
raise RuntimeError(
"Pre-flight task mismatch: "
f"resolved '{_preflight_resolved_task}' but mutation requires "
f"'{task}' (fail closed)"
) )
ctx = _resolve_author_mutation_context(worktree_path) ctx = _resolve_author_mutation_context(worktree_path)
@@ -513,6 +584,7 @@ def verify_preflight_purity(remote: str | None = None, worktree_path: str | None
) )
_enforce_branches_only_author_mutation(worktree_path) _enforce_branches_only_author_mutation(worktree_path)
_clear_preflight_capability_state()
from mcp.server.fastmcp import FastMCP # noqa: E402 from mcp.server.fastmcp import FastMCP # noqa: E402
@@ -544,6 +616,7 @@ import stacked_pr_support # noqa: E402
import merge_approval_gate # noqa: E402 import merge_approval_gate # noqa: E402
import already_landed_reconcile # noqa: E402 import already_landed_reconcile # noqa: E402
import author_mutation_worktree # noqa: E402 import author_mutation_worktree # noqa: E402
import preflight_contract # noqa: E402
import issue_claim_heartbeat # noqa: E402 import issue_claim_heartbeat # noqa: E402
import issue_work_duplicate_gate # noqa: E402 import issue_work_duplicate_gate # noqa: E402
import reviewer_pr_lease # noqa: E402 import reviewer_pr_lease # noqa: E402
@@ -1228,7 +1301,7 @@ def gitea_create_issue(
) )
if blocked: if blocked:
return blocked return blocked
verify_preflight_purity(remote, worktree_path=worktree_path) verify_preflight_purity(remote, worktree_path=worktree_path, task="create_issue")
base = repo_api_url(h, o, r) base = repo_api_url(h, o, r)
open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth) open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth)
closed_issues = api_get_all( closed_issues = api_get_all(
@@ -1361,7 +1434,7 @@ def gitea_lock_issue(
) )
else: else:
git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree) git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree)
verify_preflight_purity(remote, worktree_path=resolved_worktree) verify_preflight_purity(remote, worktree_path=resolved_worktree, task="lock_issue")
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree( lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path=resolved_worktree, worktree_path=resolved_worktree,
current_branch=git_state.get("current_branch"), current_branch=git_state.get("current_branch"),
@@ -1585,7 +1658,7 @@ def gitea_create_pr(
) )
if blocked: if blocked:
return blocked return blocked
verify_preflight_purity(remote, worktree_path=worktree_path) verify_preflight_purity(remote, worktree_path=worktree_path, task="create_pr")
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
# ── Issue Lock Validation (Issue #194 / #196 / #443) ── # ── Issue Lock Validation (Issue #194 / #196 / #443) ──
@@ -2651,7 +2724,7 @@ def _evaluate_pr_review_submission(
final_review_decision_ready: bool = False, final_review_decision_ready: bool = False,
) -> dict: ) -> dict:
"""Shared gate chain for live submit and dry-run review tools.""" """Shared gate chain for live submit and dry-run review tools."""
verify_preflight_purity(remote) verify_preflight_purity(remote, task="review_pr")
action = (action or "").strip().lower() action = (action or "").strip().lower()
result = { result = {
"requested_action": action, "requested_action": action,
@@ -3158,12 +3231,12 @@ def gitea_edit_pr(
if not payload: if not payload:
raise ValueError("At least one field to edit (title, body, state, base) must be provided.") raise ValueError("At least one field to edit (title, body, state, base) must be provided.")
verify_preflight_purity(remote) closing = payload.get("state") == "closed"
verify_preflight_purity(remote, task="close_pr" if closing else None)
# PR closure is a first-class capability, distinct from retitling or # PR closure is a first-class capability, distinct from retitling or
# rebasing edits (#216). Gate BEFORE auth/API setup so a blocked close # rebasing edits (#216). Gate BEFORE auth/API setup so a blocked close
# never touches the network. # never touches the network.
closing = payload.get("state") == "closed"
if closing: if closing:
gate_reasons = _profile_operation_gate("gitea.pr.close") gate_reasons = _profile_operation_gate("gitea.pr.close")
if gate_reasons: if gate_reasons:
@@ -3408,7 +3481,7 @@ def gitea_commit_files(
branch="", branch="",
) )
verify_preflight_purity(remote) verify_preflight_purity(remote, task="commit_files")
processed_files, source_proofs = _prepare_commit_payload_files(files) processed_files, source_proofs = _prepare_commit_payload_files(files)
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
@@ -3509,7 +3582,7 @@ def gitea_merge_pr(
reasons/gates passed or blocked, and merge result / merge commit if reasons/gates passed or blocked, and merge result / merge commit if
available. Never secrets. available. Never secrets.
""" """
verify_preflight_purity(remote) verify_preflight_purity(remote, task="merge_pr")
do = (do or "").strip().lower() do = (do or "").strip().lower()
result = { result = {
"performed": False, "performed": False,
@@ -4038,7 +4111,7 @@ def gitea_delete_branch(
"permission_report": _permission_block_report("gitea.branch.delete"), "permission_report": _permission_block_report("gitea.branch.delete"),
} }
verify_preflight_purity(remote) verify_preflight_purity(remote, task="delete_branch")
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h) auth = _auth(h)
import urllib.parse import urllib.parse
@@ -4147,7 +4220,7 @@ def gitea_reconcile_merged_cleanups(
report["executed"] = False report["executed"] = False
return {"success": True, "performed": False, **report} return {"success": True, "performed": False, **report}
verify_preflight_purity(remote) verify_preflight_purity(remote, task="reconcile_merged_cleanups")
actions: list[dict] = [] actions: list[dict] = []
for entry in report.get("entries") or []: for entry in report.get("entries") or []:
head_branch = entry.get("head_branch") or "" head_branch = entry.get("head_branch") or ""
@@ -4461,7 +4534,7 @@ def gitea_reconcile_already_landed_pr(
) )
return result return result
verify_preflight_purity(remote) verify_preflight_purity(remote, task="reconcile_already_landed_pr")
if post_comment and comment_body.strip(): if post_comment and comment_body.strip():
comment_block = _profile_operation_gate("gitea.pr.comment") comment_block = _profile_operation_gate("gitea.pr.comment")
@@ -4564,7 +4637,7 @@ def gitea_close_issue(
task_capability_map.required_permission("close_issue")) task_capability_map.required_permission("close_issue"))
if blocked: if blocked:
return blocked return blocked
verify_preflight_purity(remote) verify_preflight_purity(remote, task="close_issue")
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h) auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/issues/{issue_number}" url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
@@ -4992,7 +5065,7 @@ def gitea_acquire_reviewer_pr_lease(
"permission_report": _permission_block_report("gitea.pr.comment"), "permission_report": _permission_block_report("gitea.pr.comment"),
} }
verify_preflight_purity(remote) verify_preflight_purity(remote, task="review_pr")
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h) auth = _auth(h)
profile = get_profile() profile = get_profile()
@@ -5093,7 +5166,7 @@ def gitea_heartbeat_reviewer_pr_lease(
], ],
} }
verify_preflight_purity(remote) verify_preflight_purity(remote, task="review_pr")
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h) auth = _auth(h)
body = reviewer_pr_lease.format_lease_body( body = reviewer_pr_lease.format_lease_body(
@@ -5269,7 +5342,7 @@ def gitea_create_issue_comment(
(permission blocks also carry a structured 'permission_report', (permission blocks also carry a structured 'permission_report',
#142). #142).
""" """
verify_preflight_purity(remote) verify_preflight_purity(remote, task="comment_issue")
gate_reasons = _profile_operation_gate("gitea.issue.comment") gate_reasons = _profile_operation_gate("gitea.issue.comment")
reasons = list(gate_reasons) reasons = list(gate_reasons)
if not (body or "").strip(): if not (body or "").strip():
@@ -6803,7 +6876,7 @@ def gitea_mark_issue(
task_capability_map.required_permission("mark_issue")) task_capability_map.required_permission("mark_issue"))
if blocked: if blocked:
return blocked return blocked
verify_preflight_purity(remote, worktree_path=worktree_path) verify_preflight_purity(remote, worktree_path=worktree_path, task="mark_issue")
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h) auth = _auth(h)
base = repo_api_url(h, o, r) base = repo_api_url(h, o, r)
@@ -6881,7 +6954,7 @@ def gitea_post_heartbeat(
task_capability_map.required_permission("post_heartbeat")) task_capability_map.required_permission("post_heartbeat"))
if blocked: if blocked:
return blocked return blocked
verify_preflight_purity(remote) verify_preflight_purity(remote, task="post_heartbeat")
active_profile = profile or get_profile().get("profile_name") active_profile = profile or get_profile().get("profile_name")
body = issue_claim_heartbeat.format_heartbeat_body( body = issue_claim_heartbeat.format_heartbeat_body(
kind="progress", kind="progress",
@@ -6920,7 +6993,9 @@ def gitea_acquire_conflict_fix_lease(
task_capability_map.required_permission("comment_issue")) task_capability_map.required_permission("comment_issue"))
if blocked: if blocked:
return blocked return blocked
verify_preflight_purity(remote, worktree_path=worktree_path) verify_preflight_purity(
remote, worktree_path=worktree_path, task="comment_issue"
)
comments = _list_pr_lease_comments( comments = _list_pr_lease_comments(
pr_number, pr_number,
remote=remote, remote=remote,
@@ -7124,7 +7199,7 @@ def gitea_cleanup_stale_claims(
task_capability_map.required_permission("cleanup_stale_claims")) task_capability_map.required_permission("cleanup_stale_claims"))
if blocked: if blocked:
return blocked return blocked
verify_preflight_purity(remote) verify_preflight_purity(remote, task="cleanup_stale_claims")
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h) auth = _auth(h)
@@ -7278,7 +7353,7 @@ def gitea_set_issue_labels(
task_capability_map.required_permission("set_issue_labels")) task_capability_map.required_permission("set_issue_labels"))
if blocked: if blocked:
return blocked return blocked
verify_preflight_purity(remote) verify_preflight_purity(remote, task="set_issue_labels")
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h) auth = _auth(h)
base = repo_api_url(h, o, r) base = repo_api_url(h, o, r)
@@ -7515,7 +7590,7 @@ def gitea_resolve_task_capability(
"exact_safe_next_action": next_safe_action, "exact_safe_next_action": next_safe_action,
} }
record_preflight_check("capability", required_role) record_preflight_check("capability", required_role, resolved_task=task)
# Try automatic dispatch switching # Try automatic dispatch switching
_ensure_matching_profile(required_permission, required_role, remote, host) _ensure_matching_profile(required_permission, required_role, remote, host)
+66
View File
@@ -0,0 +1,66 @@
"""Capability preflight lifetime contract (#470).
Defines which read-only MCP tools preserve an existing capability proof and the
canonical sequencing operators must follow before mutations.
"""
from __future__ import annotations
# Read-only tools that must not invalidate capability proof (#470).
# gitea_whoami is read-only but re-pins identity; capability is preserved when
# identity was already verified clean (#469).
READ_ONLY_PREFLIGHT_TOOLS = frozenset({
"gitea_whoami",
"gitea_get_authenticated_user",
"gitea_get_current_user",
"gitea_view_pr",
"gitea_view_issue",
"gitea_list_prs",
"gitea_list_issues",
"gitea_list_issue_comments",
"gitea_get_runtime_context",
"gitea_resolve_task_capability",
"gitea_check_pr_eligibility",
"gitea_get_pr_review_feedback",
"gitea_assess_work_issue_duplicate",
"gitea_route_task_session",
"gitea_audit_config",
"gitea_list_labels",
"gitea_get_profile",
"gitea_list_profiles",
})
PREFLIGHT_CONTRACT_SUMMARY = (
"Capability preflight is session-scoped per MCP process, profile, and task. "
"After gitea_resolve_task_capability(task=...), interleaved read-only calls "
"(whoami, view_*, list_*, get_runtime_context, eligibility checks) preserve "
"the proof until a gated mutation consumes it. Each mutation consumes the proof "
"once; re-resolve immediately before the next mutation. A new resolve for a "
"different task replaces the prior task binding. Profile/session changes or "
"workspace edits before resolve invalidate proof."
)
def format_missing_capability_error(task: str | None = None) -> str:
base = (
"Pre-flight order violation: Task capability "
"(gitea_resolve_task_capability) has not been resolved (fail closed)"
)
if task:
return (
f"{base}. Re-run gitea_resolve_task_capability(task=\"{task}\") "
"immediately before this mutation."
)
return (
f"{base}. Re-run gitea_resolve_task_capability for the mutation task "
"immediately before acting."
)
def format_task_mismatch_error(resolved: str, required: str) -> str:
return (
"Pre-flight task mismatch: "
f"resolved '{resolved}' but mutation requires '{required}' (fail closed). "
f"Re-run gitea_resolve_task_capability(task=\"{required}\") "
"immediately before this mutation."
)
+159
View File
@@ -0,0 +1,159 @@
"""#469/#470: capability preflight lifetime across safe read-only calls."""
import os
import unittest
import gitea_mcp_server as mcp_server
class TestPreflightReadSurvival(unittest.TestCase):
def setUp(self):
self.orig_whoami = mcp_server._preflight_whoami_called
self.orig_capability = mcp_server._preflight_capability_called
self.orig_whoami_violation = mcp_server._preflight_whoami_violation
self.orig_capability_violation = mcp_server._preflight_capability_violation
self.orig_resolved_role = mcp_server._preflight_resolved_role
self.orig_resolved_task = mcp_server._preflight_resolved_task
self.orig_process_start = mcp_server._process_start_porcelain
self.orig_whoami_baseline = mcp_server._preflight_whoami_baseline_porcelain
self.orig_capability_baseline = mcp_server._preflight_capability_baseline_porcelain
for key in ("GITEA_TEST_FORCE_DIRTY", "GITEA_TEST_PORCELAIN"):
if key in os.environ:
del os.environ[key]
os.environ["GITEA_TEST_PORCELAIN"] = ""
mcp_server._preflight_whoami_called = False
mcp_server._preflight_capability_called = False
mcp_server._preflight_whoami_violation = False
mcp_server._preflight_capability_violation = False
mcp_server._preflight_resolved_role = None
mcp_server._preflight_resolved_task = None
mcp_server._process_start_porcelain = ""
mcp_server._preflight_whoami_baseline_porcelain = None
mcp_server._preflight_capability_baseline_porcelain = None
def tearDown(self):
mcp_server._preflight_whoami_called = self.orig_whoami
mcp_server._preflight_capability_called = self.orig_capability
mcp_server._preflight_whoami_violation = self.orig_whoami_violation
mcp_server._preflight_capability_violation = self.orig_capability_violation
mcp_server._preflight_resolved_role = self.orig_resolved_role
mcp_server._preflight_resolved_task = self.orig_resolved_task
mcp_server._process_start_porcelain = self.orig_process_start
mcp_server._preflight_whoami_baseline_porcelain = self.orig_whoami_baseline
mcp_server._preflight_capability_baseline_porcelain = self.orig_capability_baseline
for key in ("GITEA_TEST_FORCE_DIRTY", "GITEA_TEST_PORCELAIN"):
if key in os.environ:
del os.environ[key]
def test_interleaved_whoami_preserves_capability(self):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="reconciler", resolved_task="close_pr"
)
self.assertTrue(mcp_server._preflight_capability_called)
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
mcp_server.record_preflight_check("whoami")
self.assertTrue(mcp_server._preflight_capability_called)
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
mcp_server.verify_preflight_purity(task="close_pr")
self.assertFalse(mcp_server._preflight_capability_called)
def test_missing_capability_still_fails_closed(self):
mcp_server.record_preflight_check("whoami")
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(task="close_pr")
self.assertIn("has not been resolved", str(ctx.exception))
def test_task_mismatch_fails_closed(self):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="author", resolved_task="create_issue"
)
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(task="close_pr")
self.assertIn("task mismatch", str(ctx.exception))
def test_capability_consumed_after_mutation_gate(self):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="author", resolved_task="create_issue"
)
mcp_server.verify_preflight_purity(task="create_issue")
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(task="create_issue")
self.assertIn("has not been resolved", str(ctx.exception))
def test_whoami_recovery_after_violation_clears_capability(self):
os.environ["GITEA_TEST_FORCE_DIRTY"] = "1"
mcp_server.record_preflight_check("whoami")
self.assertTrue(mcp_server._preflight_whoami_violation)
del os.environ["GITEA_TEST_FORCE_DIRTY"]
os.environ["GITEA_TEST_PORCELAIN"] = ""
mcp_server.record_preflight_check("whoami")
self.assertFalse(mcp_server._preflight_whoami_violation)
self.assertFalse(mcp_server._preflight_capability_called)
mcp_server.record_preflight_check(
"capability", resolved_role="reviewer", resolved_task="review_pr"
)
mcp_server.verify_preflight_purity(task="review_pr")
def test_close_pr_sequence_with_interleaved_reads(self):
"""resolve(close_pr) → whoami/view reads → close_pr gate (#470)."""
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="reconciler", resolved_task="close_pr"
)
# Simulate live-state revalidation between resolve and mutation.
mcp_server.record_preflight_check("whoami")
self.assertTrue(mcp_server._preflight_capability_called)
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
mcp_server.verify_preflight_purity(task="close_pr")
self.assertFalse(mcp_server._preflight_capability_called)
def test_fresh_capability_resolve_replaces_prior_task(self):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="author", resolved_task="create_issue"
)
mcp_server.record_preflight_check(
"capability", resolved_role="reconciler", resolved_task="close_pr"
)
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
mcp_server.verify_preflight_purity(task="close_pr")
def test_missing_capability_error_names_re_resolve(self):
mcp_server.record_preflight_check("whoami")
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(task="close_pr")
msg = str(ctx.exception)
self.assertIn("gitea_resolve_task_capability", msg)
self.assertIn('task="close_pr"', msg)
def test_task_mismatch_error_names_re_resolve(self):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="author", resolved_task="create_issue"
)
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(task="close_pr")
msg = str(ctx.exception)
self.assertIn("task mismatch", msg)
self.assertIn('task="close_pr"', msg)
def test_consumed_capability_error_names_re_resolve(self):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="reconciler", resolved_task="close_pr"
)
mcp_server.verify_preflight_purity(task="close_pr")
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(task="close_pr")
self.assertIn('task="close_pr"', str(ctx.exception))
if __name__ == "__main__":
unittest.main()