Add explicit close_pr capability resolution and gated PR close path (Issue #216)
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]>
This commit is contained in:
@@ -203,5 +203,67 @@ class TestResolveTaskCapability(unittest.TestCase):
|
||||
reasons = res.get("reasons", [])
|
||||
self.assertTrue(any("author" in str(r).lower() for r in reasons) or len(reasons) > 0)
|
||||
|
||||
# Issue #216: close_pr is a first-class resolver task gated on gitea.pr.close.
|
||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_resolve_close_pr_author_profile_without_close_blocked(self, _auth, _api):
|
||||
# Default author profile has PR create/comment but not close: the
|
||||
# close capability must never be implied by broader author ops.
|
||||
with patch.dict(os.environ, self._env("author-profile")):
|
||||
res = mcp_server.gitea_resolve_task_capability(task="close_pr", remote="prgs")
|
||||
self.assertEqual(res["requested_task"], "close_pr")
|
||||
self.assertEqual(res["required_operation_permission"], "gitea.pr.close")
|
||||
self.assertEqual(res["required_role_kind"], "author")
|
||||
self.assertFalse(res["allowed_in_current_session"])
|
||||
self.assertTrue(res["stop_required"])
|
||||
self.assertEqual(res["matching_configured_profile"], [])
|
||||
self.assertTrue(res["different_mcp_namespace_required"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_resolve_close_pr_author_profile_with_close_allowed(self, _auth, _api):
|
||||
# Operator-granted close capability resolves cleanly under an author profile.
|
||||
config = json.loads(json.dumps(CONFIG_RESOLVER))
|
||||
config["profiles"]["author-closer"] = {
|
||||
"enabled": True,
|
||||
"context": "ctx",
|
||||
"role": "author",
|
||||
"username": "author-user",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.close"],
|
||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||
"execution_profile": "author-closer",
|
||||
}
|
||||
self._write_config(config)
|
||||
with patch.dict(os.environ, self._env("author-closer")):
|
||||
res = mcp_server.gitea_resolve_task_capability(task="close_pr", remote="prgs")
|
||||
self.assertTrue(res["allowed_in_current_session"])
|
||||
self.assertFalse(res["stop_required"])
|
||||
self.assertIn("author-closer", res["matching_configured_profile"])
|
||||
self.assertIn("ready for operations", res["exact_safe_next_action"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
|
||||
def test_resolve_close_pr_reviewer_profile_blocked(self, _auth, _api):
|
||||
# close_pr is author-side: a reviewer profile must be told to stop.
|
||||
with patch.dict(os.environ, self._env("reviewer-profile")):
|
||||
res = mcp_server.gitea_resolve_task_capability(task="close_pr", remote="prgs")
|
||||
self.assertFalse(res["allowed_in_current_session"])
|
||||
self.assertTrue(res["stop_required"])
|
||||
self.assertEqual(res["required_role_kind"], "author")
|
||||
self.assertIn("author", res["exact_safe_next_action"].lower())
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_close_pr_known_and_lookalike_tasks_still_fail_closed(self, _auth, _api):
|
||||
# close_pr resolves (no Unknown-task error); near-miss spellings keep
|
||||
# failing closed so no untracked close fallback can be rationalized.
|
||||
with patch.dict(os.environ, self._env("author-profile")):
|
||||
res = mcp_server.gitea_resolve_task_capability(task="close_pr", remote="prgs")
|
||||
self.assertEqual(res["required_operation_permission"], "gitea.pr.close")
|
||||
for unknown in ("close_pull_request", "close", "pr_close"):
|
||||
with self.assertRaises(ValueError):
|
||||
mcp_server.gitea_resolve_task_capability(task=unknown, remote="prgs")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user