fix(gateway): resolve adversarial-review findings on the pr_reviewer flow

An adversarial review of the feature found two blocking defects (both would
surface the moment external_pr_enabled is turned on) plus hardening gaps:

- HIGH: the enforcement legacy role-gate overlay OVERWROTE spec-derived roles,
  so pr_reviewer was erased from the (in_progress->completed) edge it shares
  with the PM self-complete gate — the review task could never complete. Fix:
  UNION legacy + spec roles instead of overwriting (also preserves the legacy
  'add roles' intent on every shared edge).
- HIGH: claim_pr_review routed claim+start through the verb runner, which hit
  start()'s plan gate (planless review task -> None -> crash/respawn loop) and
  auto-created+pushed a stray branch (violating the read-only/branchless
  invariant). Fix: mirror QA's claim_review — a verb-body TaskService.pr_review_claim
  does pending->in_progress with no plan and no branch.
- MED: add the pr_reviewer Write(*)/Edit(*) deny at the permission layer (it
  ingests untrusted PR diffs — make read-only explicit, not implicit).
- MED: regenerate the verb-table artifacts (the schemas existed but the
  generator had not been re-run; the agent prompt showed 'unknown' signatures).

ruff + mypy clean (279 files); foundation + gateway suites green (5205 passed).
This commit is contained in:
Renn F
2026-06-16 11:48:24 +02:00
parent 831321f436
commit 5bea82dbbc
6 changed files with 72 additions and 20 deletions
@@ -88,19 +88,24 @@ class PRReviewerMixin(_Base):
task_id=task_id,
verb="claim_pr_review",
)
runner = self._verb_runner()
try:
t = await runner.run_intent("claim_pr_review", t, agent, spec_ctx)
except Exception as exc:
return await self._runner_failure(
exc,
t,
role_str,
briefing,
reviewer_agent_id,
task_id,
"claim_pr_review",
# Verb body owns the claim (mirrors QA's claim_review): a specialized
# pending->in_progress claim with NO plan and NO branch. The spec's
# composes=("claim","start") is for the gate above only — routing it
# through the verb runner would hit start()'s plan gate and auto-create
# a branch, neither of which a read-only review task wants.
claimed = await self.task.pr_review_claim(reviewer_agent_id, task_id)
if claimed is None:
return await self._emit_rejection(
Envelope.invalid_state(
message="this external-PR review task is no longer claimable",
remediate="it may already be claimed; give_me_work for the next",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="claim_pr_review",
)
t = claimed
evidence = await self._build_pr_review_evidence(t)
return Envelope.ok(
status=str(t.status),
@@ -133,9 +138,7 @@ class PRReviewerMixin(_Base):
task_id=task_id,
verb="post_pr_review",
)
pre = await self._post_pr_review_preflight(
t, reviewer_agent_id, task_id, body
)
pre = await self._post_pr_review_preflight(t, reviewer_agent_id, task_id, body)
if isinstance(pre, Envelope):
return pre
agent, role_str, briefing, spec_ctx = pre