fix(mcp): surface safe diagnostics for opaque mutation internal_error
Mutation tools (create_issue, create_pr, delete_branch, issue-lock) returned reason_code=internal_error / retryable=false with no class, message, HTTP status, or actionable detail. The #699/#701 tool-error boundary intentionally collapses every non-typed exception to a fixed "Internal tool error" and drops the class and message, so a mutation failure is undiagnosable — and the #695 runtime guard blocks standalone reproduction. The true failing stage was therefore invisible by two independent mechanisms. Make the failure observable and actionable without weakening the secret-free contract: - Boundary (internal_error path ONLY): capture a safe exception class (a Python type identifier, never instance text) plus a strictly-redacted `detail` (token/Authorization/Bearer credentials, JSON/kv secret values incl. short passwords, raw URLs/hostnames, and absolute filesystem paths all stripped; whitespace collapsed; length-bounded) and an optional `mutation_stage`. Typed auth/authz/network/config/http paths are unchanged and still emit no detail. - Convert raw exceptions into typed structured errors via a safe `gitea_reason_code` attribute contract: server raisers may self-declare a known reason and the boundary emits it (fixed message) instead of an opaque internal_error. Pre-flight order violations now raise `_PreflightOrderError` (a RuntimeError subclass → preflight_order_violation). - Add a `_mutation_stage` context manager tagging escaping exceptions with the stage name (preflight_purity / resolve_auth / api_*), applied to delete_branch, create_issue, create_pr. Fail-closed identity/repo/worktree/role/permission/lock/lease/ancestry gates are untouched; only error *reporting* changed. New suite test_mutation_error_diagnostics.py (18 tests) pins the redaction, class capture, stage tagging, typed conversion, and adversarial no-leak contract. Existing boundary suite (25), core server (209), and preflight (92) suites stay green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+53
-14
@@ -986,11 +986,11 @@ def verify_preflight_purity(
|
||||
|
||||
if not skip_purity_order:
|
||||
if not _preflight_whoami_called:
|
||||
raise RuntimeError(
|
||||
raise _PreflightOrderError(
|
||||
"Pre-flight order violation: Identity (gitea_whoami) has not been verified (fail closed)"
|
||||
)
|
||||
if not _preflight_capability_called:
|
||||
raise RuntimeError(
|
||||
raise _PreflightOrderError(
|
||||
"Pre-flight order violation: Task capability (gitea_resolve_task_capability) has not been resolved (fail closed)"
|
||||
)
|
||||
if (
|
||||
@@ -998,7 +998,7 @@ def verify_preflight_purity(
|
||||
and _preflight_resolved_task is not None
|
||||
and task != _preflight_resolved_task
|
||||
):
|
||||
raise RuntimeError(
|
||||
raise _PreflightOrderError(
|
||||
"Pre-flight task mismatch: "
|
||||
f"resolved '{_preflight_resolved_task}' but mutation requires "
|
||||
f"'{task}' (fail closed)"
|
||||
@@ -1044,13 +1044,13 @@ def verify_preflight_purity(
|
||||
)
|
||||
else:
|
||||
if _preflight_whoami_violation:
|
||||
raise RuntimeError(
|
||||
raise _PreflightOrderError(
|
||||
"Pre-flight order violation: Workspace file edits occurred before "
|
||||
f"gitea_whoami verification (fail closed). Offending files: "
|
||||
f"{_format_preflight_files(_preflight_whoami_violation_files)}"
|
||||
)
|
||||
if _preflight_capability_violation:
|
||||
raise RuntimeError(
|
||||
raise _PreflightOrderError(
|
||||
"Pre-flight order violation: Workspace file edits occurred before "
|
||||
f"gitea_resolve_task_capability verification (fail closed). Offending files: "
|
||||
f"{_format_preflight_files(_preflight_capability_violation_files)}"
|
||||
@@ -2491,6 +2491,40 @@ def _audit(action: str, *, host, remote, result, org=None, repo=None,
|
||||
pass # best-effort; never break the action
|
||||
|
||||
|
||||
class _PreflightOrderError(RuntimeError):
|
||||
"""Pre-flight order/precondition violation for a mutation (fail closed).
|
||||
|
||||
Carries a safe ``gitea_reason_code`` so the tool-error boundary converts it
|
||||
into a typed structured error (``preflight_order_violation``) instead of an
|
||||
opaque ``internal_error``. It is still a ``RuntimeError`` subclass so every
|
||||
existing ``except RuntimeError`` / message assertion keeps working.
|
||||
"""
|
||||
|
||||
gitea_reason_code = "preflight_order_violation"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _mutation_stage(stage: str):
|
||||
"""Tag any exception escaping *stage* with a safe stage name (#diagnostics).
|
||||
|
||||
On exception, records ``stage`` on the exception (innermost wins) so the
|
||||
tool-error boundary can report ``mutation_stage=<stage>`` alongside the
|
||||
redacted class/detail. Never swallows, never alters control flow, and never
|
||||
overwrites a more specific (inner) stage already set.
|
||||
"""
|
||||
try:
|
||||
yield
|
||||
except BaseException as exc: # noqa: BLE001 - tag-and-reraise only
|
||||
try:
|
||||
if isinstance(exc, Exception) and not getattr(
|
||||
exc, "_gitea_mutation_stage", None
|
||||
):
|
||||
setattr(exc, "_gitea_mutation_stage", stage)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _audited(action: str, *, host, remote, org=None, repo=None,
|
||||
request_metadata=None, issue_number=None, pr_number=None,
|
||||
@@ -2661,9 +2695,10 @@ def gitea_create_issue(
|
||||
if blocked:
|
||||
return blocked
|
||||
try:
|
||||
verify_preflight_purity(
|
||||
remote, worktree_path=worktree_path, task="create_issue"
|
||||
)
|
||||
with _mutation_stage("preflight_purity"):
|
||||
verify_preflight_purity(
|
||||
remote, worktree_path=worktree_path, task="create_issue"
|
||||
)
|
||||
except Exception as exc:
|
||||
typed = _production_guard_block_from_exc(exc, number=None)
|
||||
if typed is not None:
|
||||
@@ -3114,7 +3149,8 @@ def gitea_create_pr(
|
||||
)
|
||||
if blocked:
|
||||
return blocked
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_pr")
|
||||
with _mutation_stage("preflight_purity"):
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_pr")
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
|
||||
# ── Issue Lock Validation (Issue #194 / #196 / #443) ──
|
||||
@@ -8616,9 +8652,11 @@ def gitea_delete_branch(
|
||||
"audit_phase": audit_reconciliation_mode.current_phase(),
|
||||
}
|
||||
|
||||
verify_preflight_purity(remote, task="delete_branch")
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
with _mutation_stage("preflight_purity"):
|
||||
verify_preflight_purity(remote, task="delete_branch")
|
||||
with _mutation_stage("resolve_auth"):
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
import urllib.parse
|
||||
encoded_branch = urllib.parse.quote(branch, safe="")
|
||||
url = f"{repo_api_url(h, o, r)}/branches/{encoded_branch}"
|
||||
@@ -8626,8 +8664,9 @@ def gitea_delete_branch(
|
||||
"branch": branch,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
}
|
||||
with _audited("delete_branch", host=h, remote=remote, org=o, repo=r,
|
||||
target_branch=branch, request_metadata=request_metadata):
|
||||
with _mutation_stage("api_delete"), _audited(
|
||||
"delete_branch", host=h, remote=remote, org=o, repo=r,
|
||||
target_branch=branch, request_metadata=request_metadata):
|
||||
api_request("DELETE", url, auth)
|
||||
return {"success": True, "message": f"Remote branch '{branch}' deleted."}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user