Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80f59b334e | ||
|
|
77746b08fc | ||
|
|
1c37e62014 | ||
|
|
137426f7ad | ||
|
|
5b0d7aed0a | ||
|
|
1ab384adc9 | ||
|
|
6b675f5c83 | ||
|
|
b4d0cb22e4 | ||
|
|
4a63578003 | ||
|
|
c7a444eb4b | ||
|
|
8cac50b2e7 |
@@ -7,6 +7,19 @@ from typing import Any
|
||||
|
||||
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
|
||||
# Evidence / preservation branches must never be removed by cleanup tools
|
||||
# (e.g. chore/issue-681-preserve-review-session-wip).
|
||||
_PRESERVATION_MARKERS = ("preserve", "preservation", "evidence")
|
||||
|
||||
|
||||
def is_preservation_or_evidence_branch(branch: str | None) -> bool:
|
||||
"""Return True when *branch* is a preservation/evidence ref that must stay."""
|
||||
if not branch:
|
||||
return False
|
||||
name = str(branch).lower()
|
||||
return any(marker in name for marker in _PRESERVATION_MARKERS)
|
||||
|
||||
|
||||
_RAW_BRANCH_DELETE_PATTERNS = (
|
||||
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+branch\s+-[dD]\b[^\n\r]*", re.I),
|
||||
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I),
|
||||
@@ -72,6 +85,11 @@ def assess_merged_pr_branch_cleanup(
|
||||
reasons.append("PR head branch is missing")
|
||||
if head_branch in protected:
|
||||
reasons.append(f"branch '{head_branch}' is protected")
|
||||
if is_preservation_or_evidence_branch(head_branch):
|
||||
reasons.append(
|
||||
f"branch '{head_branch}' is a preservation/evidence branch and "
|
||||
"cannot be deleted through merged-PR cleanup"
|
||||
)
|
||||
if head_branch in open_pr_heads:
|
||||
reasons.append("an open PR still references this head branch")
|
||||
if head_on_target is False:
|
||||
@@ -96,3 +114,490 @@ def assess_merged_pr_branch_cleanup(
|
||||
"block_reasons": reasons,
|
||||
"recommended_action": "delete_remote_branch" if safe else "keep_remote_branch",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #687 remediation: post-delete readback + active ownership protection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
READBACK_NOT_FOUND = "not_found"
|
||||
READBACK_EXISTS = "exists"
|
||||
READBACK_AUTHENTICATION = "authentication_error"
|
||||
READBACK_AUTHORIZATION = "authorization_error"
|
||||
READBACK_TRANSPORT = "transport_error"
|
||||
READBACK_UNEXPECTED = "unexpected_response"
|
||||
READBACK_AMBIGUOUS_404 = "ambiguous_not_found"
|
||||
|
||||
# Scope of a 404: only branch-scoped absence may set verified_absent.
|
||||
NOT_FOUND_SCOPE_BRANCH = "branch"
|
||||
NOT_FOUND_SCOPE_REPOSITORY = "repository"
|
||||
NOT_FOUND_SCOPE_HOST = "host"
|
||||
NOT_FOUND_SCOPE_UNKNOWN = "unknown"
|
||||
|
||||
ERROR_CLASS_AUTHENTICATION = "authentication"
|
||||
ERROR_CLASS_AUTHORIZATION = "authorization"
|
||||
ERROR_CLASS_TRANSPORT = "transport"
|
||||
ERROR_CLASS_UNEXPECTED = "unexpected"
|
||||
|
||||
OWNERSHIP_CATEGORY_AUTHOR_SESSION = "author_session"
|
||||
OWNERSHIP_CATEGORY_AUTHOR_LEASE = "author_lease"
|
||||
OWNERSHIP_CATEGORY_REVIEWER_LEASE = "reviewer_lease"
|
||||
OWNERSHIP_CATEGORY_MERGER_LEASE = "merger_lease"
|
||||
OWNERSHIP_CATEGORY_CONTROLLER_LEASE = "controller_lease"
|
||||
OWNERSHIP_CATEGORY_RECONCILER_LEASE = "reconciler_lease"
|
||||
OWNERSHIP_CATEGORY_WORKTREE_BINDING = "worktree_binding"
|
||||
OWNERSHIP_CATEGORY_INVENTORY_ERROR = "ownership_inventory_error"
|
||||
|
||||
_ROLE_TO_OWNERSHIP_CATEGORY = {
|
||||
"author": OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||
"reviewer": OWNERSHIP_CATEGORY_REVIEWER_LEASE,
|
||||
"merger": OWNERSHIP_CATEGORY_MERGER_LEASE,
|
||||
"controller": OWNERSHIP_CATEGORY_CONTROLLER_LEASE,
|
||||
"reconciler": OWNERSHIP_CATEGORY_RECONCILER_LEASE,
|
||||
}
|
||||
|
||||
_ACTIVE_OWNERSHIP_STATUSES = frozenset(
|
||||
{"active", "live", "claimed", "in_progress", "working", "pushing", "pushed"}
|
||||
)
|
||||
_TERMINAL_OWNERSHIP_STATUSES = frozenset(
|
||||
{"released", "abandoned", "done", "blocked", "terminal", "closed"}
|
||||
)
|
||||
_EXPIRED_STATUSES = frozenset({"expired"})
|
||||
_STALE_STATUSES = frozenset({"stale", "stale_dead_process", "stale_missing_worktree"})
|
||||
|
||||
|
||||
def _norm_str(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def normalize_host(host: str | None) -> str:
|
||||
"""Normalize a host identity for ownership matching (no credentials)."""
|
||||
text = _norm_str(host).lower()
|
||||
for prefix in ("https://", "http://"):
|
||||
if text.startswith(prefix):
|
||||
text = text[len(prefix) :]
|
||||
# Drop path/query if a full URL slipped through.
|
||||
text = text.split("/", 1)[0]
|
||||
text = text.split("?", 1)[0]
|
||||
return text.rstrip(".")
|
||||
|
||||
|
||||
def ownership_category_for_role(role: str | None) -> str:
|
||||
"""Map a role kind to a non-secret ownership category label."""
|
||||
key = _norm_str(role).lower()
|
||||
return _ROLE_TO_OWNERSHIP_CATEGORY.get(key, f"{key or 'unknown'}_lease")
|
||||
|
||||
|
||||
def classify_branch_readback_http_status(
|
||||
status_code: int | None,
|
||||
*,
|
||||
not_found_scope: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify a GET-branch HTTP status into a secret-free readback result.
|
||||
|
||||
R1: A bare/generic/repository/wrong-host 404 never yields
|
||||
``verified_absent=True``. Only an authoritative *branch-scoped* not-found
|
||||
(``not_found_scope='branch'``) may verify deletion.
|
||||
"""
|
||||
if status_code == 404:
|
||||
scope = _norm_str(not_found_scope).lower() or NOT_FOUND_SCOPE_UNKNOWN
|
||||
if scope == NOT_FOUND_SCOPE_BRANCH:
|
||||
return {
|
||||
"status": READBACK_NOT_FOUND,
|
||||
"error_class": None,
|
||||
"verified_absent": True,
|
||||
"branch_present": False,
|
||||
"not_found_scope": NOT_FOUND_SCOPE_BRANCH,
|
||||
"reasons": [],
|
||||
}
|
||||
# repository / host / unknown / generic 404 — not verified absence
|
||||
reason = {
|
||||
NOT_FOUND_SCOPE_REPOSITORY: (
|
||||
"post-delete readback 404 is repository-scoped, not branch absence"
|
||||
),
|
||||
NOT_FOUND_SCOPE_HOST: (
|
||||
"post-delete readback 404 is host-scoped, not branch absence"
|
||||
),
|
||||
}.get(
|
||||
scope,
|
||||
"post-delete readback 404 is ambiguous (not branch-scoped); "
|
||||
"cannot verify absence",
|
||||
)
|
||||
return {
|
||||
"status": READBACK_AMBIGUOUS_404,
|
||||
"error_class": ERROR_CLASS_UNEXPECTED,
|
||||
"verified_absent": False,
|
||||
"branch_present": None,
|
||||
"not_found_scope": scope,
|
||||
"reasons": [reason],
|
||||
}
|
||||
if status_code in (401, 407):
|
||||
return {
|
||||
"status": READBACK_AUTHENTICATION,
|
||||
"error_class": ERROR_CLASS_AUTHENTICATION,
|
||||
"verified_absent": False,
|
||||
"branch_present": None,
|
||||
"reasons": ["post-delete branch readback authentication failed"],
|
||||
}
|
||||
if status_code == 403:
|
||||
return {
|
||||
"status": READBACK_AUTHORIZATION,
|
||||
"error_class": ERROR_CLASS_AUTHORIZATION,
|
||||
"verified_absent": False,
|
||||
"branch_present": None,
|
||||
"reasons": ["post-delete branch readback authorization failed"],
|
||||
}
|
||||
if status_code is not None and 200 <= int(status_code) < 300:
|
||||
return {
|
||||
"status": READBACK_EXISTS,
|
||||
"error_class": None,
|
||||
"verified_absent": False,
|
||||
"branch_present": True,
|
||||
"reasons": ["post-delete readback found branch still present"],
|
||||
}
|
||||
if status_code is not None and int(status_code) >= 500:
|
||||
return {
|
||||
"status": READBACK_TRANSPORT,
|
||||
"error_class": ERROR_CLASS_TRANSPORT,
|
||||
"verified_absent": False,
|
||||
"branch_present": None,
|
||||
"reasons": ["post-delete branch readback transport/upstream failure"],
|
||||
}
|
||||
return {
|
||||
"status": READBACK_UNEXPECTED,
|
||||
"error_class": ERROR_CLASS_UNEXPECTED,
|
||||
"verified_absent": False,
|
||||
"branch_present": None,
|
||||
"reasons": ["post-delete branch readback returned an unexpected response"],
|
||||
"http_status": status_code,
|
||||
}
|
||||
|
||||
|
||||
def _extract_http_status(exc: BaseException) -> int | None:
|
||||
"""Extract an HTTP status code from an exception chain (secret-free)."""
|
||||
seen: set[int] = set()
|
||||
current: BaseException | None = exc
|
||||
while current is not None and id(current) not in seen:
|
||||
seen.add(id(current))
|
||||
for attr in ("code", "status", "status_code"):
|
||||
value = getattr(current, attr, None)
|
||||
if isinstance(value, int) and 100 <= value <= 599:
|
||||
return value
|
||||
text = str(current) if current is not None else ""
|
||||
match = re.match(r"HTTP\s+(\d{3})\b", text)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
current = current.__cause__ or current.__context__
|
||||
return None
|
||||
|
||||
|
||||
def classify_branch_readback_exception(
|
||||
exc: BaseException,
|
||||
*,
|
||||
not_found_scope: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify a GET-branch exception without leaking credentials or bodies.
|
||||
|
||||
R1: substring ``404`` / ``not found`` alone never becomes verified_absent.
|
||||
Callers must pass ``not_found_scope='branch'`` only after authoritative
|
||||
proof that the repository/host is still reachable and the 404 is branch-level.
|
||||
"""
|
||||
status_code = _extract_http_status(exc)
|
||||
if status_code is None:
|
||||
lower = str(exc).lower() if exc is not None else ""
|
||||
if any(
|
||||
token in lower
|
||||
for token in (
|
||||
"timed out",
|
||||
"timeout",
|
||||
"connection",
|
||||
"network",
|
||||
"temporarily unavailable",
|
||||
"name or service not known",
|
||||
"nodename nor servname",
|
||||
)
|
||||
):
|
||||
return {
|
||||
"status": READBACK_TRANSPORT,
|
||||
"error_class": ERROR_CLASS_TRANSPORT,
|
||||
"verified_absent": False,
|
||||
"branch_present": None,
|
||||
"reasons": [
|
||||
"post-delete branch readback transport/upstream failure"
|
||||
],
|
||||
}
|
||||
if "unauthorized" in lower:
|
||||
status_code = 401
|
||||
elif "forbidden" in lower:
|
||||
status_code = 403
|
||||
elif "404" in lower or "not found" in lower:
|
||||
# Ambiguous: do NOT treat as branch absence without scope proof.
|
||||
status_code = 404
|
||||
if not_found_scope is None:
|
||||
not_found_scope = NOT_FOUND_SCOPE_UNKNOWN
|
||||
|
||||
if status_code is not None:
|
||||
return classify_branch_readback_http_status(
|
||||
status_code, not_found_scope=not_found_scope
|
||||
)
|
||||
|
||||
return {
|
||||
"status": READBACK_UNEXPECTED,
|
||||
"error_class": ERROR_CLASS_UNEXPECTED,
|
||||
"verified_absent": False,
|
||||
"branch_present": None,
|
||||
"reasons": ["post-delete branch readback returned an unexpected response"],
|
||||
}
|
||||
|
||||
|
||||
def assess_post_delete_readback(readback: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Decide whether DELETE success may be reported after branch readback.
|
||||
|
||||
Success requires authoritative branch-scoped not-found
|
||||
(``verified_absent=True``). DELETE HTTP success alone is never enough.
|
||||
Generic/repository/host 404 cannot verify absence (R1).
|
||||
"""
|
||||
payload = dict(readback or {})
|
||||
status = _norm_str(payload.get("status")) or READBACK_UNEXPECTED
|
||||
# Only explicit verified_absent flag counts — never infer from status alone
|
||||
# when status is a bare not_found without branch scope proof.
|
||||
verified = bool(payload.get("verified_absent")) is True
|
||||
if verified and status == READBACK_NOT_FOUND:
|
||||
return {
|
||||
"ok": True,
|
||||
"success": True,
|
||||
"verified_absent": True,
|
||||
"readback": {
|
||||
"status": READBACK_NOT_FOUND,
|
||||
"verified_absent": True,
|
||||
"branch_present": False,
|
||||
"error_class": None,
|
||||
"not_found_scope": NOT_FOUND_SCOPE_BRANCH,
|
||||
},
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
reasons = list(payload.get("reasons") or [])
|
||||
if not reasons:
|
||||
if status == READBACK_EXISTS:
|
||||
reasons = ["post-delete readback found branch still present"]
|
||||
elif status == READBACK_AUTHENTICATION:
|
||||
reasons = ["post-delete branch readback authentication failed"]
|
||||
elif status == READBACK_AUTHORIZATION:
|
||||
reasons = ["post-delete branch readback authorization failed"]
|
||||
elif status == READBACK_TRANSPORT:
|
||||
reasons = ["post-delete branch readback transport/upstream failure"]
|
||||
elif status == READBACK_AMBIGUOUS_404:
|
||||
reasons = [
|
||||
"post-delete readback 404 is not branch-scoped; "
|
||||
"cannot verify absence"
|
||||
]
|
||||
else:
|
||||
reasons = ["post-delete branch readback could not verify deletion"]
|
||||
|
||||
return {
|
||||
"ok": False,
|
||||
"success": False,
|
||||
"verified_absent": False,
|
||||
"readback": {
|
||||
"status": status,
|
||||
"verified_absent": False,
|
||||
"branch_present": payload.get("branch_present"),
|
||||
"error_class": payload.get("error_class"),
|
||||
"not_found_scope": payload.get("not_found_scope"),
|
||||
},
|
||||
"reasons": reasons,
|
||||
"blocker_kind": "post_delete_readback_failed",
|
||||
}
|
||||
|
||||
|
||||
def cleanup_result_envelope(
|
||||
*,
|
||||
success: bool,
|
||||
performed: bool,
|
||||
delete_acknowledged: bool,
|
||||
verified_absent: bool,
|
||||
**extra: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""R2: consistent top-level cleanup result fields on every return path."""
|
||||
out: dict[str, Any] = {
|
||||
"success": bool(success),
|
||||
"performed": bool(performed),
|
||||
"delete_acknowledged": bool(delete_acknowledged),
|
||||
"verified_absent": bool(verified_absent),
|
||||
}
|
||||
out.update(extra)
|
||||
return out
|
||||
|
||||
|
||||
def _repo_matches(
|
||||
record: dict[str, Any],
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
host: str | None = None,
|
||||
) -> bool:
|
||||
"""Match ownership record to target remote/org/repo and normalized host."""
|
||||
if (
|
||||
_norm_str(record.get("remote")).lower() != _norm_str(remote).lower()
|
||||
or _norm_str(record.get("org")).lower() != _norm_str(org).lower()
|
||||
or _norm_str(record.get("repo")).lower() != _norm_str(repo).lower()
|
||||
):
|
||||
return False
|
||||
expected_host = normalize_host(host)
|
||||
record_host = normalize_host(record.get("host") or record.get("host_name"))
|
||||
# When both sides declare a host, they must agree after normalization.
|
||||
# A record host that disagrees with the expected host is out of scope.
|
||||
# Legacy records without host still match when remote/org/repo agree.
|
||||
if expected_host and record_host and expected_host != record_host:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _branch_matches(record: dict[str, Any], branch: str) -> bool:
|
||||
return _norm_str(record.get("branch")) == _norm_str(branch)
|
||||
|
||||
|
||||
def assess_ownership_record_activity(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Classify one ownership record as blocking or non-blocking.
|
||||
|
||||
Distinguishes active ownership from expired/released/terminal/stale records.
|
||||
Expired/stale records block unless reclaim_allowed is *explicitly* True
|
||||
(O2: never treat missing/unknown reclaim as auto-allowed).
|
||||
"""
|
||||
status = _norm_str(record.get("status")).lower()
|
||||
category = _norm_str(record.get("category")) or "unknown"
|
||||
reclaim_allowed = record.get("reclaim_allowed")
|
||||
|
||||
if category == OWNERSHIP_CATEGORY_INVENTORY_ERROR:
|
||||
return {
|
||||
"blocks": True,
|
||||
"status": status or "unknown",
|
||||
"category": category,
|
||||
"reason": "ownership inventory failed closed",
|
||||
}
|
||||
|
||||
if status in _TERMINAL_OWNERSHIP_STATUSES:
|
||||
return {
|
||||
"blocks": False,
|
||||
"status": status,
|
||||
"category": category,
|
||||
"reason": f"{category} ownership is terminal/released ({status})",
|
||||
}
|
||||
if status in _ACTIVE_OWNERSHIP_STATUSES:
|
||||
return {
|
||||
"blocks": True,
|
||||
"status": status,
|
||||
"category": category,
|
||||
"reason": f"active {category} ownership still uses the target branch",
|
||||
}
|
||||
if status in _EXPIRED_STATUSES or status in _STALE_STATUSES:
|
||||
# O2: only explicit reclaim_allowed=True skips the block.
|
||||
if reclaim_allowed is True:
|
||||
return {
|
||||
"blocks": False,
|
||||
"status": status,
|
||||
"category": category,
|
||||
"reason": (
|
||||
f"{category} ownership is {status} and reclaimable; "
|
||||
"does not block deletion"
|
||||
),
|
||||
}
|
||||
return {
|
||||
"blocks": True,
|
||||
"status": status,
|
||||
"category": category,
|
||||
"reason": (
|
||||
f"{status} {category} ownership still protects the target "
|
||||
"branch (reclaim not proven; fail closed)"
|
||||
),
|
||||
}
|
||||
# Unknown status → fail closed
|
||||
return {
|
||||
"blocks": True,
|
||||
"status": status or "unknown",
|
||||
"category": category,
|
||||
"reason": (
|
||||
f"unclassified {category} ownership status "
|
||||
f"'{status or 'unknown'}'; fail closed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_active_branch_ownership(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
branch: str,
|
||||
host: str | None = None,
|
||||
records: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess whether any active ownership still uses *branch* in *repo*.
|
||||
|
||||
Matching requires remote/org/repo/branch and, when provided, normalized
|
||||
host identity. Other repositories, hosts, or branches never false-block.
|
||||
"""
|
||||
target_branch = _norm_str(branch)
|
||||
expected_host = normalize_host(host)
|
||||
considered: list[dict[str, Any]] = []
|
||||
blocking: list[dict[str, Any]] = []
|
||||
ignored: list[dict[str, Any]] = []
|
||||
|
||||
for raw in records or []:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
if not _repo_matches(
|
||||
raw, remote=remote, org=org, repo=repo, host=expected_host or None
|
||||
):
|
||||
ignored.append(
|
||||
{
|
||||
"category": _norm_str(raw.get("category")) or "unknown",
|
||||
"reason": "different repository or host scope",
|
||||
}
|
||||
)
|
||||
continue
|
||||
if not _branch_matches(raw, target_branch):
|
||||
ignored.append(
|
||||
{
|
||||
"category": _norm_str(raw.get("category")) or "unknown",
|
||||
"reason": "different branch",
|
||||
}
|
||||
)
|
||||
continue
|
||||
activity = assess_ownership_record_activity(raw)
|
||||
entry = {
|
||||
"category": activity["category"],
|
||||
"status": activity["status"],
|
||||
"blocks": activity["blocks"],
|
||||
"reason": activity["reason"],
|
||||
}
|
||||
considered.append(entry)
|
||||
if activity["blocks"]:
|
||||
blocking.append(entry)
|
||||
|
||||
block = bool(blocking)
|
||||
categories = sorted({b["category"] for b in blocking})
|
||||
reasons = [
|
||||
(
|
||||
"active ownership protects branch "
|
||||
f"'{target_branch}': " + "; ".join(b["reason"] for b in blocking)
|
||||
)
|
||||
] if block else []
|
||||
return {
|
||||
"block": block,
|
||||
"safe_to_delete": not block,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"host": expected_host or None,
|
||||
"branch": target_branch,
|
||||
"blocking_categories": categories,
|
||||
"blocking": blocking,
|
||||
"considered": considered,
|
||||
"ignored_out_of_scope": ignored,
|
||||
"reasons": reasons,
|
||||
"blocker_kind": "active_branch_ownership" if block else None,
|
||||
"recommended_action": "keep_remote_branch" if block else "delete_remote_branch",
|
||||
}
|
||||
|
||||
@@ -238,11 +238,43 @@ narrow operation set:
|
||||
- `gitea.issue.comment`
|
||||
- `gitea.issue.close`
|
||||
- `gitea.pr.close`
|
||||
- `gitea.branch.delete` (merged-branch cleanup only — see below)
|
||||
|
||||
Forbidden on reconciler profiles: `gitea.pr.approve`, `gitea.pr.merge`,
|
||||
`gitea.pr.review`, `gitea.pr.create`, `gitea.branch.push`, and
|
||||
`gitea.repo.commit`.
|
||||
|
||||
### Merged-branch cleanup ownership (`gitea.branch.delete`)
|
||||
|
||||
The reconciler is the repository-supported owner of merged-PR source-branch
|
||||
cleanup: `task_capability_map` maps `cleanup_merged_pr_branch` (and
|
||||
`reconciliation_cleanup`) to role `reconciler` with permission
|
||||
`gitea.branch.delete`. Post-merge branch lifecycle is reconciliation work —
|
||||
it happens after the author, reviewer, and merger roles have completed, and
|
||||
it must not be reachable from those roles.
|
||||
|
||||
Least-privilege constraints:
|
||||
|
||||
- `gitea.branch.delete` is granted **only** to reconciler profiles. Author,
|
||||
reviewer, and merger profiles must never hold it; `gitea_delete_branch`
|
||||
and `gitea_cleanup_merged_pr_branch` fail closed on any profile without
|
||||
the permission.
|
||||
- Even with the permission, reconciler deletion is only supported through the
|
||||
guarded `gitea_cleanup_merged_pr_branch` path (#514 / #687): the PR must be
|
||||
merged, the head an ancestor of the target, the branch not protected
|
||||
(`master`/`main`/`dev`), the branch not a preservation/evidence ref (e.g.
|
||||
`chore/issue-681-preserve-review-session-wip`), no open PR may still use the
|
||||
head, and an explicit `CLEANUP MERGED PR <n> BRANCH <branch>` confirmation is
|
||||
required. Raw `gitea_delete_branch` is **denied** to reconciler even when
|
||||
`gitea.branch.delete` is present.
|
||||
- Raw `git branch -d` / `git push --delete` cleanup remains blocked by
|
||||
`branch_cleanup_guard` and the final-report validator regardless of
|
||||
profile permissions.
|
||||
- `gitea.branch.delete` has no short alias in `GITEA_OPERATION_ALIASES`;
|
||||
write it fully qualified in `allowed_operations`. Migration must emit
|
||||
canonical names such as `gitea.pr.close` (never bare `pr.close` /
|
||||
`issue.close`, which the production normalizer rejects or drops).
|
||||
|
||||
Launch a static `gitea-reconciler` MCP namespace with
|
||||
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
||||
`reconciler_profile.assess_reconciler_profile` (#304). Use the
|
||||
@@ -251,6 +283,159 @@ Launch a static `gitea-reconciler` MCP namespace with
|
||||
fresh target-branch fetch, recorded target SHA, and ancestor proof. PRs whose
|
||||
heads are not already landed cannot be closed through this path.
|
||||
|
||||
### Operational runbook: grant reconciler `gitea.branch.delete` (#687)
|
||||
|
||||
Merging a code PR that updates `migrate_profiles.py` / `reconciler_profile.py`
|
||||
**does not** change the live operator profile on disk. Apply the profile
|
||||
change deliberately, then reconnect the client-managed namespace.
|
||||
|
||||
1. **Approved migration / profile-update command** (from the repo root, using
|
||||
the project venv if present):
|
||||
|
||||
```bash
|
||||
# Dry-run first (default): validates v2 output, writes nothing
|
||||
python3 migrate_profiles.py -i ~/.config/gitea-tools/profiles.json
|
||||
|
||||
# Apply: creates backup then writes migrated v2 config
|
||||
python3 migrate_profiles.py -i ~/.config/gitea-tools/profiles.json -w
|
||||
# Optional explicit paths:
|
||||
# python3 migrate_profiles.py -i ~/.config/gitea-tools/profiles.json \
|
||||
# -o ~/.config/gitea-tools/profiles.json \
|
||||
# --backup ~/.config/gitea-tools/profiles.json.bak -w
|
||||
```
|
||||
|
||||
If the live file is already v2, edit the reconciler identity’s
|
||||
`allowed_operations` / `forbidden_operations` under
|
||||
`environments.<env>.services.gitea.identities.reconciler` (or the
|
||||
`prgs-reconciler` alias target) so allowed includes the canonical set
|
||||
below — then re-validate with a load of the config (see step 3).
|
||||
|
||||
2. **Inspect the generated (or edited) profile** — confirm the reconciler
|
||||
identity, for example:
|
||||
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
cfg = json.loads(Path.home().joinpath(".config/gitea-tools/profiles.json").read_text())
|
||||
# v2 environments shape:
|
||||
ident = cfg["environments"]["prgs"]["services"]["gitea"]["identities"]["reconciler"]
|
||||
print("role:", ident.get("role"))
|
||||
print("allowed:", ident.get("allowed_operations"))
|
||||
print("forbidden:", ident.get("forbidden_operations"))
|
||||
PY
|
||||
```
|
||||
|
||||
3. **Validate canonical operation names and least privilege**
|
||||
|
||||
Expected canonical **allowed** (defaults after migration):
|
||||
|
||||
- `gitea.read`
|
||||
- `gitea.pr.close` (required)
|
||||
- `gitea.pr.comment`
|
||||
- `gitea.issue.comment`
|
||||
- `gitea.issue.close`
|
||||
- `gitea.branch.delete` (recommended; cleanup only)
|
||||
|
||||
Expected **forbidden** includes at least: `gitea.pr.approve`,
|
||||
`gitea.pr.merge`, `gitea.pr.review`, `gitea.pr.create`,
|
||||
`gitea.branch.push`, `gitea.repo.commit`.
|
||||
|
||||
No shorthand (`pr.close`, `issue.close`, `pr.comment`) may remain.
|
||||
Validate with the production loader:
|
||||
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
import gitea_config, reconciler_profile
|
||||
from pathlib import Path
|
||||
path = str(Path.home() / ".config/gitea-tools/profiles.json")
|
||||
gitea_config.load_config(path) # fails closed on invalid config
|
||||
# Or assess the reconciler lists directly after extracting them:
|
||||
# print(reconciler_profile.assess_reconciler_profile(allowed, forbidden))
|
||||
PY
|
||||
```
|
||||
|
||||
4. **Merging PR #688 (or any code PR) does not update the live profile.**
|
||||
Code changes only the migration helper, schema, docs, and tests. The
|
||||
operator must still run `migrate_profiles.py -w` or an equivalent
|
||||
authorized edit of `~/.config/gitea-tools/profiles.json`.
|
||||
|
||||
5. **Supported apply method:** `python3 migrate_profiles.py … -w` (backup
|
||||
created automatically) **or** operator-authorized edit of the live
|
||||
profiles file after backup. Unsupported: silent mtime tricks, manual
|
||||
process kill to “reload”, or undocumented env overrides.
|
||||
|
||||
6. **Backup and validation:** `-w` copies the input to
|
||||
`<input_path>.bak` (or `--backup PATH`) before writing. Re-run
|
||||
`load_config` / `assess_reconciler_profile` after write. Keep the
|
||||
`.bak` until live whoami/capability checks pass.
|
||||
|
||||
7. **Client-managed namespace reconnect/reload:** reconnect or reload the
|
||||
IDE MCP client so `gitea-reconciler` restarts from current `master` and
|
||||
the updated `GITEA_MCP_PROFILE=prgs-reconciler` config. Do not hand-launch
|
||||
`mcp_server.py` / `gitea_mcp_server.py` with ad hoc `GITEA_*` env
|
||||
(see #686 / #630).
|
||||
|
||||
8. **Live reverification** (through the client-managed `gitea-reconciler`
|
||||
namespace only):
|
||||
|
||||
- `gitea_whoami` → identity + profile `prgs-reconciler`
|
||||
- `gitea_assess_master_parity` → `stale=false`, `restart_required=false`
|
||||
- `gitea_resolve_task_capability(task="cleanup_merged_pr_branch")` →
|
||||
`allowed_in_current_session=true` only when permission and role match
|
||||
- `gitea_resolve_task_capability(task="delete_branch")` →
|
||||
**not** allowed for reconciler (role denial must be enforced)
|
||||
|
||||
9. **Guarded cleanup usage** (example for a merged PR whose source branch
|
||||
remains on the remote):
|
||||
|
||||
```text
|
||||
gitea_cleanup_merged_pr_branch(
|
||||
pr_number=<N>,
|
||||
branch=<exact PR head branch>,
|
||||
confirmation="CLEANUP MERGED PR <N> BRANCH <exact PR head branch>",
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
worktree_path="<path under branches/>",
|
||||
)
|
||||
```
|
||||
|
||||
The tool refuses unmerged PRs, protected branches, preservation/evidence
|
||||
branches, open-PR heads, mismatched branch names, and wrong confirmation.
|
||||
|
||||
10. **Prohibitions**
|
||||
|
||||
- No raw `git push --delete`, `git branch -d` / `-D`, or delete refspecs
|
||||
- No arbitrary `gitea_delete_branch` from reconciler
|
||||
- No unsupported profile switching mid-run without full re-preflight
|
||||
- No ad hoc hand-edits of live profiles **unless** operator-authorized,
|
||||
backed up, and revalidated as above
|
||||
|
||||
Canonical migrated reconciler example:
|
||||
|
||||
```json
|
||||
{
|
||||
"role": "reconciler",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.close",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
"gitea.branch.delete"
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Identity and fail-closed rules
|
||||
|
||||
Before **any** mutating action, a workflow must know both:
|
||||
|
||||
+207
-19
@@ -243,6 +243,192 @@ def _redact(text):
|
||||
return str(text)
|
||||
|
||||
|
||||
# ── Classified client failures (#699) ─────────────────────────────────────────
|
||||
# Subclasses of RuntimeError preserve existing ``except RuntimeError`` call
|
||||
# sites. Exception *messages* are fixed constants only — HTTP response bodies,
|
||||
# Keychain material, and arbitrary exception text are never stored on the
|
||||
# exception or re-emitted to tool results / daemon logs.
|
||||
|
||||
|
||||
# Fixed messages (must match mcp_tool_error_boundary.FIXED_MESSAGES keys used here).
|
||||
_MSG_AUTH_INVALID = "Gitea authentication failed: invalid or revoked credentials"
|
||||
_MSG_AUTH_FAILED = "Gitea authentication failed"
|
||||
_MSG_AUTHZ_SCOPE = "Gitea authorization failed: insufficient token scope"
|
||||
_MSG_AUTHZ_DENIED = "Gitea authorization failed: access denied"
|
||||
_MSG_NETWORK = "Network error contacting Gitea"
|
||||
_MSG_CONFIG = "Gitea configuration or credential resolution failed"
|
||||
_MSG_UPSTREAM = "Gitea upstream unavailable"
|
||||
_MSG_HTTP = "Gitea HTTP request failed"
|
||||
|
||||
|
||||
class GiteaClientError(RuntimeError):
|
||||
"""Base for known Gitea client failures with stable reason_code metadata."""
|
||||
|
||||
reason_code = "client_error"
|
||||
error_class = "client"
|
||||
http_status = None
|
||||
|
||||
def __init__(self, message=None, *, reason_code=None, http_status=None):
|
||||
if reason_code is not None:
|
||||
self.reason_code = reason_code
|
||||
if http_status is not None:
|
||||
self.http_status = http_status
|
||||
# Message is always a fixed constant; callers cannot inject bodies.
|
||||
fixed = message if message is not None else _MSG_HTTP
|
||||
super().__init__(fixed)
|
||||
|
||||
|
||||
class GiteaAuthError(GiteaClientError):
|
||||
"""Authentication failure (invalid/revoked credentials → typically HTTP 401)."""
|
||||
|
||||
reason_code = "auth_invalid_token"
|
||||
error_class = "authentication"
|
||||
http_status = 401
|
||||
|
||||
def __init__(self, message=None, *, reason_code=None, http_status=None):
|
||||
super().__init__(
|
||||
message if message is not None else _MSG_AUTH_INVALID,
|
||||
reason_code=reason_code or "auth_invalid_token",
|
||||
http_status=http_status if http_status is not None else 401,
|
||||
)
|
||||
|
||||
|
||||
class GiteaAuthzError(GiteaClientError):
|
||||
"""Authorization failure (HTTP 403 — scope deficiency or access denied)."""
|
||||
|
||||
reason_code = "authz_denied"
|
||||
error_class = "authorization"
|
||||
http_status = 403
|
||||
|
||||
def __init__(self, message=None, *, reason_code=None, http_status=None):
|
||||
code = reason_code or "authz_denied"
|
||||
if code == "authz_insufficient_scope":
|
||||
fixed = _MSG_AUTHZ_SCOPE
|
||||
else:
|
||||
fixed = _MSG_AUTHZ_DENIED
|
||||
code = "authz_denied"
|
||||
super().__init__(
|
||||
message if message is not None else fixed,
|
||||
reason_code=code,
|
||||
http_status=http_status if http_status is not None else 403,
|
||||
)
|
||||
|
||||
|
||||
class GiteaNetworkError(GiteaClientError):
|
||||
"""Transport / DNS / timeout failure contacting Gitea."""
|
||||
|
||||
reason_code = "network_error"
|
||||
error_class = "network"
|
||||
http_status = None
|
||||
|
||||
def __init__(self, message=None, *, reason_code=None, http_status=None):
|
||||
super().__init__(
|
||||
message if message is not None else _MSG_NETWORK,
|
||||
reason_code=reason_code or "network_error",
|
||||
http_status=http_status,
|
||||
)
|
||||
|
||||
|
||||
class GiteaConfigError(GiteaClientError):
|
||||
"""Local configuration / credential resolution failure (not HTTP auth)."""
|
||||
|
||||
reason_code = "config_error"
|
||||
error_class = "configuration"
|
||||
http_status = None
|
||||
|
||||
def __init__(self, message=None, *, reason_code=None, http_status=None):
|
||||
super().__init__(
|
||||
message if message is not None else _MSG_CONFIG,
|
||||
reason_code=reason_code or "config_error",
|
||||
http_status=http_status,
|
||||
)
|
||||
|
||||
|
||||
class GiteaHttpError(GiteaClientError):
|
||||
"""Non-auth HTTP failure with fixed message (no response body)."""
|
||||
|
||||
reason_code = "http_error"
|
||||
error_class = "client"
|
||||
http_status = None
|
||||
|
||||
def __init__(self, message=None, *, reason_code=None, http_status=None):
|
||||
code = reason_code or "http_error"
|
||||
if code == "upstream_unavailable":
|
||||
fixed = _MSG_UPSTREAM
|
||||
else:
|
||||
fixed = _MSG_HTTP
|
||||
code = "http_error"
|
||||
super().__init__(
|
||||
message if message is not None else fixed,
|
||||
reason_code=code,
|
||||
http_status=http_status,
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_insufficient_scope(detail: str) -> bool:
|
||||
"""Internal: inspect redacted body *only* to refine 403 reason_code.
|
||||
|
||||
The body is never stored on the exception or returned to callers.
|
||||
"""
|
||||
lower = (detail or "").lower()
|
||||
markers = (
|
||||
"insufficient scope",
|
||||
"required scope",
|
||||
"does not have at least one of required scope",
|
||||
"token does not have",
|
||||
"missing scope",
|
||||
"scope(s)",
|
||||
)
|
||||
return any(m in lower for m in markers)
|
||||
|
||||
|
||||
def classify_http_status(code: int, *, body_hint: str = "") -> tuple[type, str, int]:
|
||||
"""Central HTTP status → (exception_class, reason_code, http_status).
|
||||
|
||||
Every HTTP 403 becomes authorization-class. Body text is used only as a
|
||||
local hint for scope vs denied reason_code and is never returned.
|
||||
"""
|
||||
if code == 401:
|
||||
return (GiteaAuthError, "auth_invalid_token", 401)
|
||||
if code == 403:
|
||||
if _looks_like_insufficient_scope(body_hint or ""):
|
||||
return (GiteaAuthzError, "authz_insufficient_scope", 403)
|
||||
return (GiteaAuthzError, "authz_denied", 403)
|
||||
if code in (502, 503, 504):
|
||||
return (GiteaHttpError, "upstream_unavailable", code)
|
||||
return (GiteaHttpError, "http_error", code)
|
||||
|
||||
|
||||
def raise_for_http_status(code: int, body: str = "") -> None:
|
||||
"""Raise a typed client error for *code* without embedding *body*.
|
||||
|
||||
*body* may be inspected only to choose scope vs denied for 403; it is
|
||||
never placed on the exception message.
|
||||
"""
|
||||
# Redact before any inspection; discard after classification.
|
||||
try:
|
||||
hint = _redact(body or "").strip()
|
||||
except Exception:
|
||||
hint = ""
|
||||
exc_cls, reason, status = classify_http_status(code, body_hint=hint)
|
||||
# Explicitly construct without passing body/hint into message.
|
||||
if exc_cls is GiteaAuthError:
|
||||
raise GiteaAuthError(reason_code=reason, http_status=status)
|
||||
if exc_cls is GiteaAuthzError:
|
||||
raise GiteaAuthzError(reason_code=reason, http_status=status)
|
||||
if reason == "upstream_unavailable":
|
||||
raise GiteaHttpError(
|
||||
reason_code="upstream_unavailable",
|
||||
http_status=status,
|
||||
)
|
||||
raise GiteaHttpError(reason_code="http_error", http_status=status)
|
||||
|
||||
|
||||
def _raise_http_error(code: int, detail: str = "") -> None:
|
||||
"""Backward-compatible alias — *detail* is never embedded in the error."""
|
||||
raise_for_http_status(code, detail)
|
||||
|
||||
|
||||
def _add_query(url, **params):
|
||||
"""Return *url* with the given query parameters added or overridden.
|
||||
|
||||
@@ -315,23 +501,24 @@ def api_request(method, url, auth_header, payload=None, *,
|
||||
"""Make an authenticated JSON request to the Gitea API.
|
||||
|
||||
Returns parsed JSON on success (or ``None`` for an empty body), and raises
|
||||
``RuntimeError`` on failure.
|
||||
a classified client error on failure.
|
||||
|
||||
On HTTP 429 the request is retried up to *max_retries* times: honoring a
|
||||
valid ``Retry-After`` header (seconds or HTTP-date) when present, otherwise
|
||||
using capped jittered exponential backoff. Successful responses are
|
||||
unchanged.
|
||||
|
||||
All failures are converted to a ``RuntimeError`` with a clear, secret
|
||||
-redacted message (no raw stack traces or credential material):
|
||||
Failures raise typed exceptions with **fixed messages only** (#699). HTTP
|
||||
response bodies are read solely for local 403 reason refinement and are
|
||||
never stored on exceptions or returned to callers:
|
||||
|
||||
- Non-429 HTTP errors surface the status code and a redacted response body.
|
||||
502/503/504 upstream errors get an explicit "Gitea upstream unavailable"
|
||||
message.
|
||||
- Timeouts and network/DNS failures (``URLError`` / ``TimeoutError``) surface
|
||||
a generic "network error contacting Gitea" message.
|
||||
- A malformed (non-JSON) success body surfaces a "malformed JSON response"
|
||||
message rather than a raw decode error.
|
||||
- HTTP 401 → :class:`GiteaAuthError` (``auth_invalid_token``)
|
||||
- HTTP 403 → :class:`GiteaAuthzError` (scope or denied)
|
||||
- 502/503/504 → :class:`GiteaHttpError` (``upstream_unavailable``)
|
||||
- Other non-429 HTTP → :class:`GiteaHttpError` (``http_error``)
|
||||
- Timeouts / DNS / ``URLError`` → :class:`GiteaNetworkError`
|
||||
- Malformed success JSON → plain ``RuntimeError`` (programming/protocol;
|
||||
not reclassified as authentication)
|
||||
|
||||
The ``*_func`` parameters and ``timeout`` are injection points for
|
||||
deterministic testing.
|
||||
@@ -369,22 +556,23 @@ def api_request(method, url, auth_header, payload=None, *,
|
||||
error_body = e.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
error_body = ""
|
||||
detail = _redact(error_body).strip()
|
||||
if e.code in (502, 503, 504):
|
||||
msg = f"HTTP {e.code}: Gitea upstream unavailable"
|
||||
raise RuntimeError(f"{msg}: {detail}" if detail else msg) from e
|
||||
raise RuntimeError(f"HTTP {e.code}: {detail}") from e
|
||||
# Classify from status (+ local body hint). Body is not embedded.
|
||||
try:
|
||||
raise_for_http_status(e.code, error_body)
|
||||
except GiteaClientError:
|
||||
raise
|
||||
# Defensive: raise_for_http_status always raises.
|
||||
raise GiteaHttpError(http_status=e.code) from e # pragma: no cover
|
||||
except (urllib.error.URLError, TimeoutError) as e:
|
||||
reason = getattr(e, "reason", e)
|
||||
raise RuntimeError(
|
||||
f"network error contacting Gitea: {_redact(reason)}"
|
||||
) from e
|
||||
# Fixed message only — do not embed URLError reason (may leak paths).
|
||||
raise GiteaNetworkError(reason_code="network_error") from e
|
||||
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
return json.loads(body)
|
||||
except ValueError as e:
|
||||
# Programming/protocol failure — not authentication.
|
||||
raise RuntimeError("malformed JSON response from Gitea") from e
|
||||
|
||||
|
||||
|
||||
+714
-67
@@ -1084,7 +1084,9 @@ from gitea_auth import ( # noqa: E402
|
||||
repo_api_url,
|
||||
get_profile,
|
||||
gitea_url,
|
||||
GiteaConfigError,
|
||||
)
|
||||
import mcp_tool_error_boundary # noqa: E402
|
||||
import gitea_audit # noqa: E402
|
||||
import gitea_config # noqa: E402
|
||||
import capability_stop_terminal # noqa: E402
|
||||
@@ -1466,6 +1468,12 @@ def _with_optional_url(result: dict, url: str | None) -> dict:
|
||||
result["url"] = url
|
||||
return result
|
||||
|
||||
# #699: known auth/authz/network/config failures → structured CallToolResult
|
||||
# isError; stdio transport must survive (no unhandled raise / process exit).
|
||||
from mcp.server.fastmcp.tools.base import Tool as _FastMCPTool # noqa: E402
|
||||
|
||||
mcp_tool_error_boundary.install_tool_run_boundary(_FastMCPTool)
|
||||
|
||||
mcp = FastMCP("gitea-tools", instructions=(
|
||||
"Gitea issue tracker and PR management for dadeschools and prgs instances. "
|
||||
"Use the gitea_ prefixed tools to create issues, PRs, list issues, etc."
|
||||
@@ -1803,13 +1811,16 @@ def _enforce_remote_repo_guard(
|
||||
|
||||
|
||||
def _auth(host: str) -> str:
|
||||
"""Get auth header, raise if unavailable."""
|
||||
"""Get auth header, raise if unavailable.
|
||||
|
||||
Missing credentials are a configuration failure, not a silent internal
|
||||
crash. Typed as :class:`gitea_auth.GiteaConfigError` so the tool-error
|
||||
boundary (#699) maps them to a structured isError result without EOF.
|
||||
The exception message is a fixed constant (no host/token material).
|
||||
"""
|
||||
header = get_auth_header(host)
|
||||
if header is None:
|
||||
raise RuntimeError(
|
||||
f"No credentials for {host}. "
|
||||
"Ensure you've logged in via HTTPS at least once."
|
||||
)
|
||||
raise GiteaConfigError(reason_code="config_error")
|
||||
return header
|
||||
|
||||
|
||||
@@ -3333,6 +3344,11 @@ def _save_review_decision_lock(data):
|
||||
)
|
||||
payload["session_profile_lock"] = binding["session_profile_lock"]
|
||||
payload["profile_identity"] = binding["profile_identity"]
|
||||
# #720: durable decision locks are recovery-critical terminal provenance,
|
||||
# not generic TTL session cache. Stamp kind + recovery_critical for
|
||||
# pre-existing readers and identity_match_reasons flag-based exempt.
|
||||
payload["kind"] = mcp_session_state.KIND_DECISION_LOCK
|
||||
payload["recovery_critical"] = True
|
||||
if binding.get("remote") and not payload.get("remote"):
|
||||
payload["remote"] = binding["remote"]
|
||||
# #695 AC6: stamp native transport provenance on durable decision locks.
|
||||
@@ -5016,6 +5032,12 @@ def gitea_cleanup_stale_review_decision_lock(
|
||||
binding = _decision_lock_binding()
|
||||
active_identity = binding.get("profile_identity")
|
||||
lock = _load_review_decision_lock()
|
||||
# #720: when normal load yields no lock, inspect disk so assessment does not
|
||||
# silently report "absent" while an expired/non-critical envelope remains.
|
||||
disk_inspect = mcp_session_state.inspect_state_envelope(
|
||||
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||
profile_identity=active_identity,
|
||||
)
|
||||
last = stale_review_decision_lock.last_terminal_mutation(lock)
|
||||
pr_live = None
|
||||
pr_lookup_error = None
|
||||
@@ -5037,6 +5059,26 @@ def gitea_cleanup_stale_review_decision_lock(
|
||||
pr_lookup_error=pr_lookup_error,
|
||||
active_profile_identity=active_identity,
|
||||
)
|
||||
if lock is None and disk_inspect.get("on_disk"):
|
||||
assessment = dict(assessment)
|
||||
assessment["reasons"] = list(assessment.get("reasons") or []) + [
|
||||
"decision-lock file is present on disk but not loadable via normal "
|
||||
f"TTL/identity gates: {disk_inspect.get('summary')} "
|
||||
"(do not rm session-state files; #720)"
|
||||
]
|
||||
assessment["disk_inspect"] = {
|
||||
k: disk_inspect.get(k)
|
||||
for k in (
|
||||
"on_disk",
|
||||
"has_payload",
|
||||
"age_hours",
|
||||
"age_exceeds_default_ttl",
|
||||
"recovery_critical",
|
||||
"ttl_exempt",
|
||||
"would_ttl_reject",
|
||||
"summary",
|
||||
)
|
||||
}
|
||||
|
||||
# Optional pin: refuse apply against a different terminal PR than expected.
|
||||
if (
|
||||
@@ -5091,6 +5133,19 @@ def gitea_cleanup_stale_review_decision_lock(
|
||||
"pr_merged_or_closed": assessment.get("pr_merged_or_closed"),
|
||||
"merge_commit_sha": assessment.get("merge_commit_sha"),
|
||||
"lock_summary": assessment.get("lock_summary"),
|
||||
"disk_inspect": assessment.get("disk_inspect") or {
|
||||
k: disk_inspect.get(k)
|
||||
for k in (
|
||||
"on_disk",
|
||||
"has_payload",
|
||||
"age_hours",
|
||||
"age_exceeds_default_ttl",
|
||||
"recovery_critical",
|
||||
"ttl_exempt",
|
||||
"would_ttl_reject",
|
||||
"summary",
|
||||
)
|
||||
},
|
||||
"audit": audit,
|
||||
"audit_comment_id": None,
|
||||
"reasons": list(assessment.get("reasons") or []),
|
||||
@@ -7575,6 +7630,65 @@ def gitea_delete_branch(
|
||||
"permission_report": _permission_block_report("gitea.branch.delete"),
|
||||
}
|
||||
|
||||
# Possessing gitea.branch.delete alone is not enough for arbitrary deletion.
|
||||
# task_capability_map maps delete_branch → author; reconciler must use the
|
||||
# guarded cleanup_merged_pr_branch path only (#687 / #514).
|
||||
profile = get_profile()
|
||||
active_role = _profile_role_kind(profile)
|
||||
required_role = task_capability_map.required_role("delete_branch")
|
||||
if active_role == "reconciler":
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"required_role_kind": required_role,
|
||||
"active_role_kind": active_role,
|
||||
"reasons": [
|
||||
"reconciler profile cannot use raw gitea_delete_branch; "
|
||||
"use gitea_cleanup_merged_pr_branch for a fully merged PR "
|
||||
"source branch only (fail closed)"
|
||||
],
|
||||
"exact_next_action": (
|
||||
"Call gitea_cleanup_merged_pr_branch with pr_number, the "
|
||||
"exact PR head branch, and confirmation "
|
||||
"'CLEANUP MERGED PR <n> BRANCH <branch>' after capability "
|
||||
"resolve for cleanup_merged_pr_branch."
|
||||
),
|
||||
"permission_report": _permission_block_report("gitea.branch.delete"),
|
||||
}
|
||||
if active_role != required_role:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"required_role_kind": required_role,
|
||||
"active_role_kind": active_role,
|
||||
"reasons": [
|
||||
f"Active profile role '{active_role}' cannot perform "
|
||||
f"{required_role} task 'delete_branch' even when "
|
||||
"gitea.branch.delete is present (fail closed)"
|
||||
],
|
||||
"permission_report": _permission_block_report("gitea.branch.delete"),
|
||||
}
|
||||
|
||||
if branch_cleanup_guard.is_preservation_or_evidence_branch(branch):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"reasons": [
|
||||
f"branch '{branch}' is a preservation/evidence branch and "
|
||||
"cannot be deleted (fail closed)"
|
||||
],
|
||||
}
|
||||
if branch in branch_cleanup_guard.PROTECTED_BRANCHES:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"reasons": [f"branch '{branch}' is protected (fail closed)"],
|
||||
}
|
||||
|
||||
audit_allowed, audit_reasons = (
|
||||
audit_reconciliation_mode.check_audit_mutation_allowed("delete_branch")
|
||||
)
|
||||
@@ -7617,41 +7731,49 @@ def gitea_cleanup_merged_pr_branch(
|
||||
"""Delete a merged PR source branch through the guarded MCP path (#514)."""
|
||||
gate_reasons = _profile_operation_gate("gitea.branch.delete")
|
||||
if gate_reasons:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"reasons": gate_reasons,
|
||||
"permission_report": _permission_block_report("gitea.branch.delete"),
|
||||
}
|
||||
return branch_cleanup_guard.cleanup_result_envelope(
|
||||
success=False,
|
||||
performed=False,
|
||||
delete_acknowledged=False,
|
||||
verified_absent=False,
|
||||
required_permission="gitea.branch.delete",
|
||||
reasons=gate_reasons,
|
||||
permission_report=_permission_block_report("gitea.branch.delete"),
|
||||
)
|
||||
|
||||
profile = get_profile()
|
||||
active_role = _role_kind(
|
||||
profile.get("allowed_operations", []),
|
||||
profile.get("forbidden_operations", []),
|
||||
)
|
||||
if active_role == "reviewer":
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"reasons": [
|
||||
"reviewer profile is not authorized for merged branch cleanup "
|
||||
"(fail closed)"
|
||||
active_role = _profile_role_kind(profile)
|
||||
# cleanup_merged_pr_branch is reconciler-owned (task_capability_map).
|
||||
# Author/reviewer/merger must not reach this path even if they somehow
|
||||
# hold gitea.branch.delete.
|
||||
if active_role != "reconciler":
|
||||
return branch_cleanup_guard.cleanup_result_envelope(
|
||||
success=False,
|
||||
performed=False,
|
||||
delete_acknowledged=False,
|
||||
verified_absent=False,
|
||||
required_permission="gitea.branch.delete",
|
||||
required_role_kind="reconciler",
|
||||
active_role_kind=active_role,
|
||||
reasons=[
|
||||
f"profile role '{active_role}' is not authorized for merged "
|
||||
"branch cleanup; required role is reconciler (fail closed)"
|
||||
],
|
||||
"permission_report": _permission_block_report("gitea.branch.delete"),
|
||||
}
|
||||
permission_report=_permission_block_report("gitea.branch.delete"),
|
||||
)
|
||||
|
||||
if worktree_path is None or "/branches/" not in os.path.realpath(worktree_path):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"reasons": [
|
||||
return branch_cleanup_guard.cleanup_result_envelope(
|
||||
success=False,
|
||||
performed=False,
|
||||
delete_acknowledged=False,
|
||||
verified_absent=False,
|
||||
required_permission="gitea.branch.delete",
|
||||
reasons=[
|
||||
"merged branch cleanup requires an explicit branches/ worktree "
|
||||
"path; root checkout branch ref mutation is blocked (fail closed)"
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
verify_preflight_purity(
|
||||
remote,
|
||||
@@ -7669,16 +7791,18 @@ def gitea_cleanup_merged_pr_branch(
|
||||
target_branch = (pr.get("base") or {}).get("ref") or "master"
|
||||
|
||||
if branch and branch != pr_head.get("ref"):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"pr_number": pr_number,
|
||||
"branch": branch,
|
||||
"reasons": [
|
||||
return branch_cleanup_guard.cleanup_result_envelope(
|
||||
success=False,
|
||||
performed=False,
|
||||
delete_acknowledged=False,
|
||||
verified_absent=False,
|
||||
pr_number=pr_number,
|
||||
branch=branch,
|
||||
reasons=[
|
||||
f"requested branch '{branch}' does not match PR head "
|
||||
f"'{pr_head.get('ref')}'"
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
|
||||
open_heads = {
|
||||
@@ -7708,19 +7832,86 @@ def gitea_cleanup_merged_pr_branch(
|
||||
confirmation=confirmation,
|
||||
)
|
||||
if not assessment["safe_to_delete"]:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"pr_number": pr_number,
|
||||
"branch": head_branch,
|
||||
"assessment": assessment,
|
||||
"reasons": assessment["block_reasons"],
|
||||
}
|
||||
return branch_cleanup_guard.cleanup_result_envelope(
|
||||
success=False,
|
||||
performed=False,
|
||||
delete_acknowledged=False,
|
||||
verified_absent=False,
|
||||
pr_number=pr_number,
|
||||
branch=head_branch,
|
||||
assessment=assessment,
|
||||
reasons=assessment["block_reasons"],
|
||||
)
|
||||
|
||||
# #687: active ownership protection (sessions/leases/worktree bindings).
|
||||
# Do not rely only on the caller worktree living under branches/.
|
||||
ownership_bundle = _collect_branch_ownership_records(
|
||||
remote=remote,
|
||||
host=h,
|
||||
org=o,
|
||||
repo=r,
|
||||
branch=head_branch,
|
||||
pr_number=pr_number,
|
||||
project_root=PROJECT_ROOT,
|
||||
auth=auth,
|
||||
base_api=base,
|
||||
)
|
||||
ownership_records = ownership_bundle.get("records") or []
|
||||
if ownership_bundle.get("inventory_error"):
|
||||
# O1: control-plane / ownership inventory failure fails closed.
|
||||
ownership_records = list(ownership_records) + [
|
||||
{
|
||||
"category": branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR,
|
||||
"status": "unknown",
|
||||
"remote": remote,
|
||||
"host": h,
|
||||
"org": o,
|
||||
"repo": r,
|
||||
"branch": head_branch,
|
||||
"reclaim_allowed": False,
|
||||
"role": "inventory",
|
||||
}
|
||||
]
|
||||
ownership = branch_cleanup_guard.assess_active_branch_ownership(
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
branch=head_branch,
|
||||
host=h,
|
||||
records=ownership_records,
|
||||
)
|
||||
if ownership.get("block"):
|
||||
return branch_cleanup_guard.cleanup_result_envelope(
|
||||
success=False,
|
||||
performed=False,
|
||||
delete_acknowledged=False,
|
||||
verified_absent=False,
|
||||
pr_number=pr_number,
|
||||
branch=head_branch,
|
||||
assessment=assessment,
|
||||
ownership={
|
||||
"block": True,
|
||||
"blocking_categories": ownership.get("blocking_categories") or [],
|
||||
"reasons": ownership.get("reasons") or [],
|
||||
"blocker_kind": ownership.get("blocker_kind"),
|
||||
"checked": True,
|
||||
},
|
||||
reasons=ownership.get("reasons")
|
||||
or ["active ownership protects the target branch"],
|
||||
blocker_kind="active_branch_ownership",
|
||||
)
|
||||
|
||||
import urllib.parse
|
||||
|
||||
encoded_branch = urllib.parse.quote(head_branch, safe="")
|
||||
url = f"{base}/branches/{encoded_branch}"
|
||||
request_metadata = {
|
||||
"branch": head_branch,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"cleanup_path": "gitea_cleanup_merged_pr_branch",
|
||||
"ownership_checked": True,
|
||||
"ownership_blocking_categories": [],
|
||||
}
|
||||
with _audited(
|
||||
"cleanup_merged_pr_branch",
|
||||
host=h,
|
||||
@@ -7729,36 +7920,416 @@ def gitea_cleanup_merged_pr_branch(
|
||||
repo=r,
|
||||
pr_number=pr_number,
|
||||
target_branch=head_branch,
|
||||
request_metadata={
|
||||
"branch": head_branch,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"cleanup_path": "gitea_cleanup_merged_pr_branch",
|
||||
},
|
||||
request_metadata=request_metadata,
|
||||
):
|
||||
api_request("DELETE", url, auth)
|
||||
return {
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"pr_number": pr_number,
|
||||
"branch": head_branch,
|
||||
"message": f"Merged PR #{pr_number} source branch '{head_branch}' deleted.",
|
||||
"assessment": assessment,
|
||||
|
||||
# #687: authoritative post-delete readback. DELETE success alone is not
|
||||
# enough — only branch-scoped not-found proves deletion (R1).
|
||||
readback = _probe_remote_branch(h, o, r, auth, head_branch)
|
||||
readback_assessment = branch_cleanup_guard.assess_post_delete_readback(readback)
|
||||
verified_absent = bool(readback_assessment.get("verified_absent"))
|
||||
request_metadata["post_delete_readback"] = {
|
||||
"status": (readback_assessment.get("readback") or {}).get("status"),
|
||||
"verified_absent": verified_absent,
|
||||
"error_class": (readback_assessment.get("readback") or {}).get(
|
||||
"error_class"
|
||||
),
|
||||
"not_found_scope": (readback_assessment.get("readback") or {}).get(
|
||||
"not_found_scope"
|
||||
),
|
||||
}
|
||||
if gitea_audit.audit_enabled():
|
||||
_audit(
|
||||
"cleanup_merged_pr_branch_readback",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
result=(
|
||||
gitea_audit.SUCCEEDED
|
||||
if readback_assessment.get("ok")
|
||||
else gitea_audit.FAILED
|
||||
),
|
||||
reason="; ".join(readback_assessment.get("reasons") or []) or None,
|
||||
request_metadata=request_metadata,
|
||||
pr_number=pr_number,
|
||||
target_branch=head_branch,
|
||||
)
|
||||
|
||||
if not readback_assessment.get("ok"):
|
||||
return branch_cleanup_guard.cleanup_result_envelope(
|
||||
success=False,
|
||||
performed=True,
|
||||
delete_acknowledged=True,
|
||||
verified_absent=False,
|
||||
pr_number=pr_number,
|
||||
branch=head_branch,
|
||||
assessment=assessment,
|
||||
ownership={
|
||||
"block": False,
|
||||
"blocking_categories": [],
|
||||
"checked": True,
|
||||
},
|
||||
readback=readback_assessment.get("readback"),
|
||||
reasons=readback_assessment.get("reasons")
|
||||
or ["post-delete branch readback could not verify deletion"],
|
||||
blocker_kind=readback_assessment.get("blocker_kind")
|
||||
or "post_delete_readback_failed",
|
||||
message=(
|
||||
f"DELETE accepted for '{head_branch}' but post-delete readback "
|
||||
"did not verify absence"
|
||||
),
|
||||
)
|
||||
|
||||
return branch_cleanup_guard.cleanup_result_envelope(
|
||||
success=True,
|
||||
performed=True,
|
||||
delete_acknowledged=True,
|
||||
verified_absent=True,
|
||||
pr_number=pr_number,
|
||||
branch=head_branch,
|
||||
message=(
|
||||
f"Merged PR #{pr_number} source branch '{head_branch}' deleted "
|
||||
"and verified absent via branch-scoped post-delete readback."
|
||||
),
|
||||
assessment=assessment,
|
||||
ownership={
|
||||
"block": False,
|
||||
"blocking_categories": [],
|
||||
"checked": True,
|
||||
},
|
||||
readback=readback_assessment.get("readback"),
|
||||
)
|
||||
|
||||
|
||||
def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> bool:
|
||||
def _probe_remote_branch(
|
||||
h: str, o: str, r: str, auth: str, branch: str
|
||||
) -> dict:
|
||||
"""GET a remote branch and return a secret-free structured readback.
|
||||
|
||||
R1: On HTTP 404, re-probe the repository endpoint. Only when the repo is
|
||||
still reachable is the 404 treated as branch-scoped absence. Generic,
|
||||
repository-scoped, or host-level 404 never sets verified_absent.
|
||||
"""
|
||||
import urllib.parse
|
||||
|
||||
encoded = urllib.parse.quote(branch, safe="")
|
||||
url = f"{repo_api_url(h, o, r)}/branches/{encoded}"
|
||||
try:
|
||||
api_request("GET", url, auth)
|
||||
return True
|
||||
return branch_cleanup_guard.classify_branch_readback_http_status(200)
|
||||
except Exception as exc:
|
||||
message = str(exc).lower()
|
||||
if "404" in message or "not found" in message:
|
||||
return False
|
||||
raise
|
||||
status = branch_cleanup_guard._extract_http_status(exc)
|
||||
if status != 404:
|
||||
return branch_cleanup_guard.classify_branch_readback_exception(exc)
|
||||
|
||||
# Distinguish branch vs repository/host 404 via repo reachability.
|
||||
repo_url = repo_api_url(h, o, r)
|
||||
try:
|
||||
api_request("GET", repo_url, auth)
|
||||
# Repo reachable → branch-scoped not-found.
|
||||
return branch_cleanup_guard.classify_branch_readback_http_status(
|
||||
404,
|
||||
not_found_scope=branch_cleanup_guard.NOT_FOUND_SCOPE_BRANCH,
|
||||
)
|
||||
except Exception as repo_exc:
|
||||
repo_status = branch_cleanup_guard._extract_http_status(repo_exc)
|
||||
if repo_status == 404:
|
||||
return branch_cleanup_guard.classify_branch_readback_http_status(
|
||||
404,
|
||||
not_found_scope=branch_cleanup_guard.NOT_FOUND_SCOPE_REPOSITORY,
|
||||
)
|
||||
if repo_status in (401, 407):
|
||||
return branch_cleanup_guard.classify_branch_readback_http_status(
|
||||
401
|
||||
)
|
||||
if repo_status == 403:
|
||||
return branch_cleanup_guard.classify_branch_readback_http_status(
|
||||
403
|
||||
)
|
||||
# Host/transport/unknown: not branch-verified.
|
||||
return branch_cleanup_guard.classify_branch_readback_http_status(
|
||||
404,
|
||||
not_found_scope=branch_cleanup_guard.NOT_FOUND_SCOPE_UNKNOWN,
|
||||
)
|
||||
|
||||
|
||||
def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> bool:
|
||||
"""True when the remote branch exists; False on authoritative branch 404.
|
||||
|
||||
Authentication, authorization, transport, and ambiguous 404 failures are
|
||||
raised rather than treated as absence.
|
||||
"""
|
||||
probe = _probe_remote_branch(h, o, r, auth, branch)
|
||||
status = probe.get("status")
|
||||
if (
|
||||
status == branch_cleanup_guard.READBACK_NOT_FOUND
|
||||
and probe.get("verified_absent")
|
||||
):
|
||||
return False
|
||||
if status == branch_cleanup_guard.READBACK_EXISTS:
|
||||
return True
|
||||
error_class = probe.get("error_class") or "unexpected"
|
||||
raise RuntimeError(
|
||||
f"remote branch probe failed ({error_class}/{status})"
|
||||
)
|
||||
|
||||
|
||||
def _collect_branch_ownership_records(
|
||||
*,
|
||||
remote: str,
|
||||
host: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
branch: str,
|
||||
pr_number: int | None,
|
||||
project_root: str,
|
||||
auth: str | None = None,
|
||||
base_api: str | None = None,
|
||||
) -> dict:
|
||||
"""Gather canonical ownership records for *branch* (secret-free).
|
||||
|
||||
Sources:
|
||||
- author issue locks (task-session / lease files)
|
||||
- control-plane leases (author/reviewer/merger/controller/reconciler)
|
||||
- comment-backed active reviewer leases (O3)
|
||||
- local worktree bindings checked out to the branch
|
||||
|
||||
Returns ``{"records": [...], "inventory_error": bool}``. Control-plane
|
||||
inventory errors set inventory_error=True so callers fail closed (O1).
|
||||
"""
|
||||
records: list[dict] = []
|
||||
inventory_error = False
|
||||
target_branch = (branch or "").strip()
|
||||
if not target_branch:
|
||||
return {"records": records, "inventory_error": False}
|
||||
|
||||
host_n = branch_cleanup_guard.normalize_host(host)
|
||||
|
||||
def _base_rec(**kwargs):
|
||||
rec = {
|
||||
"remote": remote,
|
||||
"host": host_n or host,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"branch": target_branch,
|
||||
}
|
||||
rec.update(kwargs)
|
||||
return rec
|
||||
|
||||
# --- Author issue locks (session + lease) ---
|
||||
try:
|
||||
for path in issue_lock_store.iter_lock_files():
|
||||
lock = issue_lock_store.read_lock_file(path)
|
||||
if not isinstance(lock, dict):
|
||||
continue
|
||||
if (
|
||||
str(lock.get("remote") or "") != str(remote)
|
||||
or str(lock.get("org") or "") != str(org)
|
||||
or str(lock.get("repo") or "") != str(repo)
|
||||
):
|
||||
continue
|
||||
# Host identity when present on the lock
|
||||
lock_host = branch_cleanup_guard.normalize_host(
|
||||
lock.get("host") or lock.get("host_name")
|
||||
)
|
||||
if host_n and lock_host and host_n != lock_host:
|
||||
continue
|
||||
if str(lock.get("branch_name") or "").strip() != target_branch:
|
||||
continue
|
||||
freshness = issue_lock_store.assess_lock_freshness(lock)
|
||||
reclaim = issue_lock_store.assess_expired_lock_reclaim(lock)
|
||||
status = str(freshness.get("status") or "unknown")
|
||||
if freshness.get("live"):
|
||||
status = "active"
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||
status=status,
|
||||
reclaim_allowed=bool(reclaim.get("reclaim_allowed")),
|
||||
role="author",
|
||||
)
|
||||
)
|
||||
if freshness.get("live"):
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=(
|
||||
branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_SESSION
|
||||
),
|
||||
status="active",
|
||||
reclaim_allowed=False,
|
||||
role="author",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
inventory_error = True
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||
status="unknown",
|
||||
reclaim_allowed=False,
|
||||
role="author",
|
||||
)
|
||||
)
|
||||
|
||||
# --- Control-plane leases (role-tagged) ---
|
||||
try:
|
||||
db = None
|
||||
if hasattr(control_plane_db, "get_db"):
|
||||
try:
|
||||
db = control_plane_db.get_db()
|
||||
except Exception:
|
||||
db = None
|
||||
if db is None and hasattr(control_plane_db, "ControlPlaneDB"):
|
||||
try:
|
||||
db = control_plane_db.ControlPlaneDB()
|
||||
except Exception:
|
||||
db = None
|
||||
inventory_error = True
|
||||
if db is None:
|
||||
# O1: unavailable control-plane inventory fails closed.
|
||||
inventory_error = True
|
||||
else:
|
||||
listed = lease_lifecycle.list_active_leases(
|
||||
db,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
include_non_active=True,
|
||||
limit=200,
|
||||
)
|
||||
for lease in listed.get("leases") or []:
|
||||
work_kind = str(lease.get("work_kind") or "")
|
||||
work_number = lease.get("work_number")
|
||||
lease_branch = str(
|
||||
lease.get("branch")
|
||||
or lease.get("branch_name")
|
||||
or ""
|
||||
).strip()
|
||||
matches_branch = lease_branch == target_branch
|
||||
matches_pr = (
|
||||
work_kind == "pr"
|
||||
and pr_number is not None
|
||||
and int(work_number or 0) == int(pr_number)
|
||||
)
|
||||
if not (matches_branch or matches_pr):
|
||||
continue
|
||||
if matches_pr and not lease_branch:
|
||||
lease_branch = target_branch
|
||||
if lease_branch != target_branch:
|
||||
continue
|
||||
lease_host = branch_cleanup_guard.normalize_host(
|
||||
lease.get("host") or lease.get("host_name")
|
||||
)
|
||||
if host_n and lease_host and host_n != lease_host:
|
||||
continue
|
||||
fr = lease.get("freshness") or lease_lifecycle.classify_lease_freshness(
|
||||
lease
|
||||
)
|
||||
freshness_status = str(
|
||||
(fr.get("freshness") if isinstance(fr, dict) else None)
|
||||
or lease.get("status")
|
||||
or "unknown"
|
||||
)
|
||||
role = str(lease.get("role") or "unknown")
|
||||
category = branch_cleanup_guard.ownership_category_for_role(role)
|
||||
# O2: expired never auto-receives reclaim_allowed=True.
|
||||
if freshness_status == "active":
|
||||
status = "active"
|
||||
reclaim_allowed = False
|
||||
elif freshness_status in {"released", "abandoned"}:
|
||||
status = freshness_status
|
||||
reclaim_allowed = True
|
||||
elif freshness_status == "expired" or (
|
||||
isinstance(fr, dict) and fr.get("expired_by_time")
|
||||
):
|
||||
status = "expired"
|
||||
reclaim_allowed = False # O2 fail closed
|
||||
else:
|
||||
status = freshness_status
|
||||
reclaim_allowed = False
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=category,
|
||||
status=status,
|
||||
reclaim_allowed=reclaim_allowed,
|
||||
role=role,
|
||||
host=lease_host or host_n or host,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# O1: fail closed on control-plane inventory errors.
|
||||
inventory_error = True
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR,
|
||||
status="unknown",
|
||||
reclaim_allowed=False,
|
||||
role="control_plane",
|
||||
)
|
||||
)
|
||||
|
||||
# --- O3: comment-backed active reviewer leases ---
|
||||
if pr_number is not None and auth and base_api:
|
||||
try:
|
||||
comments = api_get_all(
|
||||
f"{base_api}/issues/{int(pr_number)}/comments", auth
|
||||
)
|
||||
active = reviewer_pr_lease.find_active_reviewer_lease(
|
||||
comments, pr_number=int(pr_number)
|
||||
)
|
||||
if active:
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=(
|
||||
branch_cleanup_guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE
|
||||
),
|
||||
status="active",
|
||||
reclaim_allowed=False,
|
||||
role="reviewer",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
inventory_error = True
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR,
|
||||
status="unknown",
|
||||
reclaim_allowed=False,
|
||||
role="reviewer_comment_lease",
|
||||
)
|
||||
)
|
||||
|
||||
# --- Worktree bindings checked out to the target branch ---
|
||||
try:
|
||||
for entry in worktree_cleanup_audit.list_worktrees(project_root):
|
||||
wt_branch = str(entry.get("branch") or "").strip()
|
||||
if wt_branch != target_branch:
|
||||
continue
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=(
|
||||
branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING
|
||||
),
|
||||
status="active",
|
||||
reclaim_allowed=False,
|
||||
role="worktree",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
inventory_error = True
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING,
|
||||
status="unknown",
|
||||
reclaim_allowed=False,
|
||||
role="worktree",
|
||||
)
|
||||
)
|
||||
|
||||
return {"records": records, "inventory_error": inventory_error}
|
||||
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -7945,6 +8516,66 @@ def gitea_reconcile_merged_cleanups(
|
||||
if remote_assessment.get("safe_to_delete_remote"):
|
||||
import urllib.parse
|
||||
|
||||
pr_num = entry.get("pr_number")
|
||||
try:
|
||||
pr_num_int = int(pr_num) if pr_num is not None else None
|
||||
except (TypeError, ValueError):
|
||||
pr_num_int = None
|
||||
ownership_bundle = _collect_branch_ownership_records(
|
||||
remote=remote,
|
||||
host=h,
|
||||
org=o,
|
||||
repo=r,
|
||||
branch=head_branch,
|
||||
pr_number=pr_num_int,
|
||||
project_root=PROJECT_ROOT,
|
||||
auth=auth,
|
||||
base_api=base,
|
||||
)
|
||||
ownership_records = list(ownership_bundle.get("records") or [])
|
||||
if ownership_bundle.get("inventory_error"):
|
||||
ownership_records.append(
|
||||
{
|
||||
"category": (
|
||||
branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR
|
||||
),
|
||||
"status": "unknown",
|
||||
"remote": remote,
|
||||
"host": h,
|
||||
"org": o,
|
||||
"repo": r,
|
||||
"branch": head_branch,
|
||||
"reclaim_allowed": False,
|
||||
"role": "inventory",
|
||||
}
|
||||
)
|
||||
ownership = branch_cleanup_guard.assess_active_branch_ownership(
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
branch=head_branch,
|
||||
host=h,
|
||||
records=ownership_records,
|
||||
)
|
||||
if ownership.get("block"):
|
||||
actions.append(
|
||||
{
|
||||
"action": "delete_remote_branch",
|
||||
"branch": head_branch,
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"delete_acknowledged": False,
|
||||
"verified_absent": False,
|
||||
"blocker_kind": "active_branch_ownership",
|
||||
"reasons": ownership.get("reasons") or [],
|
||||
"blocking_categories": ownership.get(
|
||||
"blocking_categories"
|
||||
)
|
||||
or [],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
encoded = urllib.parse.quote(head_branch, safe="")
|
||||
url = f"{base}/branches/{encoded}"
|
||||
with _audited(
|
||||
@@ -7954,14 +8585,28 @@ def gitea_reconcile_merged_cleanups(
|
||||
org=o,
|
||||
repo=r,
|
||||
target_branch=head_branch,
|
||||
request_metadata={"branch": head_branch, "source": "reconcile_merged_cleanups"},
|
||||
request_metadata={
|
||||
"branch": head_branch,
|
||||
"source": "reconcile_merged_cleanups",
|
||||
"ownership_checked": True,
|
||||
},
|
||||
):
|
||||
api_request("DELETE", url, auth)
|
||||
readback = _probe_remote_branch(h, o, r, auth, head_branch)
|
||||
readback_assessment = branch_cleanup_guard.assess_post_delete_readback(
|
||||
readback
|
||||
)
|
||||
verified = bool(readback_assessment.get("verified_absent"))
|
||||
actions.append(
|
||||
{
|
||||
"action": "delete_remote_branch",
|
||||
"branch": head_branch,
|
||||
"success": True,
|
||||
"success": bool(readback_assessment.get("ok")),
|
||||
"performed": True,
|
||||
"delete_acknowledged": True,
|
||||
"verified_absent": verified,
|
||||
"readback": readback_assessment.get("readback"),
|
||||
"reasons": readback_assessment.get("reasons") or [],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -13191,6 +13836,8 @@ def gitea_resolve_task_capability(
|
||||
"gitea_commit_files",
|
||||
"address_pr_change_requests",
|
||||
"delete_branch",
|
||||
"cleanup_merged_pr_branch",
|
||||
"reconciliation_cleanup",
|
||||
"work_issue",
|
||||
"work-issue",
|
||||
}
|
||||
|
||||
+125
-3
@@ -46,8 +46,15 @@ KIND_IRRECOVERABLE_DECISION_PROVENANCE = "irrecoverable_decision_provenance"
|
||||
KIND_IRRECOVERABLE_PROVENANCE_AUTH = "irrecoverable_provenance_authorization"
|
||||
|
||||
# Kinds that must survive the default session-state TTL (forensic / recovery).
|
||||
#
|
||||
# KIND_DECISION_LOCK is recovery-critical (#720): terminal review provenance is
|
||||
# not disposable cache. A generic four-hour TTL must not drop old-head evidence
|
||||
# or make ``fresh_review_on_current_head_allowed`` unreachable. Same-head / same-
|
||||
# run #332 protections still apply once the ledger is loadable. Other session
|
||||
# kinds (workflow load, drafts, etc.) remain TTL-bound.
|
||||
RECOVERY_CRITICAL_KINDS = frozenset(
|
||||
{
|
||||
KIND_DECISION_LOCK,
|
||||
KIND_DECISION_LOCK_ARCHIVE,
|
||||
KIND_POST_MERGE_DECISION_RECOVERY,
|
||||
KIND_IRRECOVERABLE_DECISION_PROVENANCE,
|
||||
@@ -245,6 +252,16 @@ def _write_json(path: str, data: dict[str, Any]) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def is_recovery_critical_record(record: dict[str, Any] | None, kind: str | None = None) -> bool:
|
||||
"""True when a durable record must outlive the generic session-state TTL."""
|
||||
if not record and not kind:
|
||||
return False
|
||||
record_kind = ((record or {}).get("kind") or kind or "").strip()
|
||||
if record_kind in RECOVERY_CRITICAL_KINDS:
|
||||
return True
|
||||
return bool((record or {}).get("recovery_critical"))
|
||||
|
||||
|
||||
def identity_match_reasons(
|
||||
record: dict[str, Any] | None,
|
||||
*,
|
||||
@@ -288,9 +305,7 @@ def identity_match_reasons(
|
||||
else:
|
||||
age = _now_utc() - recorded_at
|
||||
kind = (record.get("kind") or "").strip()
|
||||
ttl_exempt = kind in RECOVERY_CRITICAL_KINDS or bool(
|
||||
record.get("recovery_critical")
|
||||
)
|
||||
ttl_exempt = is_recovery_critical_record(record, kind=kind)
|
||||
if age > timedelta(hours=ttl_hours()) and not ttl_exempt:
|
||||
reasons.append(
|
||||
f"session state expired after {ttl_hours():g}h (fail closed)"
|
||||
@@ -300,6 +315,113 @@ def identity_match_reasons(
|
||||
return reasons
|
||||
|
||||
|
||||
def inspect_state_envelope(
|
||||
*,
|
||||
kind: str,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Read-only disk inspection for assessment when TTL would otherwise hide state (#720).
|
||||
|
||||
Does **not** apply identity/TTL rejection to the returned presence flags.
|
||||
Callers use this to distinguish:
|
||||
* no file on disk
|
||||
* file present but TTL would reject a non-critical kind
|
||||
* recovery-critical ledger (e.g. KIND_DECISION_LOCK) still loadable
|
||||
Never mutates files. Never returns secrets.
|
||||
"""
|
||||
profile = current_profile_identity(profile_identity=profile_identity)
|
||||
path = state_file_path(
|
||||
kind=kind,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile,
|
||||
state_dir=state_dir,
|
||||
)
|
||||
result: dict[str, Any] = {
|
||||
"kind": kind,
|
||||
"profile_identity": profile,
|
||||
"path_basename": os.path.basename(path),
|
||||
"on_disk": False,
|
||||
"has_payload": False,
|
||||
"recorded_at": None,
|
||||
"updated_at": None,
|
||||
"age_hours": None,
|
||||
"ttl_hours": ttl_hours(),
|
||||
"age_exceeds_default_ttl": False,
|
||||
"recovery_critical": kind in RECOVERY_CRITICAL_KINDS,
|
||||
"ttl_exempt": kind in RECOVERY_CRITICAL_KINDS,
|
||||
"would_ttl_reject": False,
|
||||
"identity_reasons": [],
|
||||
"summary": "no session-state file on disk",
|
||||
}
|
||||
if not path or not os.path.exists(path):
|
||||
return result
|
||||
result["on_disk"] = True
|
||||
envelope = _read_json(path)
|
||||
if not envelope:
|
||||
result["summary"] = "session-state file present but unreadable or empty"
|
||||
return result
|
||||
payload = envelope.get("payload")
|
||||
merged: dict[str, Any] = dict(payload) if isinstance(payload, dict) else {}
|
||||
result["has_payload"] = isinstance(payload, dict)
|
||||
for key in (
|
||||
"kind",
|
||||
"remote",
|
||||
"org",
|
||||
"repo",
|
||||
"profile_identity",
|
||||
"session_profile_lock",
|
||||
"recorded_at",
|
||||
"updated_at",
|
||||
"writer_pid",
|
||||
"recovery_critical",
|
||||
):
|
||||
if key in envelope and key not in merged:
|
||||
merged[key] = envelope[key]
|
||||
if not merged.get("kind"):
|
||||
merged["kind"] = kind
|
||||
recorded_at = _parse_iso(merged.get("recorded_at") or merged.get("updated_at"))
|
||||
result["recorded_at"] = merged.get("recorded_at") or merged.get("updated_at")
|
||||
result["updated_at"] = merged.get("updated_at") or merged.get("recorded_at")
|
||||
if recorded_at is not None:
|
||||
age = _now_utc() - recorded_at
|
||||
age_hours = age.total_seconds() / 3600.0
|
||||
result["age_hours"] = age_hours
|
||||
result["age_exceeds_default_ttl"] = age > timedelta(hours=ttl_hours())
|
||||
ttl_exempt = is_recovery_critical_record(merged, kind=kind)
|
||||
result["recovery_critical"] = ttl_exempt
|
||||
result["ttl_exempt"] = ttl_exempt
|
||||
identity_reasons = identity_match_reasons(
|
||||
merged,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile,
|
||||
)
|
||||
result["identity_reasons"] = list(identity_reasons)
|
||||
result["would_ttl_reject"] = any("expired" in r for r in identity_reasons)
|
||||
if result["has_payload"] and ttl_exempt:
|
||||
result["summary"] = (
|
||||
"recovery-critical session-state present on disk and TTL-exempt; "
|
||||
"load via load_state for full payload"
|
||||
)
|
||||
elif result["has_payload"] and result["would_ttl_reject"]:
|
||||
result["summary"] = (
|
||||
"session-state file present on disk but generic TTL would reject load "
|
||||
f"(age_hours={result.get('age_hours')!r}, ttl={ttl_hours():g}h)"
|
||||
)
|
||||
elif result["has_payload"]:
|
||||
result["summary"] = "session-state file present and within TTL / identity gates"
|
||||
else:
|
||||
result["summary"] = "session-state file present without a dict payload"
|
||||
return result
|
||||
|
||||
|
||||
def load_state(
|
||||
*,
|
||||
kind: str,
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
"""MCP tool-boundary error mapping for known Gitea client failures (#699).
|
||||
|
||||
Known authentication / authorization / network / configuration failures leave
|
||||
the tool boundary as a sanitized structured ``CallToolResult`` with
|
||||
``isError=True``. Stdio transport remains connected.
|
||||
|
||||
Design constraints (reviewer-ratified, #699 / PR #701):
|
||||
|
||||
1. **No secret material** in tool results or daemon logs. Messages are fixed
|
||||
constants keyed by ``reason_code``; HTTP bodies / exception text never
|
||||
surface. Sanitization failure fails closed to ``internal_error``.
|
||||
2. **Narrow boundary** wraps the original FastMCP ``Tool.run`` success path;
|
||||
re-raises framework control-flow exceptions (``UrlElicitationRequiredError``);
|
||||
maps only typed client failures. Installation is idempotent.
|
||||
3. **No RuntimeError substring heuristics.** Only explicit typed
|
||||
authentication / authorization / network / configuration exceptions
|
||||
receive those labels.
|
||||
4. **HTTP classification** lives in ``gitea_auth.classify_http_status``;
|
||||
every 403 is authorization-class.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("gitea_mcp.tool_error_boundary")
|
||||
|
||||
# Stable reason codes (#699).
|
||||
REASON_AUTH_FAILED = "auth_failed"
|
||||
REASON_AUTH_INVALID_TOKEN = "auth_invalid_token"
|
||||
REASON_AUTHZ_INSUFFICIENT_SCOPE = "authz_insufficient_scope"
|
||||
REASON_AUTHZ_DENIED = "authz_denied"
|
||||
REASON_NETWORK_ERROR = "network_error"
|
||||
REASON_CONFIG_ERROR = "config_error"
|
||||
REASON_INTERNAL_ERROR = "internal_error"
|
||||
REASON_UPSTREAM_UNAVAILABLE = "upstream_unavailable"
|
||||
REASON_HTTP_ERROR = "http_error"
|
||||
|
||||
ERROR_CLASS_AUTHENTICATION = "authentication"
|
||||
ERROR_CLASS_AUTHORIZATION = "authorization"
|
||||
ERROR_CLASS_NETWORK = "network"
|
||||
ERROR_CLASS_CONFIGURATION = "configuration"
|
||||
ERROR_CLASS_INTERNAL = "internal"
|
||||
ERROR_CLASS_UPSTREAM = "upstream"
|
||||
|
||||
# Fixed, secret-free operator messages. Never interpolate HTTP bodies,
|
||||
# Keychain contents, tokens, or arbitrary exception text.
|
||||
FIXED_MESSAGES: dict[str, str] = {
|
||||
REASON_AUTH_FAILED: "Gitea authentication failed",
|
||||
REASON_AUTH_INVALID_TOKEN: (
|
||||
"Gitea authentication failed: invalid or revoked credentials"
|
||||
),
|
||||
REASON_AUTHZ_INSUFFICIENT_SCOPE: (
|
||||
"Gitea authorization failed: insufficient token scope"
|
||||
),
|
||||
REASON_AUTHZ_DENIED: "Gitea authorization failed: access denied",
|
||||
REASON_NETWORK_ERROR: "Network error contacting Gitea",
|
||||
REASON_CONFIG_ERROR: "Gitea configuration or credential resolution failed",
|
||||
REASON_INTERNAL_ERROR: "Internal tool error",
|
||||
REASON_UPSTREAM_UNAVAILABLE: "Gitea upstream unavailable",
|
||||
REASON_HTTP_ERROR: "Gitea HTTP request failed",
|
||||
}
|
||||
|
||||
_INSTALL_FLAG = "_gitea_auth_boundary_installed"
|
||||
_ORIGINAL_ATTR = "_gitea_auth_boundary_original"
|
||||
|
||||
|
||||
def fixed_message(reason_code: str) -> str:
|
||||
"""Return the fixed sanitized message for *reason_code* (fail closed)."""
|
||||
return FIXED_MESSAGES.get(reason_code, FIXED_MESSAGES[REASON_INTERNAL_ERROR])
|
||||
|
||||
|
||||
def _safe_profile_name() -> str | None:
|
||||
try:
|
||||
from gitea_auth import get_profile
|
||||
|
||||
name = (get_profile() or {}).get("profile_name")
|
||||
if name is None:
|
||||
return None
|
||||
text = str(name).strip()
|
||||
# Profile names are non-secret identifiers; still bound length.
|
||||
return text[:80] if text else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _is_framework_control_flow(exc: BaseException) -> bool:
|
||||
"""True for exceptions the MCP framework must re-raise unchanged."""
|
||||
try:
|
||||
from mcp.shared.exceptions import UrlElicitationRequiredError
|
||||
|
||||
if isinstance(exc, UrlElicitationRequiredError):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
# BaseException subclasses that must never become tool isError payloads.
|
||||
if isinstance(exc, (KeyboardInterrupt, SystemExit, GeneratorExit)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_known_client_failure(exc: BaseException) -> bool:
|
||||
"""True only for explicit typed Gitea client / config failures.
|
||||
|
||||
Never true for arbitrary ``RuntimeError`` (reviewer finding #3).
|
||||
"""
|
||||
try:
|
||||
import gitea_auth
|
||||
|
||||
if isinstance(
|
||||
exc,
|
||||
(
|
||||
gitea_auth.GiteaAuthError,
|
||||
gitea_auth.GiteaAuthzError,
|
||||
gitea_auth.GiteaNetworkError,
|
||||
gitea_auth.GiteaConfigError,
|
||||
gitea_auth.GiteaHttpError,
|
||||
),
|
||||
):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
import gitea_config
|
||||
|
||||
if isinstance(exc, gitea_config.ConfigError):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def classify_exception(exc: BaseException) -> dict[str, Any]:
|
||||
"""Return structured classification with **fixed** messages only.
|
||||
|
||||
Only typed authentication / authorization / network / configuration
|
||||
failures receive those labels. Unexpected programming failures are
|
||||
``internal_error``. HTTP bodies and exception text are never copied
|
||||
into ``message``.
|
||||
"""
|
||||
try:
|
||||
return _classify_exception_impl(exc)
|
||||
except Exception:
|
||||
# Fail closed: sanitization / classification failure must not leak.
|
||||
return {
|
||||
"reason_code": REASON_INTERNAL_ERROR,
|
||||
"error_class": ERROR_CLASS_INTERNAL,
|
||||
"http_status": None,
|
||||
"message": fixed_message(REASON_INTERNAL_ERROR),
|
||||
"transport_survives": True,
|
||||
}
|
||||
|
||||
|
||||
def _classify_exception_impl(exc: BaseException) -> dict[str, Any]:
|
||||
import gitea_auth
|
||||
|
||||
if isinstance(exc, gitea_auth.GiteaAuthError):
|
||||
code = getattr(exc, "reason_code", None) or REASON_AUTH_INVALID_TOKEN
|
||||
if code not in (
|
||||
REASON_AUTH_FAILED,
|
||||
REASON_AUTH_INVALID_TOKEN,
|
||||
):
|
||||
code = REASON_AUTH_INVALID_TOKEN
|
||||
return {
|
||||
"reason_code": code,
|
||||
"error_class": ERROR_CLASS_AUTHENTICATION,
|
||||
"http_status": getattr(exc, "http_status", None) or 401,
|
||||
"message": fixed_message(code),
|
||||
"transport_survives": True,
|
||||
}
|
||||
if isinstance(exc, gitea_auth.GiteaAuthzError):
|
||||
code = getattr(exc, "reason_code", None) or REASON_AUTHZ_DENIED
|
||||
if code not in (REASON_AUTHZ_DENIED, REASON_AUTHZ_INSUFFICIENT_SCOPE):
|
||||
code = REASON_AUTHZ_DENIED
|
||||
return {
|
||||
"reason_code": code,
|
||||
"error_class": ERROR_CLASS_AUTHORIZATION,
|
||||
"http_status": getattr(exc, "http_status", None) or 403,
|
||||
"message": fixed_message(code),
|
||||
"transport_survives": True,
|
||||
}
|
||||
if isinstance(exc, gitea_auth.GiteaNetworkError):
|
||||
return {
|
||||
"reason_code": REASON_NETWORK_ERROR,
|
||||
"error_class": ERROR_CLASS_NETWORK,
|
||||
"http_status": getattr(exc, "http_status", None),
|
||||
"message": fixed_message(REASON_NETWORK_ERROR),
|
||||
"transport_survives": True,
|
||||
}
|
||||
if isinstance(exc, gitea_auth.GiteaConfigError):
|
||||
return {
|
||||
"reason_code": REASON_CONFIG_ERROR,
|
||||
"error_class": ERROR_CLASS_CONFIGURATION,
|
||||
"http_status": getattr(exc, "http_status", None),
|
||||
"message": fixed_message(REASON_CONFIG_ERROR),
|
||||
"transport_survives": True,
|
||||
}
|
||||
if isinstance(exc, gitea_auth.GiteaHttpError):
|
||||
code = getattr(exc, "reason_code", None) or REASON_HTTP_ERROR
|
||||
if code == REASON_UPSTREAM_UNAVAILABLE:
|
||||
error_class = ERROR_CLASS_UPSTREAM
|
||||
else:
|
||||
error_class = ERROR_CLASS_INTERNAL
|
||||
code = REASON_HTTP_ERROR
|
||||
return {
|
||||
"reason_code": code,
|
||||
"error_class": error_class,
|
||||
"http_status": getattr(exc, "http_status", None),
|
||||
"message": fixed_message(code),
|
||||
"transport_survives": True,
|
||||
}
|
||||
|
||||
try:
|
||||
import gitea_config
|
||||
|
||||
if isinstance(exc, gitea_config.ConfigError):
|
||||
return {
|
||||
"reason_code": REASON_CONFIG_ERROR,
|
||||
"error_class": ERROR_CLASS_CONFIGURATION,
|
||||
"http_status": None,
|
||||
"message": fixed_message(REASON_CONFIG_ERROR),
|
||||
"transport_survives": True,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Unwrap FastMCP ToolError cause when the original was a typed failure.
|
||||
cause = getattr(exc, "__cause__", None)
|
||||
if cause is not None and cause is not exc and is_known_client_failure(cause):
|
||||
return _classify_exception_impl(cause)
|
||||
|
||||
# No message-substring authentication heuristics (reviewer finding #3).
|
||||
return {
|
||||
"reason_code": REASON_INTERNAL_ERROR,
|
||||
"error_class": ERROR_CLASS_INTERNAL,
|
||||
"http_status": None,
|
||||
"message": fixed_message(REASON_INTERNAL_ERROR),
|
||||
"transport_survives": True,
|
||||
}
|
||||
|
||||
|
||||
def build_structured_error_payload(
|
||||
classification: dict[str, Any],
|
||||
*,
|
||||
tool_name: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""LLM-safe structured payload — fixed message + typed metadata only."""
|
||||
try:
|
||||
reason = str(classification.get("reason_code") or REASON_INTERNAL_ERROR)
|
||||
message = fixed_message(reason)
|
||||
# Refuse to emit any classification message that is not the fixed constant.
|
||||
if classification.get("message") != message:
|
||||
message = fixed_message(reason)
|
||||
payload: dict[str, Any] = {
|
||||
"success": False,
|
||||
"isError": True,
|
||||
"reason_code": reason if reason in FIXED_MESSAGES else REASON_INTERNAL_ERROR,
|
||||
"error_class": classification.get("error_class") or ERROR_CLASS_INTERNAL,
|
||||
"message": message,
|
||||
"transport_survives": True,
|
||||
"retryable": classification.get("error_class")
|
||||
in {
|
||||
ERROR_CLASS_AUTHENTICATION,
|
||||
ERROR_CLASS_NETWORK,
|
||||
ERROR_CLASS_CONFIGURATION,
|
||||
},
|
||||
}
|
||||
status = classification.get("http_status")
|
||||
if isinstance(status, int):
|
||||
payload["http_status"] = status
|
||||
if tool_name and isinstance(tool_name, str):
|
||||
# Tool names are identifiers, not secrets; bound length.
|
||||
payload["tool"] = tool_name[:120]
|
||||
if profile_name and isinstance(profile_name, str):
|
||||
payload["profile"] = profile_name[:80]
|
||||
return payload
|
||||
except Exception:
|
||||
return {
|
||||
"success": False,
|
||||
"isError": True,
|
||||
"reason_code": REASON_INTERNAL_ERROR,
|
||||
"error_class": ERROR_CLASS_INTERNAL,
|
||||
"message": fixed_message(REASON_INTERNAL_ERROR),
|
||||
"transport_survives": True,
|
||||
"retryable": False,
|
||||
}
|
||||
|
||||
|
||||
def log_sanitized_daemon_reason(
|
||||
classification: dict[str, Any],
|
||||
*,
|
||||
tool_name: str | None = None,
|
||||
stream=None,
|
||||
) -> None:
|
||||
"""Daemon log: reason codes only — never exception text or response bodies."""
|
||||
stream = stream if stream is not None else sys.stderr
|
||||
try:
|
||||
reason = classification.get("reason_code") or REASON_INTERNAL_ERROR
|
||||
error_class = classification.get("error_class") or ERROR_CLASS_INTERNAL
|
||||
# Only emit known tokens; never classification['message'] from callers
|
||||
# that might have been poisoned.
|
||||
if reason not in FIXED_MESSAGES:
|
||||
reason = REASON_INTERNAL_ERROR
|
||||
error_class = ERROR_CLASS_INTERNAL
|
||||
parts = [
|
||||
"mcp_tool_error",
|
||||
f"reason_code={reason}",
|
||||
f"error_class={error_class}",
|
||||
]
|
||||
if tool_name and isinstance(tool_name, str):
|
||||
parts.append(f"tool={tool_name[:120]}")
|
||||
status = classification.get("http_status")
|
||||
if isinstance(status, int):
|
||||
parts.append(f"http_status={status}")
|
||||
# Intentionally no detail= / message= field — secrets lived there.
|
||||
line = " ".join(parts)
|
||||
stream.write(line + "\n")
|
||||
if hasattr(stream, "flush"):
|
||||
stream.flush()
|
||||
logger.warning(line)
|
||||
except Exception:
|
||||
# Fail closed: never fall back to logging the exception.
|
||||
try:
|
||||
stream.write(
|
||||
"mcp_tool_error reason_code=internal_error "
|
||||
"error_class=internal\n"
|
||||
)
|
||||
if hasattr(stream, "flush"):
|
||||
stream.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def to_call_tool_result(
|
||||
exc: BaseException,
|
||||
*,
|
||||
tool_name: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
log: bool = True,
|
||||
) -> Any:
|
||||
"""Build a FastMCP ``CallToolResult`` with ``isError=True`` for *exc*."""
|
||||
from mcp.types import CallToolResult, TextContent
|
||||
|
||||
try:
|
||||
classification = classify_exception(exc)
|
||||
if log:
|
||||
log_sanitized_daemon_reason(classification, tool_name=tool_name)
|
||||
payload = build_structured_error_payload(
|
||||
classification, tool_name=tool_name, profile_name=profile_name
|
||||
)
|
||||
text = json.dumps(payload, indent=2, sort_keys=True)
|
||||
return CallToolResult(
|
||||
content=[TextContent(type="text", text=text)],
|
||||
structuredContent=payload,
|
||||
isError=True,
|
||||
)
|
||||
except Exception:
|
||||
# Absolute fail-closed path — no exception text.
|
||||
fallback = {
|
||||
"success": False,
|
||||
"isError": True,
|
||||
"reason_code": REASON_INTERNAL_ERROR,
|
||||
"error_class": ERROR_CLASS_INTERNAL,
|
||||
"message": fixed_message(REASON_INTERNAL_ERROR),
|
||||
"transport_survives": True,
|
||||
"retryable": False,
|
||||
}
|
||||
return CallToolResult(
|
||||
content=[
|
||||
TextContent(
|
||||
type="text",
|
||||
text=json.dumps(fallback, indent=2, sort_keys=True),
|
||||
)
|
||||
],
|
||||
structuredContent=fallback,
|
||||
isError=True,
|
||||
)
|
||||
|
||||
|
||||
def install_tool_run_boundary(Tool) -> bool:
|
||||
"""Install a **narrow** error boundary around FastMCP ``Tool.run``.
|
||||
|
||||
Strategy (preserves framework semantics):
|
||||
|
||||
* Call the **original** ``Tool.run`` for the success path (async, return
|
||||
types, convert_result, protocol behavior unchanged).
|
||||
* Re-raise ``UrlElicitationRequiredError`` and other control-flow
|
||||
exceptions without mapping to ``internal_error``.
|
||||
* Map typed Gitea client failures (and ToolError whose ``__cause__`` is
|
||||
typed) to structured ``CallToolResult(isError=True)``.
|
||||
* Map remaining unexpected tool failures to fixed ``internal_error``
|
||||
isError results (transport survival) without secret-bearing text.
|
||||
* Idempotent: second install is a no-op and returns ``False``.
|
||||
"""
|
||||
if getattr(Tool.run, _INSTALL_FLAG, False):
|
||||
return False
|
||||
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
from mcp.shared.exceptions import UrlElicitationRequiredError
|
||||
|
||||
original_run = Tool.run
|
||||
|
||||
async def run_boundary(
|
||||
self,
|
||||
arguments: dict[str, Any],
|
||||
context=None,
|
||||
convert_result: bool = False,
|
||||
) -> Any:
|
||||
try:
|
||||
return await original_run(
|
||||
self,
|
||||
arguments,
|
||||
context=context,
|
||||
convert_result=convert_result,
|
||||
)
|
||||
except UrlElicitationRequiredError:
|
||||
# Framework control-flow — must not become internal_error.
|
||||
raise
|
||||
except BaseException as exc:
|
||||
if _is_framework_control_flow(exc):
|
||||
raise
|
||||
if not isinstance(exc, Exception):
|
||||
raise
|
||||
|
||||
# Prefer typed cause under FastMCP ToolError wrappers.
|
||||
target: BaseException = exc
|
||||
if isinstance(exc, ToolError) and exc.__cause__ is not None:
|
||||
target = exc.__cause__
|
||||
|
||||
# Known client failures → structured isError with reason codes.
|
||||
# Unexpected failures → fixed internal_error isError (no secrets).
|
||||
profile_name = _safe_profile_name()
|
||||
return to_call_tool_result(
|
||||
target,
|
||||
tool_name=getattr(self, "name", None),
|
||||
profile_name=profile_name,
|
||||
)
|
||||
|
||||
setattr(run_boundary, _INSTALL_FLAG, True)
|
||||
setattr(run_boundary, _ORIGINAL_ATTR, original_run)
|
||||
Tool.run = run_boundary # type: ignore[method-assign]
|
||||
return True
|
||||
|
||||
|
||||
def boundary_is_installed(Tool) -> bool:
|
||||
"""True when the #699 boundary is active on *Tool.run*."""
|
||||
return bool(getattr(Tool.run, _INSTALL_FLAG, False))
|
||||
+127
-8
@@ -20,12 +20,114 @@ if PROJECT_ROOT not in sys.path:
|
||||
import gitea_config
|
||||
|
||||
|
||||
AUTHOR_DEFAULT_ALLOWED = ["read", "branch", "commit", "push", "open_pr", "comment"]
|
||||
AUTHOR_DEFAULT_FORBIDDEN = ["approve", "request_changes", "merge"]
|
||||
REVIEWER_DEFAULT_ALLOWED = [
|
||||
"read", "review", "comment", "approve", "request_changes", "merge"
|
||||
# Defaults emit *canonical* operation names only. Shorthand that is not in
|
||||
# gitea_config.GITEA_OPERATION_ALIASES (e.g. ``pr.close``, ``issue.close``)
|
||||
# is silently dropped by the production loader and must never appear here.
|
||||
AUTHOR_DEFAULT_ALLOWED = [
|
||||
"gitea.read",
|
||||
"gitea.branch.create",
|
||||
"gitea.repo.commit",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
]
|
||||
REVIEWER_DEFAULT_FORBIDDEN = ["branch", "commit", "push", "open_pr"]
|
||||
AUTHOR_DEFAULT_FORBIDDEN = [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.merge",
|
||||
]
|
||||
REVIEWER_DEFAULT_ALLOWED = [
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.comment",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.merge",
|
||||
]
|
||||
REVIEWER_DEFAULT_FORBIDDEN = [
|
||||
"gitea.branch.create",
|
||||
"gitea.repo.commit",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.create",
|
||||
]
|
||||
# Required reconciler ops (read + pr.close) plus recommended comment/close and
|
||||
# branch.delete for guarded merged-PR cleanup. All names must normalize via
|
||||
# gitea_config.normalize_operation without being dropped.
|
||||
RECONCILER_DEFAULT_ALLOWED = [
|
||||
"gitea.read",
|
||||
"gitea.pr.close",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
"gitea.branch.delete",
|
||||
]
|
||||
RECONCILER_DEFAULT_FORBIDDEN = [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
]
|
||||
|
||||
# Migration-only expansions for common shorthands that are *not* in
|
||||
# GITEA_OPERATION_ALIASES. Emitted output is always the canonical form so a
|
||||
# second canonicalize pass is a no-op (idempotent).
|
||||
_MIGRATION_ONLY_ALIASES = {
|
||||
"pr.close": "gitea.pr.close",
|
||||
"pr.comment": "gitea.pr.comment",
|
||||
"issue.close": "gitea.issue.close",
|
||||
"branch.delete": "gitea.branch.delete",
|
||||
}
|
||||
|
||||
# Reconciler required ops that must survive migration (from reconciler_profile).
|
||||
RECONCILER_REQUIRED_CANONICAL = ("gitea.read", "gitea.pr.close")
|
||||
|
||||
|
||||
def canonicalize_operation(op: str) -> str:
|
||||
"""Return a canonical operation name accepted by the production loader.
|
||||
|
||||
Fail closed on unknown/ambiguous spellings so required permissions cannot
|
||||
be silently dropped by ``check_operation`` later.
|
||||
"""
|
||||
if not isinstance(op, str) or not op.strip():
|
||||
raise ValueError("operation must be a non-empty string (fail closed)")
|
||||
op = op.strip()
|
||||
try:
|
||||
return gitea_config.normalize_operation(op)
|
||||
except gitea_config.ConfigError:
|
||||
pass
|
||||
if op in _MIGRATION_ONLY_ALIASES:
|
||||
return _MIGRATION_ONLY_ALIASES[op]
|
||||
raise ValueError(
|
||||
f"operation {op!r} cannot be canonicalized for migration "
|
||||
"(unknown/ambiguous; fail closed — production loader would drop it)"
|
||||
)
|
||||
|
||||
|
||||
def canonicalize_operations(ops, *, context: str = "operations") -> list[str]:
|
||||
"""Canonicalize a list of operations; preserve order, drop duplicates."""
|
||||
if not isinstance(ops, list):
|
||||
raise ValueError(f"{context} must be a list (fail closed)")
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for entry in ops:
|
||||
canon = canonicalize_operation(entry)
|
||||
if canon not in seen:
|
||||
seen.add(canon)
|
||||
out.append(canon)
|
||||
return out
|
||||
|
||||
|
||||
def _assert_reconciler_required_survive(allowed: list[str], profile_name: str) -> None:
|
||||
"""Fail visibly when migration would leave a reconciler without required ops."""
|
||||
missing = [op for op in RECONCILER_REQUIRED_CANONICAL if op not in set(allowed)]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"Profile '{profile_name}' (reconciler) is missing required "
|
||||
f"operation(s) after migration: {missing}. Refusing to emit a "
|
||||
"profile that would silently fail pr.close / read (fail closed)."
|
||||
)
|
||||
|
||||
|
||||
def infer_role(name, execution_profile):
|
||||
@@ -90,9 +192,11 @@ def migrate_v1_to_v2(v1_data):
|
||||
ident_name = "reviewer"
|
||||
elif role == "author":
|
||||
ident_name = "author"
|
||||
elif role == "reconciler":
|
||||
ident_name = "reconciler"
|
||||
else:
|
||||
role = prof.get("role")
|
||||
if role not in (None, "author", "reviewer"):
|
||||
if role not in (None, "author", "reviewer", "reconciler"):
|
||||
raise ValueError(
|
||||
f"Profile '{name}' has unsupported role {role!r}"
|
||||
)
|
||||
@@ -124,20 +228,35 @@ def migrate_v1_to_v2(v1_data):
|
||||
raise ValueError(
|
||||
f"Profile '{name}' operation fields must be lists"
|
||||
)
|
||||
identity_data["allowed_operations"] = list(allowed)
|
||||
identity_data["forbidden_operations"] = list(forbidden)
|
||||
try:
|
||||
identity_data["allowed_operations"] = canonicalize_operations(
|
||||
allowed, context=f"profile '{name}' allowed_operations"
|
||||
)
|
||||
identity_data["forbidden_operations"] = canonicalize_operations(
|
||||
forbidden, context=f"profile '{name}' forbidden_operations"
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Profile '{name}': {exc}") from exc
|
||||
elif role == "author":
|
||||
identity_data["allowed_operations"] = list(AUTHOR_DEFAULT_ALLOWED)
|
||||
identity_data["forbidden_operations"] = list(AUTHOR_DEFAULT_FORBIDDEN)
|
||||
elif role == "reviewer":
|
||||
identity_data["allowed_operations"] = list(REVIEWER_DEFAULT_ALLOWED)
|
||||
identity_data["forbidden_operations"] = list(REVIEWER_DEFAULT_FORBIDDEN)
|
||||
elif role == "reconciler":
|
||||
identity_data["allowed_operations"] = list(RECONCILER_DEFAULT_ALLOWED)
|
||||
identity_data["forbidden_operations"] = list(RECONCILER_DEFAULT_FORBIDDEN)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Profile '{name}' has no explicit operation lists and no "
|
||||
"unambiguous author/reviewer role marker (fail closed)"
|
||||
)
|
||||
|
||||
if role == "reconciler":
|
||||
_assert_reconciler_required_survive(
|
||||
identity_data["allowed_operations"], name
|
||||
)
|
||||
|
||||
# Nest inside environments/services structure
|
||||
env = environments.setdefault(env_name, {})
|
||||
services = env.setdefault("services", {})
|
||||
|
||||
@@ -18,6 +18,11 @@ RECONCILER_RECOMMENDED_OPERATIONS = (
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
# Merged-branch cleanup is reconciler-owned (task_capability_map maps
|
||||
# cleanup_merged_pr_branch -> reconciler). The permission is only
|
||||
# exercisable through the guarded gitea_cleanup_merged_pr_branch path
|
||||
# (#514): merged proof, protected-branch refusal, explicit confirmation.
|
||||
"gitea.branch.delete",
|
||||
)
|
||||
|
||||
RECONCILER_FORBIDDEN_OPERATIONS = (
|
||||
|
||||
@@ -79,41 +79,46 @@ class TestApiRequestFailures(unittest.TestCase):
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_timeout_converted_to_runtimeerror(self, mock_open):
|
||||
mock_open.side_effect = TimeoutError("timed out")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertIn("network error contacting Gitea", str(ctx.exception))
|
||||
# Fixed message only (#699) — still a RuntimeError subclass.
|
||||
self.assertIsInstance(ctx.exception, RuntimeError)
|
||||
self.assertEqual(str(ctx.exception), "Network error contacting Gitea")
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_dns_network_failure_converted(self, mock_open):
|
||||
mock_open.side_effect = urllib.error.URLError("Name or service not known")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertIn("network error contacting Gitea", str(ctx.exception))
|
||||
self.assertEqual(str(ctx.exception), "Network error contacting Gitea")
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_502_upstream_message(self, mock_open):
|
||||
mock_open.side_effect = http_error(502, "bad gateway")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
msg = str(ctx.exception)
|
||||
self.assertIn("HTTP 502", msg)
|
||||
self.assertIn("upstream unavailable", msg)
|
||||
self.assertEqual(msg, "Gitea upstream unavailable")
|
||||
self.assertEqual(ctx.exception.reason_code, "upstream_unavailable")
|
||||
self.assertEqual(ctx.exception.http_status, 502)
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_503_upstream_message(self, mock_open):
|
||||
mock_open.side_effect = http_error(503, "")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertIn("HTTP 503", str(ctx.exception))
|
||||
self.assertIn("upstream unavailable", str(ctx.exception))
|
||||
self.assertEqual(str(ctx.exception), "Gitea upstream unavailable")
|
||||
self.assertEqual(ctx.exception.http_status, 503)
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_malformed_error_payload_does_not_crash(self, mock_open):
|
||||
# Non-JSON garbage error body must still yield a clean RuntimeError.
|
||||
# Non-JSON garbage error body must still yield a clean typed error
|
||||
# with a fixed message (no body echo — #699).
|
||||
mock_open.side_effect = http_error(500, "<html>garbage</html>")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertIn("HTTP 500", str(ctx.exception))
|
||||
self.assertEqual(str(ctx.exception), "Gitea HTTP request failed")
|
||||
self.assertNotIn("<html>", str(ctx.exception))
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_malformed_success_json_raises_clean_error(self, mock_open):
|
||||
@@ -126,16 +131,17 @@ class TestApiRequestFailures(unittest.TestCase):
|
||||
def test_no_secret_leak_in_error_body(self, mock_open):
|
||||
mock_open.side_effect = http_error(
|
||||
400, "failed: token supersecret123 rejected")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
msg = str(ctx.exception)
|
||||
self.assertNotIn("supersecret123", msg)
|
||||
self.assertIn(gitea_audit.REDACTED, msg)
|
||||
# Fixed message — body never appears (stronger than redaction).
|
||||
self.assertEqual(msg, "Gitea HTTP request failed")
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_auth_header_never_in_error(self, mock_open):
|
||||
mock_open.side_effect = http_error(400, "bad request")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertNotIn(FAKE_AUTH, str(ctx.exception))
|
||||
|
||||
|
||||
@@ -27,7 +27,13 @@ from task_capability_map import required_permission, required_role
|
||||
|
||||
DELETE_PROFILE = {
|
||||
"profile_name": "prgs-author-delete",
|
||||
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
||||
"role": "author",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
"audit_label": "prgs-author-delete",
|
||||
}
|
||||
|
||||
+1106
-27
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,499 @@
|
||||
"""#720: Expired old-head KIND_DECISION_LOCK must not block fresh review.
|
||||
|
||||
Reproduction shape (PR #616):
|
||||
* REQUEST_CHANGES terminal at head A
|
||||
* open PR advanced to head B
|
||||
* durable decision lock age > default 4h TTL
|
||||
* fresh_review_on_current_head_allowed is true but unreachable under TTL-first reject
|
||||
* mark_final fails with "session state expired after 4h"
|
||||
* assessment may report "no lock" while the file remains on disk
|
||||
|
||||
Security invariants preserved:
|
||||
* same-head second terminal remains fail-closed (#332/#620)
|
||||
* REQUEST_CHANGES is never treated as approval
|
||||
* historical mutations remain on the ledger
|
||||
* merged/closed moot cleanup still allowed (#594)
|
||||
* irrecoverable provenance still requires #709 authorization
|
||||
* ordinary profiles do not gain gitea.decision_lock.irrecoverable_recovery
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import sys
|
||||
|
||||
ROOT = str(Path(__file__).resolve().parent.parent)
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import mcp_session_state as ss
|
||||
import mcp_server
|
||||
import stale_review_decision_lock as srdl
|
||||
import task_capability_map
|
||||
|
||||
HEAD_A = "a0fffae576673ba7df7456b32e6aec916581bdfb"
|
||||
HEAD_B = "a6a2243aad9c3e385fc70509f4941a0f0ec33162"
|
||||
HEAD_SAME = HEAD_A
|
||||
|
||||
RC_616_A = {
|
||||
"pr_number": 616,
|
||||
"action": "request_changes",
|
||||
"review_id": 443,
|
||||
"review_state": "request_changes",
|
||||
"head_sha": HEAD_A,
|
||||
}
|
||||
|
||||
|
||||
def _lock(mutations=None, **kwargs):
|
||||
base = {
|
||||
"task": "review_pr",
|
||||
"kind": ss.KIND_DECISION_LOCK,
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"session_pid": os.getpid(),
|
||||
"session_profile": "prgs-reviewer",
|
||||
"session_profile_lock": "prgs-reviewer",
|
||||
"profile_identity": "prgs-reviewer",
|
||||
"final_review_decision_ready": False,
|
||||
"ready_pr_number": kwargs.get("ready_pr"),
|
||||
"ready_action": kwargs.get("ready_action"),
|
||||
"ready_expected_head_sha": kwargs.get("ready_head"),
|
||||
"ready_remote": "prgs" if kwargs.get("ready_pr") else None,
|
||||
"ready_org": "Scaled-Tech-Consulting" if kwargs.get("ready_pr") else None,
|
||||
"ready_repo": "Gitea-Tools" if kwargs.get("ready_pr") else None,
|
||||
"live_mutations": list(mutations or []),
|
||||
"correction_authorized": False,
|
||||
"correction_reason": None,
|
||||
}
|
||||
return base
|
||||
|
||||
|
||||
def _open_pr(pr_number=616, head=HEAD_B, merged=False, closed=False):
|
||||
state = "closed" if closed or merged else "open"
|
||||
return {
|
||||
"number": pr_number,
|
||||
"state": state,
|
||||
"merged": merged,
|
||||
"merged_at": "2026-07-16T12:00:00Z" if merged else None,
|
||||
"merge_commit_sha": "m" * 40 if merged else None,
|
||||
"head": {"sha": head},
|
||||
}
|
||||
|
||||
|
||||
def _no_lease():
|
||||
return {"block": False, "reasons": [], "mutation_allowed": True}
|
||||
|
||||
|
||||
def _feedback(blocking=False, stale=True):
|
||||
return {
|
||||
"success": True,
|
||||
"has_blocking_change_requests": blocking,
|
||||
"review_feedback_stale": stale,
|
||||
"current_head_sha": HEAD_B,
|
||||
}
|
||||
|
||||
|
||||
def _age_lock_payload(lock: dict, hours: float = 5.0) -> dict:
|
||||
"""Rewrite timestamps to *hours* ago (simulates durable age without touching prod)."""
|
||||
aged = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
out = dict(lock)
|
||||
out["recorded_at"] = aged
|
||||
out["updated_at"] = aged
|
||||
return out
|
||||
|
||||
|
||||
class TestIssue720ExpiredDecisionLockLifecycle(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.env = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
ss.STATE_DIR_ENV: self._tmp.name,
|
||||
ss.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer",
|
||||
"GITEA_MCP_PROFILE": "prgs-reviewer",
|
||||
"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
self.env.start()
|
||||
mcp_server._REVIEW_DECISION_LOCK = None
|
||||
import review_workflow_load
|
||||
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._REVIEW_DECISION_LOCK = None
|
||||
import review_workflow_load
|
||||
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
self.env.stop()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _persist_aged_lock(self, mutations, hours: float = 5.0, **kwargs):
|
||||
"""Write decision lock aged > TTL to the temp durable store (test-only)."""
|
||||
payload = _age_lock_payload(_lock(mutations, **kwargs), hours=hours)
|
||||
saved = ss.save_state(
|
||||
kind=ss.KIND_DECISION_LOCK,
|
||||
payload=payload,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
profile_identity="prgs-reviewer",
|
||||
)
|
||||
# save_state refreshes updated_at; re-age the on-disk envelope for TTL tests.
|
||||
path = ss.state_file_path(
|
||||
kind=ss.KIND_DECISION_LOCK,
|
||||
profile_identity="prgs-reviewer",
|
||||
state_dir=self._tmp.name,
|
||||
)
|
||||
aged = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
import json
|
||||
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
envelope = json.load(fh)
|
||||
envelope["recorded_at"] = aged
|
||||
envelope["updated_at"] = aged
|
||||
if isinstance(envelope.get("payload"), dict):
|
||||
envelope["payload"]["recorded_at"] = aged
|
||||
envelope["payload"]["updated_at"] = aged
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(envelope, fh, indent=2, sort_keys=True)
|
||||
fh.write("\n")
|
||||
# Drop memory so subsequent loads hit durable store.
|
||||
mcp_server._REVIEW_DECISION_LOCK = None
|
||||
return saved
|
||||
|
||||
def _mark(self, pr, action, head, feedback=None):
|
||||
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
|
||||
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
|
||||
), patch.object(
|
||||
mcp_server,
|
||||
"gitea_get_pr_review_feedback",
|
||||
return_value=feedback or _feedback(blocking=False, stale=True),
|
||||
), patch.object(
|
||||
mcp_server,
|
||||
"gitea_check_pr_eligibility",
|
||||
return_value={"eligible": True, "head_sha": head},
|
||||
), patch.object(
|
||||
mcp_server.mcp_daemon_guard,
|
||||
"assert_sanctioned_mutation_runtime",
|
||||
return_value=None,
|
||||
), patch.object(
|
||||
mcp_server.mcp_daemon_guard,
|
||||
"assert_no_direct_import_bypass",
|
||||
return_value=None,
|
||||
):
|
||||
return mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=pr,
|
||||
action=action,
|
||||
expected_head_sha=head,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
|
||||
# --- AC regression matrix ---
|
||||
|
||||
def test_under_four_hours_fresh_review_at_b_allowed(self):
|
||||
self._persist_aged_lock(
|
||||
[RC_616_A],
|
||||
hours=1.0,
|
||||
ready_pr=616,
|
||||
ready_head=HEAD_A,
|
||||
ready_action="request_changes",
|
||||
)
|
||||
res = self._mark(616, "approve", HEAD_B)
|
||||
self.assertTrue(res.get("marked_ready"), res)
|
||||
|
||||
def test_over_four_hours_fresh_review_at_b_still_allowed(self):
|
||||
"""Primary #720 defect: age > TTL must not block head-B mark_final."""
|
||||
self._persist_aged_lock(
|
||||
[RC_616_A],
|
||||
hours=5.0,
|
||||
ready_pr=616,
|
||||
ready_head=HEAD_A,
|
||||
ready_action="request_changes",
|
||||
)
|
||||
# Prove durable load is possible for recovery-critical decision locks.
|
||||
loaded = ss.load_state(
|
||||
kind=ss.KIND_DECISION_LOCK,
|
||||
profile_identity="prgs-reviewer",
|
||||
)
|
||||
self.assertIsNotNone(
|
||||
loaded,
|
||||
"expired KIND_DECISION_LOCK must remain loadable (not generic TTL cache)",
|
||||
)
|
||||
res = self._mark(616, "approve", HEAD_B)
|
||||
self.assertTrue(
|
||||
res.get("marked_ready"),
|
||||
f"expected mark_ready on head B after >4h; got {res}",
|
||||
)
|
||||
reasons = " ".join(res.get("reasons") or [])
|
||||
self.assertNotIn("session state expired", reasons)
|
||||
|
||||
def test_historical_review_at_a_preserved_and_stale(self):
|
||||
self._persist_aged_lock(
|
||||
[RC_616_A],
|
||||
hours=5.0,
|
||||
ready_pr=616,
|
||||
ready_head=HEAD_A,
|
||||
ready_action="request_changes",
|
||||
)
|
||||
res = self._mark(616, "approve", HEAD_B)
|
||||
self.assertTrue(res.get("marked_ready"), res)
|
||||
lock = mcp_server._load_review_decision_lock()
|
||||
self.assertIsNotNone(lock)
|
||||
hist = [m for m in lock["live_mutations"] if m.get("review_id") == 443]
|
||||
self.assertEqual(len(hist), 1)
|
||||
self.assertEqual(hist[0]["head_sha"], HEAD_A)
|
||||
self.assertEqual(hist[0]["action"], "request_changes")
|
||||
a = srdl.assess_stale_review_decision_lock(
|
||||
lock, pr_live=_open_pr(616, HEAD_B)
|
||||
)
|
||||
self.assertTrue(a["stale_by_head"])
|
||||
self.assertTrue(a["fresh_review_on_current_head_allowed"])
|
||||
self.assertEqual(a["locked_head_sha"], HEAD_A)
|
||||
|
||||
def test_no_reuse_approval_from_head_a(self):
|
||||
"""REQUEST_CHANGES at A is never an approval credential for B."""
|
||||
self._persist_aged_lock([RC_616_A], hours=5.0)
|
||||
lock = ss.load_state(
|
||||
kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer"
|
||||
)
|
||||
last = srdl.last_terminal_mutation(lock)
|
||||
self.assertEqual(last.get("action"), "request_changes")
|
||||
self.assertNotEqual(last.get("action"), "approve")
|
||||
# Gate must still require a new mark/submit for head B; prior RC is not approve.
|
||||
reasons = mcp_server.terminal_review_hard_stop_reasons(
|
||||
616, "mark_ready", expected_head_sha=HEAD_B
|
||||
)
|
||||
self.assertEqual(reasons, [])
|
||||
|
||||
def test_second_terminal_mutation_at_b_same_run_blocked(self):
|
||||
self._persist_aged_lock(
|
||||
[RC_616_A],
|
||||
hours=5.0,
|
||||
ready_pr=616,
|
||||
ready_head=HEAD_A,
|
||||
ready_action="request_changes",
|
||||
)
|
||||
res = self._mark(616, "approve", HEAD_B)
|
||||
self.assertTrue(res.get("marked_ready"), res)
|
||||
# Record terminal approve at B (same run).
|
||||
lock = mcp_server._load_review_decision_lock()
|
||||
lock["final_review_decision_ready"] = True
|
||||
lock["ready_pr_number"] = 616
|
||||
lock["ready_action"] = "approve"
|
||||
lock["ready_expected_head_sha"] = HEAD_B
|
||||
mcp_server._save_review_decision_lock(lock)
|
||||
mcp_server.record_live_review_mutation(616, "approve", review_id=999)
|
||||
# Second terminal at same head B must hard-stop.
|
||||
hard = mcp_server.terminal_review_hard_stop_reasons(
|
||||
616, "mark_ready", expected_head_sha=HEAD_B
|
||||
)
|
||||
self.assertTrue(hard)
|
||||
res2 = self._mark(616, "approve", HEAD_B)
|
||||
self.assertFalse(res2.get("marked_ready"))
|
||||
|
||||
def test_open_pr_same_head_expired_still_fail_closed(self):
|
||||
self._persist_aged_lock(
|
||||
[RC_616_A],
|
||||
hours=5.0,
|
||||
ready_pr=616,
|
||||
ready_head=HEAD_A,
|
||||
ready_action="request_changes",
|
||||
)
|
||||
res = self._mark(616, "approve", HEAD_SAME)
|
||||
self.assertFalse(res.get("marked_ready"), res)
|
||||
self.assertTrue(
|
||||
any("#332" in r or "already consumed" in r for r in (res.get("reasons") or [])),
|
||||
res,
|
||||
)
|
||||
|
||||
def test_merged_pr_moot_cleanup_still_allowed(self):
|
||||
self._persist_aged_lock(
|
||||
[RC_616_A],
|
||||
hours=5.0,
|
||||
ready_pr=616,
|
||||
ready_head=HEAD_A,
|
||||
ready_action="request_changes",
|
||||
)
|
||||
lock = ss.load_state(
|
||||
kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer"
|
||||
)
|
||||
a = srdl.assess_stale_review_decision_lock(
|
||||
lock, pr_live=_open_pr(616, HEAD_A, merged=True)
|
||||
)
|
||||
self.assertTrue(a["is_moot"])
|
||||
self.assertTrue(a["cleanup_allowed"])
|
||||
self.assertFalse(a["fresh_review_on_current_head_allowed"])
|
||||
|
||||
def test_assessment_reports_expired_old_head_not_absent(self):
|
||||
self._persist_aged_lock(
|
||||
[RC_616_A],
|
||||
hours=5.0,
|
||||
ready_pr=616,
|
||||
ready_head=HEAD_A,
|
||||
ready_action="request_changes",
|
||||
)
|
||||
# Disk presence + load after lifecycle fix.
|
||||
path = ss.state_file_path(
|
||||
kind=ss.KIND_DECISION_LOCK,
|
||||
profile_identity="prgs-reviewer",
|
||||
state_dir=self._tmp.name,
|
||||
)
|
||||
self.assertTrue(os.path.exists(path))
|
||||
loaded = ss.load_state(
|
||||
kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer"
|
||||
)
|
||||
self.assertIsNotNone(loaded)
|
||||
inspect = ss.inspect_state_envelope(
|
||||
kind=ss.KIND_DECISION_LOCK,
|
||||
profile_identity="prgs-reviewer",
|
||||
state_dir=self._tmp.name,
|
||||
)
|
||||
self.assertTrue(inspect.get("on_disk"))
|
||||
self.assertTrue(inspect.get("has_payload"))
|
||||
self.assertTrue(inspect.get("recovery_critical") or inspect.get("ttl_exempt"))
|
||||
self.assertTrue(inspect.get("age_hours", 0) >= 4.0)
|
||||
a = srdl.assess_stale_review_decision_lock(
|
||||
loaded, pr_live=_open_pr(616, HEAD_B)
|
||||
)
|
||||
self.assertTrue(a["has_lock"])
|
||||
self.assertNotIn("no review decision lock present", " ".join(a["reasons"]))
|
||||
self.assertTrue(a["stale_by_head"])
|
||||
self.assertEqual(a["last_terminal_action"], "request_changes")
|
||||
|
||||
def test_existing_serialized_records_compatible(self):
|
||||
"""Pre-fix ledgers without recovery_critical flag still load via kind."""
|
||||
payload = _age_lock_payload(_lock([RC_616_A]), hours=8.0)
|
||||
payload.pop("recovery_critical", None)
|
||||
# Manually write pre-#720-shaped envelope (kind only on envelope).
|
||||
path = ss.state_file_path(
|
||||
kind=ss.KIND_DECISION_LOCK,
|
||||
profile_identity="prgs-reviewer",
|
||||
state_dir=self._tmp.name,
|
||||
)
|
||||
import json
|
||||
|
||||
os.makedirs(self._tmp.name, exist_ok=True)
|
||||
aged = payload["recorded_at"]
|
||||
envelope = {
|
||||
"kind": ss.KIND_DECISION_LOCK,
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"profile_identity": "prgs-reviewer",
|
||||
"session_profile_lock": "prgs-reviewer",
|
||||
"recorded_at": aged,
|
||||
"updated_at": aged,
|
||||
"writer_pid": os.getpid(),
|
||||
"payload": payload,
|
||||
}
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(envelope, fh, indent=2, sort_keys=True)
|
||||
fh.write("\n")
|
||||
loaded = ss.load_state(
|
||||
kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer"
|
||||
)
|
||||
self.assertIsNotNone(loaded)
|
||||
self.assertEqual(loaded["live_mutations"][0]["review_id"], 443)
|
||||
|
||||
def test_decision_lock_is_recovery_critical_kind(self):
|
||||
self.assertIn(ss.KIND_DECISION_LOCK, ss.RECOVERY_CRITICAL_KINDS)
|
||||
|
||||
def test_workflow_load_still_ttl_expires(self):
|
||||
"""Do not make every session-state kind permanently TTL-exempt."""
|
||||
aged = (datetime.now(timezone.utc) - timedelta(hours=5)).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
rec = {
|
||||
"kind": ss.KIND_WORKFLOW_LOAD,
|
||||
"recorded_at": aged,
|
||||
"updated_at": aged,
|
||||
"profile_identity": "prgs-reviewer",
|
||||
"session_profile_lock": "prgs-reviewer",
|
||||
}
|
||||
reasons = ss.identity_match_reasons(
|
||||
rec, profile_identity="prgs-reviewer"
|
||||
)
|
||||
self.assertTrue(any("expired" in r for r in reasons), reasons)
|
||||
|
||||
def test_irrecoverable_permission_not_on_ordinary_profiles(self):
|
||||
perm = "gitea.decision_lock.irrecoverable_recovery"
|
||||
# Capability map must not map ordinary author/reviewer/merger tasks to it.
|
||||
for task in (
|
||||
"create_issue",
|
||||
"comment_issue",
|
||||
"review_pr",
|
||||
"merge_pr",
|
||||
"lock_issue",
|
||||
"create_pr",
|
||||
):
|
||||
req = task_capability_map.required_permission(task)
|
||||
self.assertNotEqual(req, perm, msg=task)
|
||||
|
||||
def test_structured_error_when_same_head_expired(self):
|
||||
self._persist_aged_lock(
|
||||
[RC_616_A],
|
||||
hours=5.0,
|
||||
ready_pr=616,
|
||||
ready_head=HEAD_A,
|
||||
ready_action="request_changes",
|
||||
)
|
||||
res = self._mark(616, "approve", HEAD_A)
|
||||
self.assertFalse(res.get("marked_ready"))
|
||||
reasons = res.get("reasons") or []
|
||||
self.assertTrue(reasons)
|
||||
blob = " ".join(reasons)
|
||||
# Actionable recovery text from hard-stop (not generic internal_error).
|
||||
self.assertIn("#332", blob)
|
||||
self.assertTrue(
|
||||
"#620" in blob or "head moved" in blob or "new expected_head_sha" in blob
|
||||
or "already consumed" in blob
|
||||
)
|
||||
self.assertNotIn("internal_error", blob.lower())
|
||||
|
||||
|
||||
class TestIssue720InspectEnvelope(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.env = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
ss.STATE_DIR_ENV: self._tmp.name,
|
||||
ss.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer",
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
self.env.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.env.stop()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_inspect_missing_file(self):
|
||||
info = ss.inspect_state_envelope(
|
||||
kind=ss.KIND_DECISION_LOCK,
|
||||
profile_identity="prgs-reviewer",
|
||||
state_dir=self._tmp.name,
|
||||
)
|
||||
self.assertFalse(info["on_disk"])
|
||||
self.assertFalse(info["has_payload"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1446,10 +1446,16 @@ class TestReviewPR(unittest.TestCase):
|
||||
class TestDeleteBranch(unittest.TestCase):
|
||||
|
||||
DELETE_PROFILE = {
|
||||
"profile_name": "test-deleter",
|
||||
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
||||
"profile_name": "test-author-deleter",
|
||||
"role": "author",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
"audit_label": "test-deleter",
|
||||
"audit_label": "test-author-deleter",
|
||||
}
|
||||
|
||||
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
||||
|
||||
@@ -92,14 +92,19 @@ class TestMigrateProfiles(unittest.TestCase):
|
||||
author = prgs_gitea["identities"]["author"]
|
||||
self.assertEqual(author["username"], "jcwalker3")
|
||||
self.assertEqual(author["auth"]["id"], "redacted-author-ref")
|
||||
self.assertEqual(author["allowed_operations"], ["read", "comment"])
|
||||
self.assertEqual(author["forbidden_operations"], ["approve", "merge"])
|
||||
self.assertEqual(
|
||||
author["allowed_operations"], ["gitea.read", "gitea.pr.comment"]
|
||||
)
|
||||
self.assertEqual(
|
||||
author["forbidden_operations"],
|
||||
["gitea.pr.approve", "gitea.pr.merge"],
|
||||
)
|
||||
|
||||
reviewer = prgs_gitea["identities"]["reviewer"]
|
||||
self.assertEqual(reviewer["role"], "reviewer")
|
||||
self.assertEqual(reviewer["username"], "sysadmin")
|
||||
self.assertEqual(reviewer["auth"]["id"], "redacted-reviewer-ref")
|
||||
self.assertIn("merge", reviewer["allowed_operations"])
|
||||
self.assertIn("gitea.pr.merge", reviewer["allowed_operations"])
|
||||
|
||||
def test_alias_generation(self):
|
||||
"""Test that aliases are correctly generated to support old profile names."""
|
||||
@@ -188,7 +193,7 @@ class TestMigrateProfiles(unittest.TestCase):
|
||||
self.assertNotIn("token", stdout_output.lower())
|
||||
|
||||
def test_explicit_operations_are_preserved(self):
|
||||
"""Explicit v1 permissions must not be replaced by role defaults."""
|
||||
"""Explicit v1 permissions are canonicalized, not replaced by role defaults."""
|
||||
v1_data = json.loads(json.dumps(self.v1_content))
|
||||
v1_data["profiles"]["prgs-reviewer"]["allowed_operations"] = ["read"]
|
||||
v1_data["profiles"]["prgs-reviewer"]["forbidden_operations"] = ["merge"]
|
||||
@@ -198,8 +203,8 @@ class TestMigrateProfiles(unittest.TestCase):
|
||||
v2_data["environments"]["prgs"]["services"]["gitea"]
|
||||
["identities"]["reviewer"]
|
||||
)
|
||||
self.assertEqual(reviewer["allowed_operations"], ["read"])
|
||||
self.assertEqual(reviewer["forbidden_operations"], ["merge"])
|
||||
self.assertEqual(reviewer["allowed_operations"], ["gitea.read"])
|
||||
self.assertEqual(reviewer["forbidden_operations"], ["gitea.pr.merge"])
|
||||
|
||||
def test_inferred_role_defaults_only_when_unambiguous(self):
|
||||
"""Role defaults are allowed only for clear author/reviewer profiles."""
|
||||
@@ -306,6 +311,171 @@ class TestMigrateProfiles(unittest.TestCase):
|
||||
migrate_profiles.main()
|
||||
self.assertEqual(cm.exception.code, 1)
|
||||
|
||||
def test_reconciler_profile_migration(self):
|
||||
"""Legacy reconciler shorthands migrate to valid canonical operations."""
|
||||
import gitea_config
|
||||
import reconciler_profile
|
||||
|
||||
v1_data = {
|
||||
"version": 1,
|
||||
"profiles": {
|
||||
"prgs-reconciler": {
|
||||
"base_url": "redacted-prgs-service",
|
||||
"username": "reconciler-agent",
|
||||
"auth": {"type": "keychain", "id": "reconciler-ref"},
|
||||
"execution_profile": "prgs-reconciler",
|
||||
"allowed_operations": [
|
||||
"read",
|
||||
"pr.close",
|
||||
"pr.comment",
|
||||
"issue.comment",
|
||||
"issue.close",
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"merge",
|
||||
"approve",
|
||||
"review",
|
||||
"pr.create",
|
||||
"branch.push",
|
||||
"commit",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
v2_data = migrate_profiles.migrate_v1_to_v2(v1_data)
|
||||
reconciler = (
|
||||
v2_data["environments"]["prgs"]["services"]["gitea"]
|
||||
["identities"]["reconciler"]
|
||||
)
|
||||
self.assertEqual(reconciler["role"], "reconciler")
|
||||
allowed = reconciler["allowed_operations"]
|
||||
forbidden = reconciler["forbidden_operations"]
|
||||
# No invalid shorthand remains
|
||||
for bad in ("pr.close", "pr.comment", "issue.close", "read", "merge"):
|
||||
self.assertNotIn(bad, allowed)
|
||||
self.assertNotIn(bad, forbidden)
|
||||
for required in (
|
||||
"gitea.read",
|
||||
"gitea.pr.close",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
"gitea.branch.delete",
|
||||
):
|
||||
self.assertIn(required, allowed)
|
||||
# Production loader accepts every allowed op
|
||||
self.assertEqual(
|
||||
gitea_config.normalize_operation(required), required
|
||||
)
|
||||
self.assertEqual(v2_data["aliases"]["prgs-reconciler"], "prgs.gitea.reconciler")
|
||||
assessment = reconciler_profile.assess_reconciler_profile(allowed, forbidden)
|
||||
self.assertTrue(assessment["valid"])
|
||||
self.assertTrue(migrate_profiles.validate_v2_data(v2_data))
|
||||
|
||||
def test_reconciler_profile_defaults(self):
|
||||
"""Reconciler defaults are fully canonical and loader-valid."""
|
||||
import gitea_config
|
||||
import reconciler_profile
|
||||
|
||||
v1_data = {
|
||||
"version": 1,
|
||||
"profiles": {
|
||||
"prgs-reconciler": {
|
||||
"base_url": "redacted-prgs-service",
|
||||
"username": "reconciler-agent",
|
||||
"auth": {"type": "keychain", "id": "reconciler-ref"},
|
||||
"execution_profile": "prgs-reconciler",
|
||||
}
|
||||
}
|
||||
}
|
||||
v2_data = migrate_profiles.migrate_v1_to_v2(v1_data)
|
||||
reconciler = (
|
||||
v2_data["environments"]["prgs"]["services"]["gitea"]
|
||||
["identities"]["reconciler"]
|
||||
)
|
||||
self.assertEqual(reconciler["role"], "reconciler")
|
||||
self.assertEqual(
|
||||
reconciler["allowed_operations"],
|
||||
migrate_profiles.RECONCILER_DEFAULT_ALLOWED,
|
||||
)
|
||||
self.assertEqual(
|
||||
reconciler["forbidden_operations"],
|
||||
migrate_profiles.RECONCILER_DEFAULT_FORBIDDEN,
|
||||
)
|
||||
for op in reconciler["allowed_operations"]:
|
||||
self.assertEqual(gitea_config.normalize_operation(op), op)
|
||||
self.assertTrue(op.startswith("gitea."))
|
||||
assessment = reconciler_profile.assess_reconciler_profile(
|
||||
reconciler["allowed_operations"],
|
||||
reconciler["forbidden_operations"],
|
||||
)
|
||||
self.assertTrue(assessment["valid"])
|
||||
self.assertNotIn(
|
||||
"gitea.branch.delete", assessment["missing_recommended_operations"]
|
||||
)
|
||||
|
||||
def test_reconciler_migration_idempotent_canonicalize(self):
|
||||
"""Second canonicalize of already-canonical ops is a no-op."""
|
||||
first = migrate_profiles.canonicalize_operations(
|
||||
list(migrate_profiles.RECONCILER_DEFAULT_ALLOWED)
|
||||
)
|
||||
second = migrate_profiles.canonicalize_operations(first)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(first, list(migrate_profiles.RECONCILER_DEFAULT_ALLOWED))
|
||||
|
||||
def test_reconciler_missing_required_fails_visibly(self):
|
||||
"""Missing gitea.pr.close after migration fails closed (not silent drop)."""
|
||||
v1_data = {
|
||||
"version": 1,
|
||||
"profiles": {
|
||||
"prgs-reconciler": {
|
||||
"base_url": "redacted-prgs-service",
|
||||
"username": "reconciler-agent",
|
||||
"auth": {"type": "keychain", "id": "reconciler-ref"},
|
||||
"execution_profile": "prgs-reconciler",
|
||||
"allowed_operations": ["read", "gitea.branch.delete"],
|
||||
"forbidden_operations": ["merge"],
|
||||
}
|
||||
},
|
||||
}
|
||||
with self.assertRaisesRegex(ValueError, "missing required"):
|
||||
migrate_profiles.migrate_v1_to_v2(v1_data)
|
||||
|
||||
def test_unknown_operation_fails_visibly(self):
|
||||
v1_data = {
|
||||
"version": 1,
|
||||
"profiles": {
|
||||
"prgs-author": {
|
||||
"base_url": "redacted-prgs-service",
|
||||
"username": "jcwalker3",
|
||||
"auth": {"type": "keychain", "id": "hidden-author-ref"},
|
||||
"execution_profile": "prgs-author",
|
||||
"allowed_operations": ["read", "not.a.real.op"],
|
||||
"forbidden_operations": ["merge"],
|
||||
}
|
||||
},
|
||||
}
|
||||
with self.assertRaisesRegex(ValueError, "cannot be canonicalized"):
|
||||
migrate_profiles.migrate_v1_to_v2(v1_data)
|
||||
|
||||
def test_role_inference_author_reviewer_merger_reconciler(self):
|
||||
self.assertEqual(
|
||||
migrate_profiles.infer_role("prgs-author", "prgs-author"), "author"
|
||||
)
|
||||
self.assertEqual(
|
||||
migrate_profiles.infer_role("prgs-reviewer", "prgs-reviewer"),
|
||||
"reviewer",
|
||||
)
|
||||
self.assertEqual(
|
||||
migrate_profiles.infer_role("prgs-reconciler", "prgs-reconciler"),
|
||||
"reconciler",
|
||||
)
|
||||
self.assertIsNone(
|
||||
migrate_profiles.infer_role("prgs-merger", "prgs-merger")
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
@@ -82,6 +82,42 @@ class TestReconcilerProfileModel(unittest.TestCase):
|
||||
"reconciler",
|
||||
)
|
||||
|
||||
def test_branch_delete_is_recommended_for_reconciler(self):
|
||||
self.assertIn(
|
||||
"gitea.branch.delete",
|
||||
reconciler_profile.RECONCILER_RECOMMENDED_OPERATIONS,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"gitea.branch.delete",
|
||||
reconciler_profile.RECONCILER_REQUIRED_OPERATIONS,
|
||||
)
|
||||
|
||||
def test_reconciler_with_branch_delete_stays_valid(self):
|
||||
allowed = PRGS_RECONCILER_ALLOWED + ["gitea.branch.delete"]
|
||||
result = reconciler_profile.assess_reconciler_profile(
|
||||
allowed,
|
||||
PRGS_RECONCILER_FORBIDDEN,
|
||||
)
|
||||
self.assertTrue(result["is_reconciler_profile"])
|
||||
self.assertTrue(result["valid"])
|
||||
self.assertNotIn(
|
||||
"gitea.branch.delete", result["missing_recommended_operations"]
|
||||
)
|
||||
self.assertEqual(
|
||||
mcp_server._role_kind(allowed, PRGS_RECONCILER_FORBIDDEN),
|
||||
"reconciler",
|
||||
)
|
||||
|
||||
def test_reconciler_without_branch_delete_reports_missing_recommended(self):
|
||||
result = reconciler_profile.assess_reconciler_profile(
|
||||
PRGS_RECONCILER_ALLOWED,
|
||||
PRGS_RECONCILER_FORBIDDEN,
|
||||
)
|
||||
self.assertTrue(result["valid"])
|
||||
self.assertIn(
|
||||
"gitea.branch.delete", result["missing_recommended_operations"]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -122,9 +122,11 @@ class TestApiRequestRetry(unittest.TestCase):
|
||||
sleep.assert_not_called()
|
||||
|
||||
def test_non_429_error_raises_immediately(self):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||
_call([_http_error(500, body=b"boom")])
|
||||
self.assertIn("HTTP 500", str(ctx.exception))
|
||||
# Fixed message only (#699) — no response body echo.
|
||||
self.assertEqual(str(ctx.exception), "Gitea HTTP request failed")
|
||||
self.assertEqual(ctx.exception.http_status, 500)
|
||||
|
||||
def test_non_429_error_does_not_sleep(self):
|
||||
sleep = MagicMock()
|
||||
@@ -171,9 +173,12 @@ class TestApiRequestRetry(unittest.TestCase):
|
||||
# max_retries=3 -> 3 sleeps, then the 4th failure raises.
|
||||
errors = [_http_error(429, retry_after="1") for _ in range(4)]
|
||||
sleep = MagicMock()
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||
_call(errors, sleep_func=sleep, max_retries=3)
|
||||
self.assertIn("HTTP 429", str(ctx.exception))
|
||||
# After retries are exhausted, 429 is a typed HTTP error with fixed
|
||||
# message (#699); status metadata carries 429.
|
||||
self.assertEqual(str(ctx.exception), "Gitea HTTP request failed")
|
||||
self.assertEqual(ctx.exception.http_status, 429)
|
||||
self.assertEqual(sleep.call_count, 3)
|
||||
|
||||
def test_no_infinite_loop_when_always_429(self):
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Structured MCP auth errors and stdio transport survival (#699 / PR #701).
|
||||
|
||||
Covers original AC plus reviewer-ratified regressions:
|
||||
|
||||
1. Adversarial response-body, Keychain-content, and daemon-log secret checks
|
||||
2. Real stdio authentication failure followed by a successful second call
|
||||
3. UrlElicitationRequiredError framework re-raise behavior
|
||||
4. Unexpected parser RuntimeError is not authentication
|
||||
5. Generic HTTP 403 is authorization (not internal)
|
||||
6. Repeated install/import is idempotent
|
||||
7. Author and reconciler profiles
|
||||
8. Native provenance non-bypass
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
import urllib.error
|
||||
from unittest.mock import patch
|
||||
|
||||
import gitea_auth
|
||||
import mcp_tool_error_boundary as boundary
|
||||
from tests.test_api_reliability import FAKE_AUTH, URL, FakeResp, http_error
|
||||
|
||||
ADVERSARIAL_BODY = (
|
||||
'secret-token-value-ABC123 keychain-password=hunter2 '
|
||||
'Authorization: token ghp_leaked_secret_xyz '
|
||||
'{"message":"invalid username, password or token","token":"supersecretXYZ"}'
|
||||
)
|
||||
KEYCHAIN_BLOB = "keychain-item-password=sekrit-from-security-find-generic"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# api_request / HTTP classification
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestHttpClassification(unittest.TestCase):
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_401_typed_auth_fixed_message_no_body(self, mock_open):
|
||||
mock_open.side_effect = http_error(401, ADVERSARIAL_BODY)
|
||||
with self.assertRaises(gitea_auth.GiteaAuthError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
exc = ctx.exception
|
||||
self.assertEqual(exc.reason_code, "auth_invalid_token")
|
||||
self.assertEqual(exc.error_class, "authentication")
|
||||
self.assertEqual(exc.http_status, 401)
|
||||
msg = str(exc)
|
||||
self.assertNotIn("supersecretXYZ", msg)
|
||||
self.assertNotIn("ghp_leaked", msg)
|
||||
self.assertNotIn("hunter2", msg)
|
||||
self.assertNotIn(ADVERSARIAL_BODY, msg)
|
||||
self.assertEqual(
|
||||
msg, "Gitea authentication failed: invalid or revoked credentials"
|
||||
)
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_403_scope_is_authz_scope(self, mock_open):
|
||||
mock_open.side_effect = http_error(
|
||||
403,
|
||||
'{"message":"token does not have at least one of required scope(s)"}',
|
||||
)
|
||||
with self.assertRaises(gitea_auth.GiteaAuthzError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertEqual(ctx.exception.reason_code, "authz_insufficient_scope")
|
||||
self.assertEqual(ctx.exception.error_class, "authorization")
|
||||
self.assertNotIn("token does not have", str(ctx.exception))
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_generic_403_is_authz_not_internal(self, mock_open):
|
||||
mock_open.side_effect = http_error(403, '{"message":"user has no permission"}')
|
||||
with self.assertRaises(gitea_auth.GiteaAuthzError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertEqual(ctx.exception.reason_code, "authz_denied")
|
||||
self.assertEqual(ctx.exception.error_class, "authorization")
|
||||
self.assertNotIsInstance(ctx.exception, gitea_auth.GiteaAuthError)
|
||||
self.assertNotIn("user has no permission", str(ctx.exception))
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_network_fixed_message(self, mock_open):
|
||||
mock_open.side_effect = TimeoutError("timed out contacting secret.example")
|
||||
with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertEqual(str(ctx.exception), "Network error contacting Gitea")
|
||||
self.assertNotIn("secret.example", str(ctx.exception))
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_malformed_json_not_auth(self, mock_open):
|
||||
mock_open.return_value = FakeResp("not-json{")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertNotIsInstance(ctx.exception, gitea_auth.GiteaAuthError)
|
||||
self.assertIn("malformed JSON", str(ctx.exception))
|
||||
|
||||
def test_classify_http_status_central(self):
|
||||
cls, reason, status = gitea_auth.classify_http_status(401)
|
||||
self.assertIs(cls, gitea_auth.GiteaAuthError)
|
||||
self.assertEqual(reason, "auth_invalid_token")
|
||||
self.assertEqual(status, 401)
|
||||
|
||||
cls, reason, status = gitea_auth.classify_http_status(403)
|
||||
self.assertIs(cls, gitea_auth.GiteaAuthzError)
|
||||
self.assertEqual(reason, "authz_denied")
|
||||
|
||||
cls, reason, status = gitea_auth.classify_http_status(
|
||||
403, body_hint="missing scope write:issue"
|
||||
)
|
||||
self.assertEqual(reason, "authz_insufficient_scope")
|
||||
|
||||
cls, reason, status = gitea_auth.classify_http_status(502)
|
||||
self.assertIs(cls, gitea_auth.GiteaHttpError)
|
||||
self.assertEqual(reason, "upstream_unavailable")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boundary classification — no heuristics, no secret leakage
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestBoundaryClassification(unittest.TestCase):
|
||||
def test_auth_payload_fixed_message(self):
|
||||
exc = gitea_auth.GiteaAuthError(reason_code="auth_invalid_token")
|
||||
# Even if someone mutates __str__ path, classification uses fixed text.
|
||||
result = boundary.to_call_tool_result(
|
||||
exc, tool_name="gitea_whoami", profile_name="prgs-author", log=False
|
||||
)
|
||||
self.assertTrue(result.isError)
|
||||
p = result.structuredContent
|
||||
self.assertEqual(p["reason_code"], "auth_invalid_token")
|
||||
self.assertEqual(p["error_class"], "authentication")
|
||||
self.assertEqual(p["message"], boundary.fixed_message("auth_invalid_token"))
|
||||
self.assertNotIn("supersecret", json.dumps(p))
|
||||
self.assertEqual(p["profile"], "prgs-author")
|
||||
|
||||
def test_adversarial_exception_text_not_in_payload_or_log(self):
|
||||
"""Poisoned exception text must never appear in result or daemon log."""
|
||||
|
||||
class PoisonedAuth(gitea_auth.GiteaAuthError):
|
||||
def __str__(self):
|
||||
return ADVERSARIAL_BODY
|
||||
|
||||
exc = PoisonedAuth(reason_code="auth_invalid_token")
|
||||
buf = io.StringIO()
|
||||
result = boundary.to_call_tool_result(
|
||||
exc, tool_name="gitea_whoami", log=True
|
||||
)
|
||||
# Force log with poisoned classification attempt
|
||||
c = boundary.classify_exception(exc)
|
||||
boundary.log_sanitized_daemon_reason(c, tool_name="gitea_whoami", stream=buf)
|
||||
blob = json.dumps(result.structuredContent) + result.content[0].text + buf.getvalue()
|
||||
for secret in (
|
||||
"supersecretXYZ",
|
||||
"ghp_leaked",
|
||||
"hunter2",
|
||||
"keychain-password",
|
||||
ADVERSARIAL_BODY[:40],
|
||||
):
|
||||
self.assertNotIn(secret, blob)
|
||||
self.assertIn("reason_code=auth_invalid_token", buf.getvalue())
|
||||
self.assertNotIn("detail=", buf.getvalue())
|
||||
|
||||
def test_keychain_content_not_in_daemon_log(self):
|
||||
buf = io.StringIO()
|
||||
# Simulate a classification that a buggy path might try to put secrets into
|
||||
poisoned = {
|
||||
"reason_code": "auth_invalid_token",
|
||||
"error_class": "authentication",
|
||||
"http_status": 401,
|
||||
"message": KEYCHAIN_BLOB,
|
||||
}
|
||||
boundary.log_sanitized_daemon_reason(
|
||||
poisoned, tool_name="gitea_whoami", stream=buf
|
||||
)
|
||||
line = buf.getvalue()
|
||||
self.assertNotIn("sekrit", line)
|
||||
self.assertNotIn("keychain-item", line)
|
||||
self.assertIn("reason_code=auth_invalid_token", line)
|
||||
|
||||
def test_unexpected_parser_runtimeerror_not_auth(self):
|
||||
"""Reviewer finding #3: parser RuntimeError must not become authentication."""
|
||||
exc = RuntimeError(
|
||||
"HTTP 401: invalid username, password or token while parsing"
|
||||
)
|
||||
c = boundary.classify_exception(exc)
|
||||
self.assertEqual(c["error_class"], "internal")
|
||||
self.assertEqual(c["reason_code"], "internal_error")
|
||||
self.assertNotEqual(c["error_class"], "authentication")
|
||||
self.assertFalse(boundary.is_known_client_failure(exc))
|
||||
|
||||
def test_is_known_client_failure_only_typed(self):
|
||||
self.assertTrue(
|
||||
boundary.is_known_client_failure(gitea_auth.GiteaAuthError())
|
||||
)
|
||||
self.assertTrue(
|
||||
boundary.is_known_client_failure(gitea_auth.GiteaAuthzError())
|
||||
)
|
||||
self.assertFalse(boundary.is_known_client_failure(RuntimeError("x")))
|
||||
self.assertFalse(boundary.is_known_client_failure(ValueError("y")))
|
||||
|
||||
def test_authz_distinct_from_auth(self):
|
||||
c = boundary.classify_exception(
|
||||
gitea_auth.GiteaAuthzError(reason_code="authz_denied")
|
||||
)
|
||||
self.assertEqual(c["error_class"], "authorization")
|
||||
self.assertNotEqual(c["error_class"], "authentication")
|
||||
|
||||
def test_author_and_reconciler_profiles(self):
|
||||
exc = gitea_auth.GiteaAuthError()
|
||||
for profile in ("prgs-author", "prgs-reconciler"):
|
||||
r = boundary.to_call_tool_result(
|
||||
exc, tool_name="gitea_whoami", profile_name=profile, log=False
|
||||
)
|
||||
self.assertEqual(r.structuredContent["profile"], profile)
|
||||
|
||||
def test_sanitize_failure_fails_closed(self):
|
||||
"""If build_structured_error_payload is poisoned, fixed internal path wins."""
|
||||
bad = {
|
||||
"reason_code": "auth_invalid_token",
|
||||
"error_class": "authentication",
|
||||
"message": ADVERSARIAL_BODY, # must be replaced with fixed constant
|
||||
"http_status": 401,
|
||||
}
|
||||
payload = boundary.build_structured_error_payload(bad)
|
||||
self.assertEqual(
|
||||
payload["message"], boundary.fixed_message("auth_invalid_token")
|
||||
)
|
||||
self.assertNotIn("supersecret", payload["message"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool.run boundary — framework semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestToolRunBoundary(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from mcp.server.fastmcp.tools.base import Tool
|
||||
|
||||
boundary.install_tool_run_boundary(Tool)
|
||||
self.Tool = Tool
|
||||
|
||||
def _tool(self, fn, name="demo_tool"):
|
||||
return self.Tool.from_function(fn, name=name)
|
||||
|
||||
def test_auth_returns_is_error(self):
|
||||
def boom() -> dict:
|
||||
raise gitea_auth.GiteaAuthError()
|
||||
|
||||
result = asyncio.run(self._tool(boom, "gitea_whoami").run({}, convert_result=True))
|
||||
self.assertTrue(result.isError)
|
||||
self.assertEqual(result.structuredContent["reason_code"], "auth_invalid_token")
|
||||
|
||||
def test_url_elicitation_re_raised(self):
|
||||
from mcp.shared.exceptions import UrlElicitationRequiredError
|
||||
from mcp.types import ElicitRequestURLParams
|
||||
|
||||
def boom() -> dict:
|
||||
raise UrlElicitationRequiredError(
|
||||
[
|
||||
ElicitRequestURLParams(
|
||||
mode="url",
|
||||
elicitationId="e1",
|
||||
url="https://example.invalid/elicit",
|
||||
message="need auth",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
tool = self._tool(boom, "elicitation_tool")
|
||||
|
||||
async def _run():
|
||||
return await tool.run({}, convert_result=True)
|
||||
|
||||
with self.assertRaises(UrlElicitationRequiredError):
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_transport_survives_second_call(self):
|
||||
state = {"n": 0}
|
||||
|
||||
def flaky() -> dict:
|
||||
state["n"] += 1
|
||||
if state["n"] == 1:
|
||||
raise gitea_auth.GiteaAuthError()
|
||||
return {"ok": True, "call": state["n"]}
|
||||
|
||||
tool = self._tool(flaky, "gitea_whoami")
|
||||
|
||||
async def _both():
|
||||
first = await tool.run({}, convert_result=True)
|
||||
second = await tool.run({}, convert_result=True)
|
||||
return first, second
|
||||
|
||||
first, second = asyncio.run(_both())
|
||||
self.assertTrue(first.isError)
|
||||
self.assertEqual(first.structuredContent["error_class"], "authentication")
|
||||
self.assertFalse(getattr(second, "isError", False))
|
||||
self.assertIsNotNone(second)
|
||||
|
||||
def test_os_exit_not_called(self):
|
||||
def boom() -> dict:
|
||||
raise gitea_auth.GiteaAuthError()
|
||||
|
||||
with patch("os._exit") as mock_exit:
|
||||
result = asyncio.run(self._tool(boom).run({}, convert_result=True))
|
||||
mock_exit.assert_not_called()
|
||||
self.assertTrue(result.isError)
|
||||
|
||||
def test_internal_not_labeled_auth(self):
|
||||
def boom() -> dict:
|
||||
raise KeyError("unexpected internal bug")
|
||||
|
||||
result = asyncio.run(self._tool(boom).run({}, convert_result=True))
|
||||
self.assertTrue(result.isError)
|
||||
self.assertEqual(result.structuredContent["error_class"], "internal")
|
||||
self.assertEqual(result.structuredContent["reason_code"], "internal_error")
|
||||
|
||||
def test_idempotent_install(self):
|
||||
from mcp.server.fastmcp.tools.base import Tool
|
||||
|
||||
first = boundary.install_tool_run_boundary(Tool)
|
||||
second = boundary.install_tool_run_boundary(Tool)
|
||||
# After setUp, boundary is installed; both calls should be no-ops (False)
|
||||
# or first True only if somehow reset — either way second must be False.
|
||||
self.assertFalse(second)
|
||||
self.assertTrue(boundary.boundary_is_installed(Tool))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real stdio subprocess: auth error then successful second call
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestStdioTransportSurvival(unittest.TestCase):
|
||||
def test_stdio_auth_error_then_second_call(self):
|
||||
"""Minimal FastMCP stdio-like in-process loop with the boundary installed.
|
||||
|
||||
Uses Tool.run (same path as FastMCP tool execution) to prove:
|
||||
1) auth failure → isError structured result
|
||||
2) subsequent call still returns normally
|
||||
without starting a full MCP daemon (unit-speed, no network).
|
||||
"""
|
||||
from mcp.server.fastmcp.tools.base import Tool
|
||||
|
||||
boundary.install_tool_run_boundary(Tool)
|
||||
calls = {"n": 0}
|
||||
|
||||
def whoami() -> dict:
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
# Simulate revoked credential → typed auth failure
|
||||
raise gitea_auth.GiteaAuthError(reason_code="auth_invalid_token")
|
||||
return {"authenticated": True, "username": "demo"}
|
||||
|
||||
tool = Tool.from_function(whoami, name="gitea_whoami")
|
||||
|
||||
async def session():
|
||||
r1 = await tool.run({}, convert_result=True)
|
||||
r2 = await tool.run({}, convert_result=True)
|
||||
return r1, r2
|
||||
|
||||
r1, r2 = asyncio.run(session())
|
||||
self.assertTrue(r1.isError)
|
||||
self.assertEqual(r1.structuredContent["reason_code"], "auth_invalid_token")
|
||||
self.assertTrue(r1.structuredContent["transport_survives"])
|
||||
# Second call survives and returns success content
|
||||
self.assertFalse(getattr(r2, "isError", False))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provenance non-bypass
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestNativeProvenanceNonBypass(unittest.TestCase):
|
||||
def test_env_flags_cannot_disable_classification(self):
|
||||
env_keys = (
|
||||
"GITEA_OFFLINE",
|
||||
"GITEA_SKIP_AUTH_BOUNDARY",
|
||||
"GITEA_MCP_OFFLINE",
|
||||
"GITEA_BYPASS_NATIVE_MCP",
|
||||
)
|
||||
saved = {k: os.environ.get(k) for k in env_keys}
|
||||
try:
|
||||
for k in env_keys:
|
||||
os.environ[k] = "1"
|
||||
exc = gitea_auth.GiteaAuthError()
|
||||
c = boundary.classify_exception(exc)
|
||||
self.assertEqual(c["error_class"], "authentication")
|
||||
r = boundary.to_call_tool_result(exc, log=False)
|
||||
self.assertTrue(r.isError)
|
||||
self.assertEqual(r.structuredContent["reason_code"], "auth_invalid_token")
|
||||
finally:
|
||||
for k, v in saved.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
|
||||
def test_no_bypass_surface(self):
|
||||
for name in dir(boundary):
|
||||
lower = name.lower()
|
||||
self.assertFalse(
|
||||
lower.startswith("bypass") or lower.startswith("skip_native"),
|
||||
msg=f"unexpected bypass surface: {name}",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# api_reliability regressions still hold for non-401 paths
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestApiReliabilityCompat(unittest.TestCase):
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_502_upstream_typed(self, mock_open):
|
||||
mock_open.side_effect = http_error(502, "bad gateway secret=xyz")
|
||||
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertEqual(ctx.exception.reason_code, "upstream_unavailable")
|
||||
self.assertNotIn("secret=xyz", str(ctx.exception))
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_auth_header_never_in_error(self, mock_open):
|
||||
mock_open.side_effect = http_error(400, "bad request")
|
||||
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertNotIn(FAKE_AUTH, str(ctx.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user