fix: anti-stomp capability auth, inventory wiring, side-effect proof (#604)

Address REQUEST_CHANGES on PR #680:

Blocker A — role vs capability agreement
- authorization_compatible allows reviewer/merger when they hold the task's
  required permission (e.g. gitea.issue.comment on comment_issue/mark_issue/
  set_issue_labels/lock_issue) without broad role-bypass.
- Unauthorized escalation without the permission remains fail closed.
- Regression coverage for allowed and denied reviewer/merger cases.

Blocker B — MUTATION_TASKS matches runtime wiring
- Remove unenforced entries; document dedicated-gate exclusions
  (mark_final_review_decision, save/resume_review_draft, etc.).
- Wire non-closing gitea_edit_pr as edit_pr; acquire lease uses
  acquire_reviewer_pr_lease task through verify_preflight_purity.
- Inventory↔wiring consistency tests replace set-membership-only coverage.

Blocker C — entrypoint side-effect ordering
- Behavioral tests for merge_pr, submit_pr_review, and comment_issue prove
  the assessor block aborts before Gitea API mutation and local durable writes.

Validation: 43 focused anti-stomp + related suites green; full suite
2639 passed / 6 skipped.

Closes #604
This commit is contained in:
2026-07-12 09:00:45 -04:00
parent ea8e186549
commit 3fd02a3c63
4 changed files with 637 additions and 31 deletions
+20 -3
View File
@@ -671,12 +671,18 @@ def _run_anti_stomp_preflight(
profile = get_profile()
profile_name = profile.get("profile_name")
profile_role = _actual_profile_role()
allowed_operations = list(profile.get("allowed_operations") or [])
req_role = None
req_perm = None
if task:
try:
req_role = task_capability_map.required_role(task)
except KeyError:
req_role = _preflight_resolved_role
try:
req_perm = task_capability_map.required_permission(task)
except KeyError:
req_perm = None
ctx = _resolve_namespace_mutation_context(worktree_path)
workspace = ctx["workspace_path"]
@@ -734,7 +740,6 @@ def _run_anti_stomp_preflight(
"approve_pr",
"request_changes_pr",
"merge_pr",
"mark_final_review_decision",
}:
try:
wf_reasons = _review_workflow_load_gate_reasons()
@@ -765,6 +770,8 @@ def _run_anti_stomp_preflight(
profile_name=profile_name,
profile_role=profile_role,
required_role=req_role or _preflight_resolved_role,
required_permission=req_perm,
allowed_operations=allowed_operations,
workspace_path=workspace,
project_root=canonical_root,
current_branch=git_state.get("current_branch"),
@@ -5030,7 +5037,12 @@ def gitea_edit_pr(
raise ValueError("At least one field to edit (title, body, state, base) must be provided.")
closing = payload.get("state") == "closed"
verify_preflight_purity(remote, task="close_pr" if closing else None)
# Closing uses close_pr; non-closing title/body/base edits use edit_pr so
# the shared #604 anti-stomp inventory stays consistent with wiring.
if closing:
verify_preflight_purity(remote, task="close_pr")
else:
verify_preflight_purity(remote, task="edit_pr")
# PR closure is a first-class capability, distinct from retitling or
# rebasing edits (#216). Gate BEFORE auth/API setup so a blocked close
@@ -7650,7 +7662,11 @@ def gitea_acquire_reviewer_pr_lease(
"permission_report": _permission_block_report("gitea.pr.comment"),
}
_verify_role_mutation_workspace(remote, worktree=worktree, task="review_pr")
# task=acquire_reviewer_pr_lease so verify_preflight_purity runs shared #604
# anti-stomp for the declared lease-acquire mutation inventory entry.
_verify_role_mutation_workspace(
remote, worktree=worktree, task="acquire_reviewer_pr_lease"
)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
profile = get_profile()
@@ -7791,6 +7807,7 @@ def gitea_adopt_merger_pr_lease(
"permission_report": _permission_block_report("gitea.pr.merge"),
}
# task=adopt_merger_pr_lease so verify_preflight_purity runs shared #604 anti-stomp.
_verify_role_mutation_workspace(
remote, worktree=worktree, task="adopt_merger_pr_lease"
)