Add explicit close_pr capability resolution and gated PR close path (Issue #216) #219

Merged
sysadmin merged 1 commits from feat/issue-216-close-pr-capability into master 2026-07-05 16:22:22 -05:00
Owner

Problem

PR closure was not a first-class capability (origin: contaminated PR #205 closure). gitea_resolve_task_capability(close_pr) failed as unknown, and gitea_edit_pr(state=closed) closed PRs with no close-specific capability proof — the broad edit path worked as an untracked close fallback.

Changes

  • Resolver: close_pr added to TASK_MAP → requires gitea.pr.close, author-side. Lookalike spellings (close, pr_close, close_pull_request) still fail closed.
  • Gated close path: gitea_edit_pr(state="closed") now requires gitea.pr.close. Without it the close fails closed before any auth or API call, returning success/performed: false, reasons, required_permission, and a structured permission_report. Title/body/base edits and reopening (state="open") stay on the ordinary edit path — PR comment, PR edit, and PR close are distinct capabilities.
  • Audit: a permitted close is audited as a distinct close_pr action with required_permission: gitea.pr.close in request metadata (capability proof per #191).
  • Validation: state values other than open/closed are rejected outright, so case variants cannot bypass the gate.
  • Generalized the per-operation profile gate helper (_issue_comment_gate_profile_operation_gate; behavior unchanged).
  • Documented the edit/close capability split in docs/gitea-execution-profiles.md. gitea.pr.close is canonical (no legacy alias) and is not part of any default profile — the operator grants it deliberately.

Tests (all acceptance criteria)

  • Resolver under author profile without close → blocked; with operator-granted close → allowed; reviewer profile → blocked (author-side task); unknown/lookalike tasks → fail closed.
  • gitea_edit_pr(state=closed) without capability → blocked, no API call, permission report; with forbidden gitea.pr.close → blocked; unresolvable profile → fail closed.
  • Explicit operator-directed contaminated-PR closure with gitea.pr.close → allowed and audited as close_pr with the capability recorded.
  • Edit/reopen without close capability → still allowed (capability split proof).

Full suite: 744 passed, 6 skipped.

Closes #216

## Problem PR closure was not a first-class capability (origin: contaminated PR #205 closure). `gitea_resolve_task_capability(close_pr)` failed as unknown, and `gitea_edit_pr(state=closed)` closed PRs with no close-specific capability proof — the broad edit path worked as an untracked close fallback. ## Changes - **Resolver**: `close_pr` added to `TASK_MAP` → requires `gitea.pr.close`, author-side. Lookalike spellings (`close`, `pr_close`, `close_pull_request`) still fail closed. - **Gated close path**: `gitea_edit_pr(state="closed")` now requires `gitea.pr.close`. Without it the close fails closed **before any auth or API call**, returning `success/performed: false`, `reasons`, `required_permission`, and a structured `permission_report`. Title/body/base edits and reopening (`state="open"`) stay on the ordinary edit path — PR comment, PR edit, and PR close are distinct capabilities. - **Audit**: a permitted close is audited as a distinct `close_pr` action with `required_permission: gitea.pr.close` in request metadata (capability proof per #191). - **Validation**: `state` values other than `open`/`closed` are rejected outright, so case variants cannot bypass the gate. - Generalized the per-operation profile gate helper (`_issue_comment_gate` → `_profile_operation_gate`; behavior unchanged). - Documented the edit/close capability split in `docs/gitea-execution-profiles.md`. `gitea.pr.close` is canonical (no legacy alias) and is not part of any default profile — the operator grants it deliberately. ## Tests (all acceptance criteria) - Resolver under author profile without close → blocked; with operator-granted close → allowed; reviewer profile → blocked (author-side task); unknown/lookalike tasks → fail closed. - `gitea_edit_pr(state=closed)` without capability → blocked, no API call, permission report; with forbidden `gitea.pr.close` → blocked; unresolvable profile → fail closed. - Explicit operator-directed contaminated-PR closure with `gitea.pr.close` → allowed and audited as `close_pr` with the capability recorded. - Edit/reopen without close capability → still allowed (capability split proof). Full suite: 744 passed, 6 skipped. Closes #216
jcwalker3 added 1 commit 2026-07-05 16:19:22 -05:00
PR closure had no first-class capability: agents could close PRs through
gitea_edit_pr(state=closed) with no close-specific capability proof, and
gitea_resolve_task_capability(close_pr) failed as unknown, leaving the
broad edit path as an untracked close fallback.

Add close_pr to the resolver TASK_MAP (gitea.pr.close, author-side) and
gate gitea_edit_pr(state=closed) on the same operation: without it the
close fails closed before any auth or API call, with reasons and a
structured permission_report; with it the close proceeds and is audited
as a distinct close_pr action carrying the required capability. Reject
invalid state values outright so case variants cannot bypass the gate.
Generalize the profile gate helper (_profile_operation_gate) and document
the PR comment / PR edit / PR close capability split.

Co-Authored-By: Claude Fable 5 <[email protected]>
sysadmin reviewed 2026-07-05 16:22:18 -05:00
sysadmin left a comment
Owner

PR #219 verified. 100% test success (744 tests passed). Diff formatting is clean. Secret sweep is clean. Base is master. No self-review constraints violated. Recommending approval.

PR #219 verified. 100% test success (744 tests passed). Diff formatting is clean. Secret sweep is clean. Base is master. No self-review constraints violated. Recommending approval.
sysadmin merged commit 34a26d1c14 into master 2026-07-05 16:22:22 -05:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Scaled-Tech-Consulting/Gitea-Tools#219