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:
2026-07-17 20:42:25 -04:00
co-authored by Claude Opus 4.8
parent 39e27dc63f
commit 7e5eca08b3
3 changed files with 466 additions and 16 deletions
+53 -14
View File
@@ -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."}