feat: structured permission failure reports on gated tools (#142)

Every gated denial now carries a 'permission_report' explaining itself:
requested/missing/required operation, active profile and identity (when
already resolved), the profile's allowed operations, which configured
profiles could perform the operation (names only), whether runtime
switching is supported, whether a different MCP namespace/session is
required, and the exact safe next action.

New fail-soft helper _permission_block_report builds the report only
after a gate has refused — it adds guidance to denials, never widens a
permission, performs no network I/O, and degrades to a minimal
fail-closed report if the profile itself cannot be resolved. Wired into
gitea_check_pr_eligibility (and via it gitea_submit_pr_review,
gitea_merge_pr, and the legacy gitea_review_pr wrapper) and into the
issue-comment gate consumers gitea_list_issue_comments and
gitea_create_issue_comment. Input errors (e.g. empty comment body) are
not permission failures and get no report.

Tests cover: missing gitea.issue.comment, author attempting review and
merge, reviewer attempting an authoring operation, unknown/missing
profile fail-closed, switching-enabled guidance, eligible calls carry
no report, and no secret/URL material in any report.

Closes #142

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-05 03:44:35 -04:00
co-authored by Claude Fable 5
parent d73d238c37
commit abe952b4f1
2 changed files with 443 additions and 9 deletions
+117 -9
View File
@@ -502,7 +502,9 @@ def gitea_check_pr_eligibility(
repo: Override the repository name.
Returns:
dict with 'eligible' (bool), the inputs inspected, and 'reasons'.
dict with 'eligible' (bool), the inputs inspected, and 'reasons';
when the block is a missing profile permission, also a structured
'permission_report' (#142).
"""
action = (action or "").strip().lower()
profile = get_profile()
@@ -623,9 +625,16 @@ def gitea_check_pr_eligibility(
# Determine missing permission
missing_perm = None
if not op_ok:
missing_perm = f"gitea.pr.{action}" if action in ("approve", "request_changes", "merge") else f"gitea.{action}"
missing_perm = (
f"gitea.pr.{action}"
if action in ("approve", "request_changes", "merge", "review")
else f"gitea.{action}")
result["missing_permission"] = missing_perm
if missing_perm:
# Structured denial guidance (#142) — read-only, never widens.
result["permission_report"] = _permission_block_report(
missing_perm, identity=auth_user)
# Determine required profile
req_profile = None
@@ -830,6 +839,8 @@ def gitea_submit_pr_review(
f"eligibility check for '{eligibility_action}' failed (fail closed)"
)
reasons.extend(elig.get("reasons", []))
if elig.get("permission_report"):
result["permission_report"] = elig["permission_report"]
return result
# Gate 3 — redundant self-approval block (belt-and-suspenders over #14).
@@ -1150,6 +1161,8 @@ def gitea_merge_pr(
if not elig.get("eligible"):
reasons.append("eligibility check for 'merge' failed (fail closed)")
reasons.extend(elig.get("reasons", []))
if elig.get("permission_report"):
result["permission_report"] = elig["permission_report"]
return result
# Gate 4 — head SHA must match if the caller pinned a reviewed SHA.
@@ -1305,7 +1318,10 @@ def gitea_review_pr(
return {"success": True, "message": f"Successfully submitted review for PR #{pr_number} with event '{event}'."}
else:
reasons = result.get("reasons", [])
return {"success": False, "message": f"Review submission failed eligibility gates: {reasons}"}
out = {"success": False, "message": f"Review submission failed eligibility gates: {reasons}"}
if result.get("permission_report"):
out["permission_report"] = result["permission_report"]
return out
@mcp.tool()
@@ -1454,6 +1470,88 @@ def gitea_view_issue(
return _with_optional_url(result, i.get("html_url"))
def _permission_block_report(required_operation: str,
identity: str | None = None) -> dict:
"""Structured, LLM-safe explanation of a permission denial (#142).
Built only after a gate has already refused; it adds guidance to the
refusal and never widens any permission, performs network I/O, or
raises (fail-soft: degrades to a minimal fail-closed report). Names
configured profiles only — never auth references, tokens, endpoint
URLs, or keychain IDs.
"""
report = {
"requested_operation": required_operation,
"missing_permission": required_operation,
"required_permission": required_operation,
"active_profile": None,
"active_identity": identity,
"active_allowed_operations": [],
"matching_configured_profiles": [],
"runtime_switching_supported": False,
"different_mcp_namespace_required": True,
"exact_safe_next_action": (
"Ask the operator to fix GITEA_MCP_CONFIG/GITEA_MCP_PROFILE; "
"the active profile could not be resolved (fail closed)."),
}
try:
profile = get_profile()
except Exception:
return report
report["active_profile"] = profile.get("profile_name")
if identity is None:
report["active_identity"] = profile.get("identity")
report["active_allowed_operations"] = (
profile.get("allowed_operations") or [])
matching = []
try:
config = gitea_config.load_config() or {}
for name, p in (config.get("profiles") or {}).items():
p_allowed = []
for op in p.get("allowed_operations") or []:
try:
p_allowed.append(gitea_config.normalize_operation(op))
except Exception:
pass
p_forbidden = []
for op in p.get("forbidden_operations") or []:
try:
p_forbidden.append(gitea_config.normalize_operation(op))
except Exception:
pass
ok, _ = gitea_config.check_operation(
required_operation, p_allowed, p_forbidden)
if ok:
matching.append(name)
except Exception:
matching = []
report["matching_configured_profiles"] = matching
try:
switching = gitea_config.is_runtime_switching_enabled()
except Exception:
switching = False
report["runtime_switching_supported"] = switching
role = "reviewer" if required_operation in (
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.request_changes",
"gitea.pr.review") else "author"
if switching and matching:
report["different_mcp_namespace_required"] = False
report["exact_safe_next_action"] = (
f"Call gitea_activate_profile with a profile that allows "
f"{required_operation} (matching configured profiles: "
f"{matching}).")
else:
report["different_mcp_namespace_required"] = True
report["exact_safe_next_action"] = (
f"Switch to the {role} MCP session (e.g. gitea-{role}), or ask "
f"the operator to set GITEA_MCP_PROFILE to a profile that "
f"allows {required_operation}.")
return report
def _issue_comment_gate(op: str) -> list[str]:
"""Profile permission check for issue-comment tools (#126).
@@ -1509,12 +1607,14 @@ def gitea_list_issue_comments(
Returns:
dict with 'success', 'issue_number', and 'comments' (each with 'id',
'author', 'created_at', 'updated_at', 'body'); on a permission block,
'success' False and 'reasons' with no API call made.
'success' False, 'reasons', and a structured 'permission_report'
(#142) with no API call made.
"""
reasons = _issue_comment_gate("gitea.read")
if reasons:
return {"success": False, "issue_number": issue_number,
"reasons": reasons}
"reasons": reasons,
"permission_report": _permission_block_report("gitea.read")}
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
@@ -1568,14 +1668,22 @@ def gitea_create_issue_comment(
Returns:
dict with 'success', 'comment_id', and 'issue_number' ('url' only
with the reveal opt-in); on a permission block or empty body,
'success'/'performed' False and 'reasons' with no API call made.
'success'/'performed' False and 'reasons' with no API call made
(permission blocks also carry a structured 'permission_report',
#142).
"""
reasons = _issue_comment_gate("gitea.issue.comment")
gate_reasons = _issue_comment_gate("gitea.issue.comment")
reasons = list(gate_reasons)
if not (body or "").strip():
reasons.append("comment body must be a non-empty string")
if reasons:
return {"success": False, "performed": False,
"issue_number": issue_number, "reasons": reasons}
blocked = {"success": False, "performed": False,
"issue_number": issue_number, "reasons": reasons}
if gate_reasons:
# Permission denial (not a mere input error): explain it (#142).
blocked["permission_report"] = _permission_block_report(
"gitea.issue.comment")
return blocked
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"