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:
+41
-76
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user