Enforce issue-write tool gates to match task capability resolver (Issue #69)

Extract TASK_CAPABILITY_MAP into task_capability_map.py as the single source
of truth shared by gitea_resolve_task_capability and issue-mutating tools.
Gate gitea_create_issue, gitea_close_issue, gitea_mark_issue, and
gitea_set_issue_labels with structured #142 permission reports. Add regression
tests proving resolver-denied tasks fail closed at the raw tool layer.

Implements mcp-control-plane #69.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-05 21:09:34 -04:00
co-authored by Claude Opus 4.8
parent c89ae2eb5c
commit 00394d43b7
6 changed files with 413 additions and 100 deletions
+41 -76
View File
@@ -257,6 +257,7 @@ import gitea_config # noqa: E402
import capability_stop_terminal # noqa: E402
import issue_duplicate_gate # noqa: E402
import role_session_router # noqa: E402
import task_capability_map # noqa: E402
# Fail-closed exact-issue-lock file (#204): written by gitea_lock_issue,
@@ -555,6 +556,12 @@ def gitea_create_issue(
"number": None,
"reasons": block_reasons,
}
blocked = _profile_permission_block(
task_capability_map.required_permission("create_issue"),
number=None,
)
if blocked:
return blocked
verify_preflight_purity(remote)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
@@ -2494,6 +2501,10 @@ def gitea_close_issue(
Returns:
dict with 'success' boolean and 'message'.
"""
blocked = _profile_permission_block(
task_capability_map.required_permission("close_issue"))
if blocked:
return blocked
verify_preflight_purity(remote)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
@@ -2698,6 +2709,25 @@ def _profile_operation_gate(op: str) -> list[str]:
return [f"profile is not allowed to {op}"]
def _profile_permission_block(required_operation: str, **extra_fields) -> dict | None:
"""Structured permission denial for gated tools (#69, #142).
Returns a block dict when the active profile forbids *required_operation*,
or ``None`` when the gate passes. Never performs network I/O.
"""
reasons = _profile_operation_gate(required_operation)
if not reasons:
return None
blocked = {
"success": False,
"performed": False,
"reasons": reasons,
"permission_report": _permission_block_report(required_operation),
}
blocked.update(extra_fields)
return blocked
@mcp.tool()
def gitea_list_issue_comments(
issue_number: int,
@@ -3882,6 +3912,10 @@ def gitea_mark_issue(
if action not in ("start", "done"):
raise ValueError(f"action must be 'start' or 'done', got '{action}'")
blocked = _profile_permission_block(
task_capability_map.required_permission("mark_issue"))
if blocked:
return blocked
verify_preflight_purity(remote)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
@@ -4005,6 +4039,10 @@ def gitea_set_issue_labels(
Returns:
list of all labels currently applied to the issue.
"""
blocked = _profile_permission_block(
task_capability_map.required_permission("set_issue_labels"))
if blocked:
return blocked
verify_preflight_purity(remote)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
@@ -4199,86 +4237,13 @@ def gitea_resolve_task_capability(
remote: Known remote instance name.
host: Optional override for the Gitea host.
"""
TASK_MAP = {
"create_issue": {
"permission": "gitea.issue.create",
"role": "author",
},
"comment_issue": {
"permission": "gitea.issue.comment",
"role": "author",
},
"close_issue": {
"permission": "gitea.issue.close",
"role": "author",
},
"claim_issue": {
"permission": "gitea.issue.comment",
"role": "author",
},
"mark_issue": {
"permission": "gitea.issue.comment",
"role": "author",
},
"create_branch": {
"permission": "gitea.branch.create",
"role": "author",
},
"push_branch": {
"permission": "gitea.branch.push",
"role": "author",
},
"create_pr": {
"permission": "gitea.pr.create",
"role": "author",
},
"comment_pr": {
"permission": "gitea.pr.comment",
"role": "author",
},
# PR closure is a first-class capability (#216): distinct from
# comment_pr (gitea.pr.comment) and from ordinary PR edits, which
# need no dedicated capability. gitea_edit_pr(state='closed') is
# gated on the same operation, so no edit-path fallback exists.
"close_pr": {
"permission": "gitea.pr.close",
"role": "author",
},
"address_pr_change_requests": {
"permission": "gitea.branch.push",
"role": "author",
},
"review_pr": {
"permission": "gitea.pr.review",
"role": "reviewer",
},
"merge_pr": {
"permission": "gitea.pr.merge",
"role": "reviewer",
},
"blind_pr_queue_review": {
"permission": "gitea.pr.review",
"role": "reviewer",
},
"request_changes_pr": {
"permission": "gitea.pr.request_changes",
"role": "reviewer",
},
"approve_pr": {
"permission": "gitea.pr.approve",
"role": "reviewer",
},
"delete_branch": {
"permission": "gitea.branch.delete",
"role": "author",
},
}
TASK_MAP = task_capability_map.TASK_CAPABILITY_MAP
if task not in TASK_MAP:
raise ValueError(f"Unknown task/action: '{task}' (fail closed)")
required_permission = TASK_MAP[task]["permission"]
required_role = TASK_MAP[task]["role"]
required_permission = task_capability_map.required_permission(task)
required_role = task_capability_map.required_role(task)
record_preflight_check("capability", required_role)