Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c780ded653 | ||
|
|
67e4a2b5e9 | ||
|
|
ba7915452e | ||
|
|
293808b42d | ||
|
|
970e68bddb | ||
|
|
80f59b334e | ||
|
|
77746b08fc | ||
|
|
1c37e62014 | ||
|
|
137426f7ad | ||
|
|
5b0d7aed0a | ||
|
|
1ab384adc9 | ||
|
|
573e721437 | ||
|
|
2b359e0c26 | ||
|
|
9cb12ee0f4 | ||
|
|
ec5cf67771 | ||
|
|
6b675f5c83 | ||
|
|
b4d0cb22e4 | ||
|
|
4a63578003 | ||
|
|
c7a444eb4b | ||
|
|
8cac50b2e7 |
@@ -55,3 +55,12 @@ GITEA_MCP_PROFILE=prgs
|
||||
# GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456
|
||||
# GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456
|
||||
# GITEA_ACTIVE_WORKTREE=/path/to/repo/branches/session-override
|
||||
|
||||
# Durable HMAC key for irrecoverable decision-lock provenance artifacts (#709 F4).
|
||||
# REQUIRED in production native MCP processes that mint or verify recovery
|
||||
# authorization (reconciler + merger must share the same key). Hex (preferred),
|
||||
# base64, or utf-8 literal (>=16 bytes). Never generate an ephemeral per-process
|
||||
# key — cross-process / post-restart verification would fail closed.
|
||||
# GITEA_IRRECOVERABLE_AUTH_HMAC_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
# Optional key version string bound into the signature (for rotation).
|
||||
# GITEA_IRRECOVERABLE_AUTH_HMAC_KEY_VERSION=v1
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# ADR: Stable control runtime vs dev runtime (Gitea MCP)
|
||||
|
||||
- **Status:** Accepted (policy effective immediately for LLM sessions; tooling may lag)
|
||||
- **Date:** 2026-07-09
|
||||
- **Tracking issue:** [#615](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/615)
|
||||
- **Related:**
|
||||
- [#543](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/543) / `docs/mcp-namespace-health.md` — client-namespace health
|
||||
- `docs/mcp-namespace-eof-recovery.md` — reconnect-only EOF recovery (no PID kill)
|
||||
- [#558](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/558) / `docs/mcp-daemon-import-guard.md` — sanctioned daemon
|
||||
- [#557](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/557) / `docs/bootstrap-review-path.md` — controller bootstrap for self-hosted fixes
|
||||
- Allocator / control-plane ADR: `docs/architecture/mcp-allocator-control-plane-observability-adr.md` (#613 / PR #614)
|
||||
|
||||
## 1. Context
|
||||
|
||||
The Gitea MCP server is the **control plane** for real issue/PR mutations (create, comment, lock, review, merge, etc.). When author/reviewer/merger/reconciler sessions kill or restart that process, relaunch it from a feature worktree, or edit the checkout that process loads, operators observe:
|
||||
|
||||
- Mid-session identity/preflight resets
|
||||
- Stale-runtime vs master parity failures
|
||||
- IDE transport EOF / “tool not found” while code on disk has changed
|
||||
- Accidental production mutations from experimental code
|
||||
|
||||
This ADR separates **stable control runtime** from **dev/test runtime** and defines promotion proof.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
### 2.1 Stable control runtime
|
||||
|
||||
The Gitea MCP server used for **real workflow mutations** is the **stable control runtime**.
|
||||
|
||||
Characteristics:
|
||||
|
||||
- Loads a known, promoted revision of Gitea-Tools (or the packaged release layout operators designate)
|
||||
- Registered in the IDE/client as the production namespaces (`gitea-tools`, `gitea-reviewer`, `gitea-merger`, `gitea-reconciler`, etc.)
|
||||
- Holds production profile credentials via sanctioned keychain/env paths only
|
||||
|
||||
### 2.2 Dev / test runtime
|
||||
|
||||
MCP **server code** development and testing:
|
||||
|
||||
- Happens in isolated **`branches/`** worktrees (or other non-stable checkouts)
|
||||
- May use a **separate** dev/test MCP runtime/process when process-level testing is required
|
||||
- **Must not** be used for real Gitea mutations on production issues/PRs
|
||||
|
||||
### 2.3 Forbidden actions (normal sessions)
|
||||
|
||||
Normal **author, reviewer, merger, and reconciler** LLM sessions **must not**:
|
||||
|
||||
| Forbidden | Why |
|
||||
|-----------|-----|
|
||||
| Kill the running MCP server process | Drops all concurrent sessions; loses preflight state |
|
||||
| Restart / relaunch the MCP server process | Same as kill; causes stale/identity churn mid-workflow |
|
||||
| Relaunch MCP from a development worktree | Runs unpromoted code against production mutations |
|
||||
| Edit files in the stable runtime checkout | Hot-mutates control plane under concurrent users |
|
||||
| Use experimental/dev MCP for real Gitea mutations | Bypasses promotion proof and audit expectations |
|
||||
| Bypass or self-reset a stale master-parity gate | The gate is fail-closed; only an operator reload restores parity |
|
||||
|
||||
**LLM-allowed vs operator-owned (authoritative split):**
|
||||
|
||||
| Actor | May do | Must not do |
|
||||
|-------|--------|-------------|
|
||||
| **LLM session** | Call tools on the already-running stable namespaces; **client reconnect** after transport EOF (no process kill); pass `worktree_path` / role worktree args; report blockers and stop mutations when unhealthy/stale | Kill, restart, or relaunch any MCP process; bump config mtimes to force reload; edit the stable checkout; switch to a dev MCP for production mutations |
|
||||
| **Operator / release-manager** | Supervised restart/reload of the **stable** control runtime; dual-namespace client configuration; §2.4 promotions; incident recovery | — |
|
||||
|
||||
EOF / transport recovery for LLM sessions: **client reconnect only** (see `docs/mcp-namespace-eof-recovery.md`). Do not “fix” health by killing PIDs or bumping MCP config mtimes as a normal session procedure.
|
||||
|
||||
This ADR **supersedes** any older runbook wording that told the LLM to relaunch or restart the client/MCP as a self-service step. Where `docs/llm-workflow-runbooks.md` (or wiki runbooks) discuss dual-namespace setup or workspace rebind, **process restart/relaunch is operator-owned**; the LLM stops, reports, and waits.
|
||||
|
||||
### 2.4 Promotion (operator / release-manager only)
|
||||
|
||||
Promotion of a new revision into the stable control runtime is an **explicit operator/release-manager action**, not an LLM self-service step.
|
||||
|
||||
A promotion **must record** (issue comment, release note, or promotion ledger):
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **previous runtime SHA** | Commit previously loaded by stable runtime |
|
||||
| **promoted runtime SHA** | Commit after promotion |
|
||||
| **source branch/PR** | Where the change was reviewed |
|
||||
| **restart/reload method** | How the process was cycled (e.g. supervised restart, client reload) |
|
||||
| **health check proof** | Client-namespace probe success (`gitea_whoami` / namespace health) |
|
||||
| **identity/profile proof** | Expected profile(s) and username(s) after reload |
|
||||
| **workspace/root proof** | Stable checkout path / root matches intended layout |
|
||||
| **mutation capability proof** | Required permissions for the target role present; forbidden ops still forbidden |
|
||||
| **rollback instructions** | How to restore previous SHA and re-verify health |
|
||||
|
||||
Suggested durable marker:
|
||||
|
||||
```text
|
||||
## MCP STABLE RUNTIME PROMOTION (#615)
|
||||
|
||||
Status: COMPLETED | ROLLED_BACK | ABORTED
|
||||
Previous-SHA: <full sha>
|
||||
Promoted-SHA: <full sha>
|
||||
Source-PR: <number>
|
||||
Source-Branch: <name>
|
||||
Reload-Method: <text>
|
||||
Health-Proof: client_namespace whoami OK / assess_mcp_namespace_health OK
|
||||
Identity-Proof: profile=<name> user=<name>
|
||||
Workspace-Proof: root=<path>
|
||||
Mutation-Proof: allowed_ops include <…>; forbidden include <…>
|
||||
Rollback: checkout <previous sha>; reload method <…>; re-run health/identity proofs
|
||||
Operator: <username>
|
||||
Timestamp: <ISO-8601>
|
||||
```
|
||||
|
||||
### 2.5 Unhealthy stable runtime → stop work
|
||||
|
||||
If the stable MCP runtime is **unhealthy**, including any of:
|
||||
|
||||
- client-namespace probes fail
|
||||
- wrong identity / wrong profile
|
||||
- wrong workspace root
|
||||
- missing mutation capability for the intended role
|
||||
- persistent EOF after **client reconnect**
|
||||
- **master parity is stale** (`startup_head` behind on-disk `master` / `restart_required` from the parity gate)
|
||||
|
||||
then:
|
||||
|
||||
1. **Normal PR / review / merge / issue-mutation work must stop immediately.**
|
||||
2. The session **reports** the unhealthy/stale state (tool error, CTH, or operator handoff) with startup vs current head when known.
|
||||
3. Do **not** improvise: no LLM process kill/restart, no dev-worktree MCP for production mutations, no env escape hatches, no manual gate bypass.
|
||||
4. Resume only after:
|
||||
- an **operator** restores the runtime (see §2.6 for routine post-merge parity reload), **or**
|
||||
- a **controlled** promotion/rollback completes with the §2.4 promotion record, **or**
|
||||
- a controller invokes the narrow **bootstrap review path** (#557) when the defect is self-hosted and documented,
|
||||
- **and** the session re-verifies health (and master parity when applicable) before the next mutation.
|
||||
|
||||
### 2.6 Routine post-merge master-parity staleness (operator reload, not promotion)
|
||||
|
||||
**Symptom:** After merges land on `master`, a long-lived stable MCP process still runs the pre-merge `startup_head`. The master-parity gate marks the server **stale** / `restart_required` and **blocks mutations**.
|
||||
|
||||
**Sanctioned response (authoritative):**
|
||||
|
||||
| Step | Actor | Action |
|
||||
|------|-------|--------|
|
||||
| 1 | LLM session | Mutations stop immediately when the gate reports stale. |
|
||||
| 2 | LLM session | Report the stale state (startup head, current master head, that operator reload is required). Do not retry mutations. |
|
||||
| 3 | **Operator** | Reload/restart the **stable** control MCP so it loads current `master` (supervised client/daemon reload). |
|
||||
| 4 | LLM session | Resume only after startup/current-head parity is verified (e.g. `gitea_get_runtime_context` / parity assessment shows in parity). |
|
||||
|
||||
**Not a §2.4 promotion:** Catching the already-designated stable control checkout up to a newly advanced `master` is a **routine operator reload** of the stable runtime. It does **not** require the nine-field promotion ledger. Use §2.4 only when **changing which unpromoted/dev revision** becomes the stable control runtime (new source branch/PR into the stable designation).
|
||||
|
||||
**Code note:** `master_parity_gate.py` may still say “restart the server” in machine-facing reason strings. That string names the **operator recovery action**, not an LLM self-service instruction. This ADR and the runbooks define the actor split.
|
||||
|
||||
## 3. Relationship to other controls
|
||||
|
||||
| Doc / mechanism | Interaction |
|
||||
|-----------------|-------------|
|
||||
| Namespace health (#543) | Proves IDE client can call tools; does not authorize restart |
|
||||
| EOF recovery | Reconnect only; no process kill |
|
||||
| Daemon import guard (#558) | Mutations require sanctioned daemon; not a bare shell import |
|
||||
| Bootstrap path (#557) | Only controller-authorized exception when live runtime cannot review its own fix |
|
||||
| Allocator / control-plane ADR | Coordination DB is separate; still depends on a healthy MCP surface for Gitea writes |
|
||||
|
||||
## 4. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Predictable control plane for concurrent LLMs
|
||||
- Clear operator-only promotion gate with rollback
|
||||
- Aligns session behavior with health/EOF docs already landed
|
||||
|
||||
### Costs
|
||||
|
||||
- LLM sessions must wait when runtime is sick (no DIY restart)
|
||||
- Operators must maintain promotion discipline and dual-runtime config if they use a dev MCP
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Does not ban operator-supervised restarts during incidents
|
||||
- Does not replace CI or code review for MCP changes
|
||||
- Does not authorize editing stable checkout “because tests need a quick fix”
|
||||
|
||||
## 5. Implementation follow-ups (optional tooling)
|
||||
|
||||
These may land in later issues; the **policy binds sessions now**:
|
||||
|
||||
1. Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”
|
||||
2. Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.
|
||||
3. Promotion checklist script that emits the durable promotion marker fields.
|
||||
|
||||
**Not optional (issue #615 acceptance criterion 2):** operator guide and runbooks **must** cross-link this ADR (see §6). Cross-links are documentation acceptance, not deferred tooling.
|
||||
|
||||
## 6. Acceptance for this ADR
|
||||
|
||||
1. Document merged under `docs/architecture/mcp-stable-control-runtime-policy-adr.md`.
|
||||
2. **Operator guide / runbooks cross-link this ADR** (`docs/wiki/Operator-Guide.md`, `docs/wiki/Runbooks.md`, `docs/llm-workflow-runbooks.md`).
|
||||
3. Issue #615 references this path.
|
||||
4. LLM/operator runbooks treat kill/restart/relaunch-from-worktree as **LLM violations**; process restart is **operator-owned**.
|
||||
5. Unhealthy runtime (including **stale master parity**) stops normal mutation work until operator restore/reload, promotion/rollback, or #557 bootstrap — then re-verify parity before mutating.
|
||||
6. Routine post-merge parity reload is documented as operator reload (§2.6), not an LLM self-restart and not a full §2.4 promotion.
|
||||
|
||||
## 7. Document history
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-09 | Initial ADR: stable vs dev runtime, forbidden session actions, promotion proof fields, stop-work rule |
|
||||
| 2026-07-16 | Review 443 remediation (#615 / PR #616): mandatory cross-links; LLM vs operator restart split; routine post-merge parity staleness (§2.6); stale parity in §2.5 unhealthy triggers |
|
||||
@@ -45,7 +45,6 @@ authenticated capability set. Each profile defines the following fields:
|
||||
| `authenticated_username` | string | The Gitea login this profile authenticates as (verified at runtime via `gitea_whoami`, not trusted from config). |
|
||||
| `allowed_operations` | list | Operation categories this profile may perform. |
|
||||
| `forbidden_operations` | list | Operation categories this profile must never perform. |
|
||||
| `allowed_repositories` | list | Optional. Canonical `owner/repository` slugs this profile may bind to. An authorization boundary, not the binding itself — see [Repository scope](#repository-scope-714). |
|
||||
| `token_source_name` | string | The *name* of the secret source (e.g. env var name or secret key). **Never the token value.** |
|
||||
| `audit_label` | string | Short label attached to audit records for actions by this profile. |
|
||||
| `can_approve_prs` | bool | May submit an approving PR review. |
|
||||
@@ -58,47 +57,6 @@ authenticated capability set. Each profile defines the following fields:
|
||||
name), never the token itself. Token values are never part of a profile object,
|
||||
never logged, never returned by a tool, and never committed.
|
||||
|
||||
## Repository scope (#714)
|
||||
|
||||
`allowed_repositories` declares the canonical `owner/repository` slugs a profile
|
||||
may operate on:
|
||||
|
||||
```json
|
||||
"prgs-author": {
|
||||
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"]
|
||||
}
|
||||
```
|
||||
|
||||
It is an **authorization boundary, not the session binding**. The binding is
|
||||
derived and enforced like this:
|
||||
|
||||
1. The session repository is derived from the **verified, workspace-aligned git
|
||||
remote** — never from a caller-supplied `org`/`repo` argument, and never from
|
||||
the `REMOTES` table (whose entries are default *targets*: `prgs` defaults to
|
||||
`Timesheet`, which is not this project).
|
||||
2. That workspace-derived slug is validated against `allowed_repositories`.
|
||||
3. The session binds immutably to that one canonical `owner/repository`. The
|
||||
organization is derived from the slug; there is no independent,
|
||||
caller-controlled organization value.
|
||||
4. If a profile authorizes several repositories, the verified workspace still
|
||||
selects exactly one. A session never binds to the whole list and never
|
||||
switches between entries.
|
||||
|
||||
Fail-closed rules:
|
||||
|
||||
- Activation is rejected when the workspace repository is absent from the
|
||||
allowlist.
|
||||
- A mutation is rejected when no verified workspace repository can be
|
||||
established.
|
||||
- A tool-level `org`/`repo` override that disagrees with the binding is rejected
|
||||
before the mutation runs.
|
||||
- A mutation request can never establish, complete, or replace the binding.
|
||||
|
||||
The field is **config-only**: no environment variable can widen or forge it. It
|
||||
is optional — a profile that omits it keeps the previous behaviour, so existing
|
||||
static-profile namespaces stay functional until an operator provisions the
|
||||
scope. Provisioning it is what activates enforcement for that profile.
|
||||
|
||||
## Example profiles
|
||||
|
||||
The following are the reference profiles. Booleans express intended capability
|
||||
@@ -280,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
|
||||
@@ -293,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:
|
||||
|
||||
@@ -195,7 +195,7 @@ To avoid the bottleneck of relaunching/restarting the MCP server to switch betwe
|
||||
`gitea_reconcile_already_landed_pr` after ancestry proof — not for normal
|
||||
review or author workflows.
|
||||
|
||||
* **Fallback:** If the dual-profile MCP launcher pattern is not supported or configured in the client, the LLM must relaunch or restart the client/MCP with the correct profile environment variable before claiming or working on any tasks.
|
||||
* **Fallback (operator-owned):** If the dual-profile MCP launcher pattern is not supported or configured in the client, **do not** have the LLM relaunch or restart the client/MCP. The LLM **stops** role-switching work, reports that the correct static namespace is missing, and waits for an **operator** to configure dual namespaces or reload the client with the correct `GITEA_MCP_PROFILE` for that role. Process restart/relaunch is operator-owned under the [stable control runtime ADR](architecture/mcp-stable-control-runtime-policy-adr.md) (#615).
|
||||
|
||||
## Setup runbook — interactive menu
|
||||
|
||||
@@ -1200,10 +1200,13 @@ When a mutation blocks on workspace binding:
|
||||
|
||||
1. Read the error — it names the **resolved workspace path**, **role
|
||||
namespace**, and **binding source** (tool arg, env var, or process root).
|
||||
2. Reconnect or relaunch the correct namespace MCP server from the intended
|
||||
workspace (or set the role-specific env var before launch).
|
||||
3. Pass `worktree_path` on reviewer/merger mutation tools when the active
|
||||
branches/ worktree differs from the MCP process root.
|
||||
2. **LLM-allowed:** pass `worktree_path` on reviewer/merger mutation tools when
|
||||
the active `branches/` worktree differs from the MCP process root; **client
|
||||
reconnect** after transport EOF only (no process kill).
|
||||
3. **Operator-owned:** if the wrong namespace process was launched, or a role-
|
||||
specific `GITEA_*_WORKTREE` must be set at process start, an **operator**
|
||||
reloads/relaunches the correct static namespace MCP. LLM sessions must not
|
||||
kill or restart MCP processes (see [stable control runtime ADR](architecture/mcp-stable-control-runtime-policy-adr.md)).
|
||||
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
|
||||
own namespace — that destroys another agent's WIP.
|
||||
|
||||
@@ -1213,9 +1216,10 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
||||
|
||||
- State which namespace was active (author / reviewer / merger / reconciler).
|
||||
- Quote the resolved workspace path and binding source from the error.
|
||||
- Name the safe reconnect action (relaunch MCP from `branches/...`, set
|
||||
`GITEA_*_WORKTREE`, or pass `worktree_path`).
|
||||
- Explicitly note that foreign worktrees must not be cleaned to unblock.
|
||||
- Name the safe next action: pass `worktree_path` if that unblocks the tool, or
|
||||
request an **operator** reload of the correct namespace MCP / env binding.
|
||||
- Explicitly note that foreign worktrees must not be cleaned to unblock, and
|
||||
that the LLM must not self-restart the MCP process.
|
||||
|
||||
## Safety notes
|
||||
|
||||
@@ -1226,6 +1230,7 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
||||
|
||||
## Related documents
|
||||
|
||||
- [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md) — stable control runtime vs dev runtime; LLM must not kill/restart MCP; operator-owned reload and promotions; routine post-merge parity staleness (#615).
|
||||
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
||||
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
||||
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||
|
||||
@@ -14,6 +14,7 @@ Handbook for LLM operators and human developers using the Gitea-Tools MCP server
|
||||
4. **No self-review / no self-merge** — The authenticated Gitea user must not approve or merge a PR they authored.
|
||||
5. **Follow the gates** — Prompts express intent; MCP tools enforce safety. Never bypass gates via prompt instructions.
|
||||
6. **Global LLM Worktree Rule** — Main checkout stays on `master`/`main`/`dev`; all mutations happen under `branches/`. Prove project root, `cwd`, branch, stable main-checkout branch, and session worktree path before editing. No exceptions.
|
||||
7. **Stable control runtime** — Real Gitea mutations use only the **stable** MCP control runtime. LLM sessions must not kill, restart, or relaunch MCP processes, edit the stable checkout, or use a dev MCP for production mutations. See the policy ADR: [mcp-stable-control-runtime-policy-adr.md](../architecture/mcp-stable-control-runtime-policy-adr.md) (#615).
|
||||
|
||||
## Supported Gitea instances
|
||||
|
||||
@@ -29,4 +30,5 @@ Always pass `remote` explicitly on tool calls. The server default is `dadeschool
|
||||
1. `gitea_whoami` — confirm authenticated user and profile.
|
||||
2. `gitea_get_runtime_context` — allowed/forbidden operations for this session.
|
||||
3. `gitea_resolve_task_capability` — prove the session may perform the planned task.
|
||||
4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations.
|
||||
4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations.
|
||||
5. Confirm master parity / runtime health when tools report stale or unhealthy control runtime — stop mutations and request an **operator** reload of the stable MCP (see [stable control runtime ADR](../architecture/mcp-stable-control-runtime-policy-adr.md)).
|
||||
@@ -12,6 +12,17 @@
|
||||
4. `gitea_mark_final_review_decision` → approve via `gitea_review_pr`.
|
||||
5. `gitea_merge_pr` with pinned head SHA and `confirmation="MERGE PR <n>"`.
|
||||
|
||||
## Stable MCP control runtime (#615)
|
||||
|
||||
Policy ADR: [mcp-stable-control-runtime-policy-adr.md](../architecture/mcp-stable-control-runtime-policy-adr.md).
|
||||
|
||||
| Situation | Who acts | What to do |
|
||||
|-----------|----------|------------|
|
||||
| Transport EOF / missing tools | LLM | **Client reconnect only** — do not kill PIDs |
|
||||
| Wrong profile / dual-namespace missing | Operator | Configure or reload the correct static namespace(s) |
|
||||
| Master parity stale after merge | LLM stops + reports; **operator** reloads stable MCP | Resume only after parity re-verified |
|
||||
| Promote unpromoted MCP code to stable | Operator / release-manager only | Full §2.4 promotion record |
|
||||
|
||||
## Gitea Wiki sync
|
||||
|
||||
The Gitea Wiki mirrors `docs/wiki/` (source of truth). After merging wiki changes:
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"execution_profile": "example-author",
|
||||
"audit_label": "example-author",
|
||||
"auth": { "type": "keychain", "id": "example-gitea-author-token" },
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
"allowed_operations": ["read", "branch", "commit", "push", "open_pr", "comment", "issue.comment"],
|
||||
"forbidden_operations": ["approve", "request_changes", "merge"]
|
||||
},
|
||||
|
||||
+210
-66
@@ -182,51 +182,11 @@ def get_auth_header(host):
|
||||
|
||||
def resolve_remote(args):
|
||||
"""Given parsed argparse args with --remote/--host/--org/--repo,
|
||||
return (host, org, repo) with overrides applied.
|
||||
|
||||
#714 / #530: when the caller omits org and/or repo, prefer the
|
||||
workspace-aligned git remote over REMOTES defaults (e.g. bare
|
||||
``--remote prgs`` must not force Timesheet when the checkout is
|
||||
Gitea-Tools). Explicit --org/--repo always win.
|
||||
"""
|
||||
return (host, org, repo) with overrides applied."""
|
||||
profile = REMOTES[args.remote]
|
||||
host = args.host or profile["host"]
|
||||
org_explicit = getattr(args, "org", None) is not None
|
||||
repo_explicit = getattr(args, "repo", None) is not None
|
||||
org = args.org if org_explicit else profile["org"]
|
||||
repo = args.repo if repo_explicit else profile["repo"]
|
||||
if not org_explicit or not repo_explicit:
|
||||
try:
|
||||
import remote_repo_guard
|
||||
import subprocess
|
||||
# Prefer the named remote URL when present; fall back to origin.
|
||||
url = None
|
||||
for remote_name in (args.remote, "origin"):
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "remote", "get-url", remote_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0 and (proc.stdout or "").strip():
|
||||
url = proc.stdout.strip()
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
# Allow tests to inject a deterministic remote URL without Git.
|
||||
env_url = os.environ.get("GITEA_TEST_WORKSPACE_REMOTE_URL")
|
||||
if env_url:
|
||||
url = env_url
|
||||
parsed = remote_repo_guard.parse_org_repo_from_remote_url(url)
|
||||
if parsed:
|
||||
if not org_explicit:
|
||||
org = parsed[0]
|
||||
if not repo_explicit:
|
||||
repo = parsed[1]
|
||||
except Exception:
|
||||
pass
|
||||
org = args.org or profile["org"]
|
||||
repo = args.repo or profile["repo"]
|
||||
return host, org, repo
|
||||
|
||||
|
||||
@@ -283,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.
|
||||
|
||||
@@ -355,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.
|
||||
@@ -409,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
|
||||
|
||||
|
||||
@@ -610,10 +758,6 @@ def get_profile():
|
||||
"profile_name": name,
|
||||
"allowed_operations": ops,
|
||||
"forbidden_operations": forbidden,
|
||||
# #714 repository authorization boundary. Config-only on purpose: an
|
||||
# environment variable must never widen or forge the set of
|
||||
# repositories a session may bind to.
|
||||
"allowed_repositories": _json_list("allowed_repositories"),
|
||||
"audit_label": audit_label,
|
||||
"token_source_name": token_source,
|
||||
"auth_source_type": auth_type,
|
||||
|
||||
@@ -475,33 +475,6 @@ def _require_enabled(kind, name, obj):
|
||||
return enabled
|
||||
|
||||
|
||||
_REPO_SCOPE_RE = re.compile(r"^[^/\s]+/[^/\s]+$")
|
||||
|
||||
|
||||
def _validate_allowed_repositories(name, raw):
|
||||
"""Validate the optional per-profile repository authorization scope (#714).
|
||||
|
||||
``allowed_repositories`` is an authorization boundary of canonical
|
||||
``owner/repository`` slugs. It is not the session binding: the verified
|
||||
workspace repository selects exactly one entry at bind time. Absent means
|
||||
"no repository scope configured" and is allowed, so existing profiles keep
|
||||
working until an operator provisions the field.
|
||||
"""
|
||||
if raw is None:
|
||||
return
|
||||
if not isinstance(raw, list):
|
||||
raise ConfigError(
|
||||
f"profile '{name}' allowed_repositories must be a list of "
|
||||
"'owner/repository' strings"
|
||||
)
|
||||
for entry in raw:
|
||||
if not isinstance(entry, str) or not _REPO_SCOPE_RE.match(entry.strip()):
|
||||
raise ConfigError(
|
||||
f"profile '{name}' allowed_repositories entry {entry!r} is not "
|
||||
"a canonical 'owner/repository' slug"
|
||||
)
|
||||
|
||||
|
||||
def _reject_inline_secrets(kind, name, obj):
|
||||
for key in _INLINE_SECRET_KEYS:
|
||||
if key in obj:
|
||||
@@ -576,7 +549,6 @@ def _load_v2_contexts(data, path):
|
||||
forbidden = raw.get("forbidden_operations") or []
|
||||
if not isinstance(allowed, list) or not isinstance(forbidden, list):
|
||||
raise ConfigError(f"profile '{name}' operation fields must be lists")
|
||||
_validate_allowed_repositories(name, raw.get("allowed_repositories"))
|
||||
allowed_n = {_normalize_op("gitea", op, name) for op in allowed}
|
||||
forbidden_n = {_normalize_op("gitea", op, name) for op in forbidden}
|
||||
# Reviewer-identity deadlock rule (#100/#103) applies here unchanged.
|
||||
|
||||
+2421
-829
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+318
-1
@@ -36,7 +36,31 @@ KIND_REVIEW_DRAFT = "review_draft"
|
||||
# other session proofs; a contaminated session fails closed on gated mutations
|
||||
# until a reconciler audits and clears it.
|
||||
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
|
||||
# #709: archive prior terminal decision ledgers instead of silent overwrite.
|
||||
KIND_DECISION_LOCK_ARCHIVE = "review_decision_lock_archive"
|
||||
# #709: post-merge cleanup/audit reconciliation-required durable record.
|
||||
KIND_POST_MERGE_DECISION_RECOVERY = "post_merge_decision_recovery"
|
||||
# #709: truthful record when historical terminal evidence is irrecoverably gone.
|
||||
KIND_IRRECOVERABLE_DECISION_PROVENANCE = "irrecoverable_decision_provenance"
|
||||
# #709 F1: server-side non-forgeable authorization artifact for recovery.
|
||||
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,
|
||||
KIND_IRRECOVERABLE_PROVENANCE_AUTH,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
||||
@@ -228,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,
|
||||
*,
|
||||
@@ -270,7 +304,9 @@ def identity_match_reasons(
|
||||
reasons.append("session state missing recorded_at timestamp (fail closed)")
|
||||
else:
|
||||
age = _now_utc() - recorded_at
|
||||
if age > timedelta(hours=ttl_hours()):
|
||||
kind = (record.get("kind") or "").strip()
|
||||
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)"
|
||||
)
|
||||
@@ -279,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,
|
||||
@@ -447,3 +590,177 @@ def clear_state(
|
||||
profile_identity=profile_identity,
|
||||
state_dir=state_dir,
|
||||
)
|
||||
|
||||
def list_decision_lock_profile_identities(
|
||||
state_dir: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Return profile identities that have a durable review_decision_lock file (#709).
|
||||
|
||||
Filename form: ``review_decision_lock-<profile>.json`` (see ``state_key``).
|
||||
Does not validate TTL or identity — callers must load via ``load_state``.
|
||||
"""
|
||||
root = (state_dir or default_state_dir()).strip()
|
||||
if not root or not os.path.isdir(root):
|
||||
return []
|
||||
prefix = f"{_sanitize_segment(KIND_DECISION_LOCK)}-"
|
||||
suffix = ".json"
|
||||
found: list[str] = []
|
||||
try:
|
||||
names = os.listdir(root)
|
||||
except OSError:
|
||||
return []
|
||||
for name in names:
|
||||
if not name.startswith(prefix) or not name.endswith(suffix):
|
||||
continue
|
||||
if name.endswith(".lock"):
|
||||
continue
|
||||
mid = name[len(prefix) : -len(suffix)]
|
||||
if mid:
|
||||
found.append(mid)
|
||||
return sorted(set(found))
|
||||
|
||||
|
||||
def load_state_for_profile(
|
||||
*,
|
||||
kind: str,
|
||||
profile_identity: str,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
skip_identity_match: bool = False,
|
||||
enforce_repo_scope: bool = True,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Load durable state for an explicit profile identity (#709 cross-profile).
|
||||
|
||||
When *skip_identity_match* is True, still requires the file's recorded
|
||||
profile_identity to equal the requested profile (anti-stomp), but does not
|
||||
require the *active* session identity to match — needed so a merger can
|
||||
inspect a reviewer lock after merge.
|
||||
|
||||
#709 F3: remote/org/repo filter reasons are **enforced** (not merely
|
||||
computed). When *enforce_repo_scope* is True (default) and the caller
|
||||
supplies remote/org/repo, mismatches fail closed with ``None``.
|
||||
"""
|
||||
# #709 F3: refuse traversal / malformed profile identities.
|
||||
try:
|
||||
from irrecoverable_provenance import assess_profile_path_identity
|
||||
|
||||
path_gate = assess_profile_path_identity(profile_identity)
|
||||
if not path_gate.get("valid"):
|
||||
return None
|
||||
except Exception:
|
||||
# Module may be mid-import in edge bootstraps; fall through to
|
||||
# conservative checks below.
|
||||
raw = str(profile_identity or "")
|
||||
if ".." in raw or "/" in raw or "\\" in raw or "\x00" in raw:
|
||||
return None
|
||||
|
||||
profile = current_profile_identity(profile_identity=profile_identity)
|
||||
root = _ensure_state_dir(state_dir)
|
||||
# Refuse symlink escape of the state root (#709 F3).
|
||||
try:
|
||||
real_root = os.path.realpath(root)
|
||||
path = state_file_path(
|
||||
kind=kind,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile,
|
||||
state_dir=root,
|
||||
)
|
||||
real_path = os.path.realpath(path) if os.path.exists(path) else path
|
||||
if os.path.exists(path) and not str(real_path).startswith(
|
||||
str(real_root) + os.sep
|
||||
) and str(real_path) != str(real_root):
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
path = state_file_path(
|
||||
kind=kind,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile,
|
||||
state_dir=root,
|
||||
)
|
||||
lock_path = f"{path}.lock"
|
||||
with _exclusive_file_lock(lock_path):
|
||||
envelope = _read_json(path)
|
||||
if not envelope:
|
||||
return None
|
||||
payload = envelope.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
merged = dict(payload)
|
||||
for key in (
|
||||
"kind",
|
||||
"remote",
|
||||
"org",
|
||||
"repo",
|
||||
"profile_identity",
|
||||
"session_profile_lock",
|
||||
"recorded_at",
|
||||
"updated_at",
|
||||
"writer_pid",
|
||||
):
|
||||
if key in envelope and key not in merged:
|
||||
merged[key] = envelope[key]
|
||||
stored = (merged.get("profile_identity") or "").strip()
|
||||
if stored and stored != profile:
|
||||
return None
|
||||
if skip_identity_match:
|
||||
# Enforce remote/org/repo when caller provides them (#709 F3).
|
||||
# Drop only *active session* profile-identity mismatches; keep
|
||||
# expiry, spoof, and repository-scope reasons.
|
||||
scope_remote = remote if enforce_repo_scope else None
|
||||
scope_org = org if enforce_repo_scope else None
|
||||
scope_repo = repo if enforce_repo_scope else None
|
||||
reasons = identity_match_reasons(
|
||||
merged,
|
||||
remote=scope_remote,
|
||||
org=scope_org,
|
||||
repo=scope_repo,
|
||||
profile_identity=stored or profile,
|
||||
)
|
||||
filtered = [
|
||||
r
|
||||
for r in reasons
|
||||
if "profile identity mismatch" not in r
|
||||
or (stored and stored != profile)
|
||||
]
|
||||
# When caller requested a specific remote/org/repo, also fail if the
|
||||
# stored record lacks those identity fields entirely (legacy incomplete).
|
||||
if enforce_repo_scope:
|
||||
for field, want in (
|
||||
("remote", remote),
|
||||
("org", org),
|
||||
("repo", repo),
|
||||
):
|
||||
want_s = (want or "").strip()
|
||||
if not want_s:
|
||||
continue
|
||||
have = (str(merged.get(field) or "")).strip()
|
||||
if not have:
|
||||
filtered.append(
|
||||
f"session state {field} missing on durable record "
|
||||
f"(expected={want_s!r}; fail closed, #709 F3)"
|
||||
)
|
||||
elif have != want_s:
|
||||
# identity_match_reasons already adds mismatch; ensure kept
|
||||
pass
|
||||
if filtered:
|
||||
return None
|
||||
return merged
|
||||
reasons = identity_match_reasons(
|
||||
merged,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile,
|
||||
)
|
||||
if reasons:
|
||||
return None
|
||||
return merged
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -1,595 +0,0 @@
|
||||
"""Session-immutable MCP mutation context (#714).
|
||||
|
||||
Pins profile, remote, host, repository, identity, and role for the life of an
|
||||
MCP process (or until an explicit ``gitea_activate_profile`` re-bind).
|
||||
|
||||
Capability resolution and mutation gates must evaluate only the active
|
||||
profile for the requested remote. Silent cross-host / cross-profile
|
||||
substitution is forbidden.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SessionContext:
|
||||
"""One atomic, immutable process-session binding."""
|
||||
|
||||
profile_name: str | None
|
||||
remote: str | None
|
||||
host: str | None
|
||||
identity: str | None
|
||||
repository: str | None
|
||||
org: str | None
|
||||
role_kind: str | None
|
||||
expected_username: str | None
|
||||
source: str
|
||||
pid: int
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"profile_name": self.profile_name,
|
||||
"remote": self.remote,
|
||||
"host": self.host,
|
||||
"identity": self.identity,
|
||||
"repository": self.repository,
|
||||
"org": self.org,
|
||||
"role_kind": self.role_kind,
|
||||
"expected_username": self.expected_username,
|
||||
"source": self.source,
|
||||
"pid": self.pid,
|
||||
}
|
||||
|
||||
|
||||
# Process-local only — never a shared file (same rationale as mutation authority).
|
||||
# The frozen value prevents partial mutation, while the lock makes first-bind and
|
||||
# sanctioned rebind atomic across concurrent MCP calls.
|
||||
_SESSION_CONTEXT: _SessionContext | None = None
|
||||
_SESSION_CONTEXT_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def _reset_session_context_for_testing() -> None:
|
||||
"""Reset at a pytest test boundary; unavailable to production callers.
|
||||
|
||||
Production sessions transition only through process startup/PID change or
|
||||
the explicit profile-activation rebind. Keeping this helper private and
|
||||
requiring pytest's per-test marker prevents it from becoming an MCP/runtime
|
||||
bypass.
|
||||
"""
|
||||
if "PYTEST_CURRENT_TEST" not in os.environ:
|
||||
raise RuntimeError("session context reset is restricted to pytest boundaries")
|
||||
global _SESSION_CONTEXT
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
_SESSION_CONTEXT = None
|
||||
|
||||
|
||||
def get_session_context() -> dict[str, Any] | None:
|
||||
"""Return a detached snapshot of the bound context, or None if unbound."""
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
if _SESSION_CONTEXT is None:
|
||||
return None
|
||||
return _SESSION_CONTEXT.as_dict()
|
||||
|
||||
|
||||
def profile_host(profile: dict | None) -> str | None:
|
||||
"""Hostname from profile base_url, lowercased, or None."""
|
||||
if not profile:
|
||||
return None
|
||||
base = (profile.get("base_url") or "").strip()
|
||||
if not base:
|
||||
return None
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(base)
|
||||
host = (parsed.netloc or parsed.path or "").strip().lower()
|
||||
return host or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def remote_host(remote: str | None, remotes: dict | None) -> str | None:
|
||||
"""Hostname for a known remote key."""
|
||||
if not remote or not remotes:
|
||||
return None
|
||||
entry = remotes.get(remote) or {}
|
||||
return (entry.get("host") or "").strip().lower() or None
|
||||
|
||||
|
||||
def profile_matches_remote(
|
||||
profile: dict | None,
|
||||
remote: str | None,
|
||||
remotes: dict | None,
|
||||
*,
|
||||
contexts: dict | None = None,
|
||||
) -> bool:
|
||||
"""True when *profile* is bound to the same host/context as *remote*."""
|
||||
if not profile or not remote:
|
||||
return False
|
||||
r_host = remote_host(remote, remotes)
|
||||
p_host = profile_host(profile)
|
||||
if r_host and p_host:
|
||||
return r_host == p_host
|
||||
# Fall back to context name heuristics when base_url missing.
|
||||
ctx = (profile.get("context") or "").strip().lower()
|
||||
if not ctx or not contexts:
|
||||
return False
|
||||
ctx_data = contexts.get(ctx) or {}
|
||||
gitea = ctx_data.get("gitea") or {}
|
||||
base = (gitea.get("base_url") or "").strip()
|
||||
if not base or not r_host:
|
||||
return False
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(base)
|
||||
c_host = (parsed.netloc or parsed.path or "").strip().lower()
|
||||
except Exception:
|
||||
return False
|
||||
return bool(c_host) and c_host == r_host
|
||||
|
||||
|
||||
def bind_session_context(
|
||||
*,
|
||||
profile_name: str,
|
||||
remote: str | None,
|
||||
host: str | None,
|
||||
identity: str | None,
|
||||
repository: str | None = None,
|
||||
org: str | None = None,
|
||||
role_kind: str | None = None,
|
||||
expected_username: str | None = None,
|
||||
source: str = "bind",
|
||||
) -> dict[str, Any]:
|
||||
"""Atomically bind/re-bind context (the explicit activation path)."""
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
return _bind_session_context_unlocked(
|
||||
profile_name=profile_name,
|
||||
remote=remote,
|
||||
host=host,
|
||||
identity=identity,
|
||||
repository=repository,
|
||||
org=org,
|
||||
role_kind=role_kind,
|
||||
expected_username=expected_username,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def _bind_session_context_unlocked(
|
||||
*,
|
||||
profile_name: str,
|
||||
remote: str | None,
|
||||
host: str | None,
|
||||
identity: str | None,
|
||||
repository: str | None,
|
||||
org: str | None,
|
||||
role_kind: str | None,
|
||||
expected_username: str | None,
|
||||
source: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Store a complete immutable context while the caller holds the lock."""
|
||||
global _SESSION_CONTEXT
|
||||
_SESSION_CONTEXT = _SessionContext(
|
||||
profile_name=(profile_name or "").strip() or None,
|
||||
remote=(remote or "").strip() or None,
|
||||
host=(host or "").strip().lower() or None,
|
||||
identity=(identity or "").strip() or None,
|
||||
repository=(repository or "").strip() or None,
|
||||
org=(org or "").strip() or None,
|
||||
role_kind=(role_kind or "").strip() or None,
|
||||
expected_username=(expected_username or "").strip() or None,
|
||||
source=source,
|
||||
pid=os.getpid(),
|
||||
)
|
||||
return _SESSION_CONTEXT.as_dict()
|
||||
|
||||
|
||||
def seed_session_context_if_unbound(
|
||||
*,
|
||||
profile_name: str,
|
||||
remote: str | None,
|
||||
host: str | None,
|
||||
identity: str | None,
|
||||
repository: str | None = None,
|
||||
org: str | None = None,
|
||||
role_kind: str | None = None,
|
||||
expected_username: str | None = None,
|
||||
source: str = "seed",
|
||||
) -> dict[str, Any]:
|
||||
"""Atomically bind only when this process has no current context.
|
||||
|
||||
A changed environment or an interleaved call is not a session boundary and
|
||||
therefore cannot replace an established binding. A newly started/forked
|
||||
process is recognized by PID; explicit ``gitea_activate_profile`` uses
|
||||
:func:`bind_session_context` as its sanctioned logical-session transition.
|
||||
"""
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
if _SESSION_CONTEXT is None or _SESSION_CONTEXT.pid != os.getpid():
|
||||
return _bind_session_context_unlocked(
|
||||
profile_name=profile_name,
|
||||
remote=remote,
|
||||
host=host,
|
||||
identity=identity,
|
||||
repository=repository,
|
||||
org=org,
|
||||
role_kind=role_kind,
|
||||
expected_username=expected_username,
|
||||
source=source,
|
||||
)
|
||||
return _SESSION_CONTEXT.as_dict()
|
||||
|
||||
|
||||
def assess_session_context(
|
||||
*,
|
||||
profile_name: str | None,
|
||||
remote: str | None,
|
||||
host: str | None = None,
|
||||
identity: str | None = None,
|
||||
repository: str | None = None,
|
||||
org: str | None = None,
|
||||
expected_username: str | None = None,
|
||||
require_bound: bool = False,
|
||||
require_complete: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Compare live values against the bound session context.
|
||||
|
||||
Returns ``proven`` / ``block`` / ``reasons``. When unbound and
|
||||
``require_bound`` is false, does not block (caller may seed). When
|
||||
unbound and ``require_bound`` is true, fails closed. ``require_complete``
|
||||
additionally fails closed when the binding carries no verified
|
||||
repository/organization identity, so a mutation can never run against an
|
||||
unknown repository (#714).
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
bound = _SESSION_CONTEXT
|
||||
ctx = bound.as_dict() if bound is not None else None
|
||||
if ctx is None or ctx.get("pid") != os.getpid():
|
||||
if require_bound:
|
||||
reasons.append(
|
||||
"session mutation context is unbound; call gitea_whoami or "
|
||||
"gitea_activate_profile before mutating (fail closed)"
|
||||
)
|
||||
return _assessment(False, reasons, ctx)
|
||||
return _assessment(True, reasons, ctx)
|
||||
|
||||
if require_complete and not (ctx.get("repository") and ctx.get("org")):
|
||||
reasons.append(
|
||||
"session repository/organization identity is unverified "
|
||||
f"(repository={ctx.get('repository')!r}, org={ctx.get('org')!r}); "
|
||||
"a mutation cannot proceed without a verified workspace "
|
||||
"repository (fail closed)"
|
||||
)
|
||||
return _assessment(False, reasons, ctx)
|
||||
|
||||
live_profile = (profile_name or "").strip() or None
|
||||
live_remote = (remote or "").strip() or None
|
||||
live_host = (host or "").strip().lower() or None
|
||||
live_identity = (identity or "").strip() or None
|
||||
live_repo = (repository or "").strip() or None
|
||||
live_org = (org or "").strip() or None
|
||||
|
||||
if ctx.get("profile_name") and live_profile and live_profile != ctx.get("profile_name"):
|
||||
reasons.append(
|
||||
f"profile drift: live '{live_profile}' != bound "
|
||||
f"'{ctx.get('profile_name')}' (fail closed)"
|
||||
)
|
||||
if ctx.get("remote") and live_remote and live_remote != ctx.get("remote"):
|
||||
reasons.append(
|
||||
f"remote drift: live '{live_remote}' != bound "
|
||||
f"'{ctx.get('remote')}' (fail closed)"
|
||||
)
|
||||
if ctx.get("host") and live_host and live_host != ctx.get("host"):
|
||||
reasons.append(
|
||||
f"host drift: live '{live_host}' != bound "
|
||||
f"'{ctx.get('host')}' (fail closed)"
|
||||
)
|
||||
if (
|
||||
ctx.get("identity")
|
||||
and live_identity
|
||||
and live_identity != ctx.get("identity")
|
||||
):
|
||||
reasons.append(
|
||||
f"identity drift: live '{live_identity}' != bound "
|
||||
f"'{ctx.get('identity')}' (fail closed)"
|
||||
)
|
||||
if ctx.get("repository") and live_repo and live_repo != ctx.get("repository"):
|
||||
reasons.append(
|
||||
f"repository drift: live '{live_repo}' != bound "
|
||||
f"'{ctx.get('repository')}' (fail closed)"
|
||||
)
|
||||
if ctx.get("org") and live_org and live_org != ctx.get("org"):
|
||||
reasons.append(
|
||||
f"org drift: live '{live_org}' != bound "
|
||||
f"'{ctx.get('org')}' (fail closed)"
|
||||
)
|
||||
|
||||
expected = expected_username or ctx.get("expected_username")
|
||||
if expected and live_identity and live_identity != expected:
|
||||
reasons.append(
|
||||
f"identity mismatch: authenticated '{live_identity}' != "
|
||||
f"profile expected '{expected}' (fail closed)"
|
||||
)
|
||||
|
||||
return _assessment(not reasons, reasons, ctx)
|
||||
|
||||
|
||||
def assess_identity_match(
|
||||
*,
|
||||
authenticated: str | None,
|
||||
expected_username: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when profile declares a username that does not match live auth."""
|
||||
reasons: list[str] = []
|
||||
auth = (authenticated or "").strip() or None
|
||||
expected = (expected_username or "").strip() or None
|
||||
if expected and auth and auth != expected:
|
||||
reasons.append(
|
||||
f"identity mismatch: authenticated '{auth}' != "
|
||||
f"profile expected '{expected}' (fail closed)"
|
||||
)
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"authenticated": auth,
|
||||
"expected_username": expected,
|
||||
}
|
||||
|
||||
|
||||
_REPO_SLUG_RE = re.compile(r"^\s*(?P<org>[^/\s]+)\s*/\s*(?P<repo>[^/\s]+?)(?:\.git)?\s*$")
|
||||
|
||||
|
||||
def parse_repository_slug(slug: str | None) -> tuple[str, str] | None:
|
||||
"""Split a canonical ``owner/repository`` slug, or None when unparseable."""
|
||||
match = _REPO_SLUG_RE.match(slug or "")
|
||||
if not match:
|
||||
return None
|
||||
return match.group("org"), match.group("repo")
|
||||
|
||||
|
||||
def format_repository_slug(org: str | None, repo: str | None) -> str | None:
|
||||
"""``owner/repository`` from parts, or None when either side is missing."""
|
||||
left = (org or "").strip()
|
||||
right = (repo or "").strip()
|
||||
if not left or not right:
|
||||
return None
|
||||
return f"{left}/{right}"
|
||||
|
||||
|
||||
def declared_allowed_repositories(
|
||||
profile: Mapping[str, Any] | None,
|
||||
*,
|
||||
strict: bool = False,
|
||||
) -> list[str]:
|
||||
"""Canonical ``owner/repository`` authorization boundary declared by *profile*.
|
||||
|
||||
This list is an authorization boundary, never the session binding itself:
|
||||
the verified workspace selects exactly one entry (see
|
||||
:func:`assess_repository_scope`).
|
||||
|
||||
When *strict* is true (mutation path), missing profile, non-list values, or
|
||||
any malformed entry raise ``ValueError`` instead of silently collapsing to
|
||||
an empty scope (which would fail open). Read-only diagnostics may use
|
||||
strict=False.
|
||||
"""
|
||||
if not profile:
|
||||
if strict:
|
||||
raise ValueError(
|
||||
"profile unresolved; cannot declare allowed_repositories "
|
||||
"(fail closed)"
|
||||
)
|
||||
return []
|
||||
raw = profile.get("allowed_repositories")
|
||||
if raw is None:
|
||||
return []
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
if strict:
|
||||
raise ValueError(
|
||||
"allowed_repositories must be a list of owner/repository slugs "
|
||||
"(fail closed)"
|
||||
)
|
||||
return []
|
||||
slugs: list[str] = []
|
||||
errors: list[str] = []
|
||||
for entry in raw:
|
||||
if not isinstance(entry, str):
|
||||
errors.append(f"non-string allowed_repositories entry {entry!r}")
|
||||
continue
|
||||
parsed = parse_repository_slug(entry)
|
||||
if not parsed:
|
||||
errors.append(
|
||||
f"malformed allowed_repositories entry {entry!r} "
|
||||
"(expected owner/repository)"
|
||||
)
|
||||
continue
|
||||
slugs.append(f"{parsed[0]}/{parsed[1]}")
|
||||
if strict and errors:
|
||||
raise ValueError("; ".join(errors) + " (fail closed)")
|
||||
return slugs
|
||||
|
||||
|
||||
def assess_repository_scope(
|
||||
*,
|
||||
workspace_slug: str | None,
|
||||
allowed: list[str] | None,
|
||||
profile_name: str | None = None,
|
||||
require_scope: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Authorize the verified workspace repository against the profile allowlist.
|
||||
|
||||
The workspace-derived slug is the only candidate: a profile that authorizes
|
||||
several repositories still binds to the single verified one, and never to
|
||||
the list as a whole.
|
||||
|
||||
*require_scope* (mutation path): missing/empty allowlist fails closed. The
|
||||
workspace remote cannot self-authorize without a configured boundary.
|
||||
"""
|
||||
scope = list(allowed or [])
|
||||
reasons: list[str] = []
|
||||
name = profile_name or "(active profile)"
|
||||
if not scope:
|
||||
if require_scope:
|
||||
reasons.append(
|
||||
f"mutation denied: profile '{name}' has no non-empty "
|
||||
"allowed_repositories authorization boundary (fail closed)"
|
||||
)
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": reasons,
|
||||
"scope_enforced": True,
|
||||
"workspace_slug": workspace_slug,
|
||||
"allowed_repositories": [],
|
||||
}
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": reasons,
|
||||
"scope_enforced": False,
|
||||
"workspace_slug": workspace_slug,
|
||||
"allowed_repositories": [],
|
||||
}
|
||||
if not workspace_slug:
|
||||
reasons.append(
|
||||
f"no verified workspace repository could be established for profile "
|
||||
f"'{name}'; repository scope cannot be authorized (fail closed)"
|
||||
)
|
||||
elif workspace_slug.lower() not in {entry.lower() for entry in scope}:
|
||||
reasons.append(
|
||||
f"repository scope denial: workspace repository '{workspace_slug}' "
|
||||
f"is not authorized by profile '{name}' allowed_repositories "
|
||||
f"{sorted(scope)} (fail closed)"
|
||||
)
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"scope_enforced": True,
|
||||
"workspace_slug": workspace_slug,
|
||||
"allowed_repositories": sorted(scope),
|
||||
}
|
||||
|
||||
|
||||
def assess_repository_override(
|
||||
*,
|
||||
requested_org: str | None,
|
||||
requested_repo: str | None,
|
||||
bound_org: str | None,
|
||||
bound_repo: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Caller-supplied org/repo must agree with the immutable binding.
|
||||
|
||||
A mutation request is never allowed to establish, complete, or replace the
|
||||
binding — it may only be checked against it.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
req_org = (requested_org or "").strip() or None
|
||||
req_repo = (requested_repo or "").strip() or None
|
||||
if req_repo and bound_repo and req_repo.lower() != bound_repo.lower():
|
||||
reasons.append(
|
||||
f"repository override denial: request targets '{req_repo}' but the "
|
||||
f"session is bound to '{bound_repo}' (fail closed)"
|
||||
)
|
||||
if req_org and bound_org and req_org.lower() != bound_org.lower():
|
||||
reasons.append(
|
||||
f"organization override denial: request targets '{req_org}' but the "
|
||||
f"session is bound to '{bound_org}' (fail closed)"
|
||||
)
|
||||
return {"proven": not reasons, "block": bool(reasons), "reasons": reasons}
|
||||
|
||||
|
||||
def filter_profiles_for_remote(
|
||||
config: dict | None,
|
||||
remote: str | None,
|
||||
remotes: dict | None,
|
||||
) -> list[str]:
|
||||
"""Profile names whose host/context matches *remote* (enabled only)."""
|
||||
if not config or not remote:
|
||||
return []
|
||||
profiles = config.get("profiles") or {}
|
||||
contexts = config.get("contexts") or {}
|
||||
names: list[str] = []
|
||||
for name, data in profiles.items():
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
if not data.get("enabled", True):
|
||||
continue
|
||||
if profile_matches_remote(data, remote, remotes, contexts=contexts):
|
||||
names.append(name)
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def profile_allowed_for_remote(
|
||||
profile: dict | None,
|
||||
remote: str | None,
|
||||
remotes: dict | None,
|
||||
*,
|
||||
contexts: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess whether the active profile may serve *remote*."""
|
||||
reasons: list[str] = []
|
||||
if not profile:
|
||||
reasons.append("active profile unresolved (fail closed)")
|
||||
return {"proven": False, "block": True, "reasons": reasons}
|
||||
if not remote:
|
||||
return {"proven": True, "block": False, "reasons": reasons}
|
||||
# Legacy env-only profiles have no configured base URL or v2 context. Their
|
||||
# first call may establish the process remote/host pin; after that,
|
||||
# assess_session_context rejects any drift. Configured v2 profiles still
|
||||
# require positive host/context alignment here.
|
||||
if not profile_host(profile) and not (profile.get("context") or "").strip():
|
||||
return {"proven": True, "block": False, "reasons": reasons}
|
||||
if not profile_matches_remote(profile, remote, remotes, contexts=contexts):
|
||||
p_name = profile.get("profile_name") or profile.get("name") or "(unknown)"
|
||||
p_host = profile_host(profile) or "(none)"
|
||||
r_host = remote_host(remote, remotes) or "(none)"
|
||||
reasons.append(
|
||||
f"cross-host profile denial: profile '{p_name}' (host '{p_host}') "
|
||||
f"cannot serve remote '{remote}' (host '{r_host}') (fail closed)"
|
||||
)
|
||||
return {"proven": not reasons, "block": bool(reasons), "reasons": reasons}
|
||||
|
||||
|
||||
def mutation_context_audit_fields(
|
||||
ctx: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fields to include in pre-mutation audit records."""
|
||||
data = ctx if ctx is not None else get_session_context()
|
||||
if not data:
|
||||
return {
|
||||
"session_context_bound": False,
|
||||
"session_profile": None,
|
||||
"session_remote": None,
|
||||
"session_host": None,
|
||||
"session_identity": None,
|
||||
"session_repository": None,
|
||||
"session_org": None,
|
||||
}
|
||||
return {
|
||||
"session_context_bound": True,
|
||||
"session_profile": data.get("profile_name"),
|
||||
"session_remote": data.get("remote"),
|
||||
"session_host": data.get("host"),
|
||||
"session_identity": data.get("identity"),
|
||||
"session_repository": data.get("repository"),
|
||||
"session_org": data.get("org"),
|
||||
"session_role_kind": data.get("role_kind"),
|
||||
"session_context_source": data.get("source"),
|
||||
}
|
||||
|
||||
|
||||
def _assessment(
|
||||
proven: bool, reasons: list[str], ctx: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": list(reasons),
|
||||
"bound_context": dict(ctx) if ctx else None,
|
||||
"audit": mutation_context_audit_fields(ctx),
|
||||
}
|
||||
@@ -438,3 +438,252 @@ def format_cleanup_audit_comment(audit: dict[str, Any]) -> str:
|
||||
"This path only clears a lock when the referenced PR is merged/closed.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #709 cross-profile cleanup, overwrite protection, recovery provenance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def has_unresolved_terminal_evidence(lock: dict | None) -> bool:
|
||||
"""True when *lock* still carries a terminal live mutation ledger."""
|
||||
return last_terminal_mutation(lock) is not None
|
||||
|
||||
|
||||
def assess_init_overwrite(
|
||||
existing_lock: dict | None,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Whether init_review_decision_lock may replace an existing durable lock (#709 AC2).
|
||||
|
||||
Unresolved terminal evidence must never be silently replaced by an empty
|
||||
initialized lock. *force* does not authorize destruction of terminal
|
||||
ledgers — only explicit cleanup / archive transitions may remove them.
|
||||
"""
|
||||
result: dict[str, Any] = {
|
||||
"overwrite_allowed": True,
|
||||
"has_existing": existing_lock is not None,
|
||||
"has_unresolved_terminal": False,
|
||||
"reasons": [],
|
||||
"last_terminal_pr": None,
|
||||
"last_terminal_action": None,
|
||||
"existing_profile_identity": None,
|
||||
}
|
||||
if existing_lock is None:
|
||||
result["reasons"].append("no existing lock; empty init allowed")
|
||||
return result
|
||||
result["existing_profile_identity"] = (
|
||||
existing_lock.get("profile_identity")
|
||||
or existing_lock.get("session_profile_lock")
|
||||
or existing_lock.get("session_profile")
|
||||
)
|
||||
last = last_terminal_mutation(existing_lock)
|
||||
if last is None:
|
||||
result["reasons"].append(
|
||||
"existing lock has no terminal mutations; re-init allowed"
|
||||
)
|
||||
return result
|
||||
result["has_unresolved_terminal"] = True
|
||||
result["overwrite_allowed"] = False
|
||||
result["last_terminal_pr"] = last.get("pr_number")
|
||||
result["last_terminal_action"] = last.get("action")
|
||||
result["reasons"].append(
|
||||
"refuse empty re-init: unresolved terminal decision-lock evidence "
|
||||
f"present ({last.get('action')} on PR #{last.get('pr_number')}); "
|
||||
"use gitea_cleanup_stale_review_decision_lock when moot, or archive "
|
||||
f"via sanctioned recovery — force={force!r} does not authorize overwrite "
|
||||
"(#709 AC2)"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def lock_targets_merged_pr_approval(
|
||||
lock: dict | None,
|
||||
*,
|
||||
pr_number: int,
|
||||
expected_head_sha: str | None = None,
|
||||
) -> bool:
|
||||
"""True when *lock*'s last terminal mutation is approve of *pr_number*.
|
||||
|
||||
When *expected_head_sha* is provided, a **recorded** terminal head must
|
||||
match. Legacy same-repo approve locks with no recorded head do **not**
|
||||
match (fail closed, #709 F3 residual / review 435) — callers must use the
|
||||
strict secondary path that refuses no-head clears.
|
||||
"""
|
||||
last = last_terminal_mutation(lock)
|
||||
if last is None:
|
||||
return False
|
||||
if last.get("action") != "approve":
|
||||
return False
|
||||
if last.get("pr_number") != pr_number:
|
||||
return False
|
||||
if expected_head_sha:
|
||||
want = normalize_head_sha(expected_head_sha)
|
||||
if not want:
|
||||
return False
|
||||
locked = mutation_head_sha(last, lock)
|
||||
# Require recorded-head match; unrecorded head is not a match (#709 F3).
|
||||
if not locked or not heads_equal(locked, want):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def build_post_merge_recovery_record(
|
||||
*,
|
||||
pr_number: int,
|
||||
head_sha: str | None,
|
||||
merge_commit_sha: str | None,
|
||||
target_profile_identity: str | None,
|
||||
failed_step: str,
|
||||
error: str | None,
|
||||
remote: str | None,
|
||||
org: str | None,
|
||||
repo: str | None,
|
||||
actor_username: str | None,
|
||||
profile_name: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Durable recovery-required payload after irreversible merge (#709 AC3)."""
|
||||
return {
|
||||
"event": "post_merge_decision_lock_recovery_required",
|
||||
"status": "recovery_required",
|
||||
"issue_ref": "#709",
|
||||
"recovery_critical": True,
|
||||
"applied": False, # never claim historical cleanup succeeded
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"pr_number": pr_number,
|
||||
"head_sha": normalize_head_sha(head_sha),
|
||||
"merge_commit_sha": merge_commit_sha,
|
||||
"target_profile_identity": target_profile_identity,
|
||||
"failed_step": failed_step,
|
||||
"error": error,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"actor_username": actor_username,
|
||||
"profile_name": profile_name,
|
||||
"required_recovery_action": (
|
||||
"retry cross-profile decision-lock cleanup and audit publication "
|
||||
"for the merged PR; if terminal evidence is gone, use "
|
||||
"gitea_record_irrecoverable_decision_lock_provenance"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_irrecoverable_provenance_record(
|
||||
*,
|
||||
pr_number: int,
|
||||
head_sha: str | None,
|
||||
remote: str | None,
|
||||
org: str | None,
|
||||
repo: str | None,
|
||||
actor_username: str | None,
|
||||
profile_name: str | None,
|
||||
reason: str,
|
||||
incident_ref: str | None = None,
|
||||
# Deprecated kwargs retained only so stale call sites fail closed:
|
||||
operator_authorized: bool | None = None,
|
||||
# Required for merger-acceptable records (#709 F1):
|
||||
authorization: dict[str, Any] | None = None,
|
||||
incident_issue: int | None = None,
|
||||
incident_comment_id: int | None = None,
|
||||
destroyed_subject: str | None = None,
|
||||
historical_provenance_subject: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Truthful absence-of-proof record (#709 AC5). Never sets applied=True.
|
||||
|
||||
Caller-supplied ``operator_authorized`` is **ignored** as authorization
|
||||
evidence (review 434 F1). Prefer
|
||||
:func:`irrecoverable_provenance.build_irrecoverable_provenance_record`
|
||||
with a verified server-side authorization artifact.
|
||||
"""
|
||||
# Explicitly ignore deprecated self-assertable Boolean.
|
||||
_ = operator_authorized
|
||||
if authorization is not None and incident_issue is not None and incident_comment_id is not None:
|
||||
from irrecoverable_provenance import (
|
||||
build_irrecoverable_provenance_record as _build,
|
||||
)
|
||||
|
||||
return _build(
|
||||
pr_number=int(pr_number),
|
||||
head_sha=str(head_sha or ""),
|
||||
remote=str(remote or ""),
|
||||
org=str(org or ""),
|
||||
repo=str(repo or ""),
|
||||
actor_username=actor_username,
|
||||
profile_name=profile_name,
|
||||
reason=reason,
|
||||
incident_issue=int(incident_issue),
|
||||
incident_comment_id=int(incident_comment_id),
|
||||
authorization=authorization,
|
||||
destroyed_subject=destroyed_subject,
|
||||
historical_provenance_subject=historical_provenance_subject
|
||||
or destroyed_subject,
|
||||
)
|
||||
# Fail-closed skeleton when no server authorization is supplied: never
|
||||
# sets merger_may_accept True (even if operator_authorized was True).
|
||||
return {
|
||||
"event": "irrecoverable_decision_lock_provenance",
|
||||
"status": "provenance_irrecoverable",
|
||||
"record_type": "irrecoverable_decision_provenance",
|
||||
"operator_recovery_required": True,
|
||||
"issue_ref": "#709",
|
||||
"recovery_critical": True,
|
||||
"applied": False,
|
||||
"historical_cleanup_proven": False,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"pr_number": pr_number,
|
||||
"head_sha": normalize_head_sha(head_sha),
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"actor_username": actor_username,
|
||||
"profile_name": profile_name,
|
||||
"reason": reason,
|
||||
"incident_ref": incident_ref,
|
||||
"incident_issue": incident_issue,
|
||||
"incident_comment_id": incident_comment_id,
|
||||
"authorization_verified": False,
|
||||
"merger_may_accept": False,
|
||||
"acceptance_rule": (
|
||||
"Merger may accept this record only when a server-side "
|
||||
"authorization artifact verifies for remote/org/repo/PR/exact "
|
||||
"head/incident, the record is durable and read back, and normal "
|
||||
"merge gates still pass. Caller Booleans never authorize. This "
|
||||
"does not prove historical cleanup."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def format_irrecoverable_audit_comment(record: dict[str, Any]) -> str:
|
||||
"""Markdown body for irrecoverable provenance audit (no applied=true claim)."""
|
||||
from irrecoverable_provenance import (
|
||||
format_irrecoverable_audit_comment as _fmt,
|
||||
)
|
||||
|
||||
return _fmt(record)
|
||||
|
||||
|
||||
def format_post_merge_recovery_comment(record: dict[str, Any]) -> str:
|
||||
"""Markdown body for post-merge recovery-required audit."""
|
||||
lines = [
|
||||
"## Post-merge decision-lock recovery required (#709)",
|
||||
"",
|
||||
"Status: **RECOVERY_REQUIRED** (merge is irreversible; cleanup/audit incomplete)",
|
||||
"",
|
||||
f"- actor: `{record.get('actor_username')}`",
|
||||
f"- profile: `{record.get('profile_name')}`",
|
||||
f"- timestamp: `{record.get('timestamp')}`",
|
||||
f"- PR: `#{record.get('pr_number')}`",
|
||||
f"- head_sha: `{record.get('head_sha')}`",
|
||||
f"- merge_commit_sha: `{record.get('merge_commit_sha')}`",
|
||||
f"- target_profile_identity: `{record.get('target_profile_identity')}`",
|
||||
f"- failed_step: `{record.get('failed_step')}`",
|
||||
f"- error: `{record.get('error')}`",
|
||||
"",
|
||||
f"Required action: {record.get('required_recovery_action')}",
|
||||
"",
|
||||
"applied=false — do not treat this as successful cleanup evidence.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
+27
-1
@@ -84,7 +84,7 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
},
|
||||
"adopt_merger_pr_lease": {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "reviewer",
|
||||
"role": "merger",
|
||||
},
|
||||
# #691: guarded non-owner cleanup of obsolete comment-backed reviewer leases.
|
||||
# Apply path posts lease release + audit comments (gitea.pr.comment).
|
||||
@@ -127,6 +127,32 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
},
|
||||
# #709: truthful absence-of-proof recovery (server-side auth + record + consume).
|
||||
# Dedicated mutation capability — gitea.read is insufficient (review 434 F1).
|
||||
"issue_irrecoverable_provenance_authorization": {
|
||||
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
||||
"role": "reconciler",
|
||||
},
|
||||
"gitea_issue_irrecoverable_provenance_authorization": {
|
||||
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
||||
"role": "reconciler",
|
||||
},
|
||||
"record_irrecoverable_decision_lock_provenance": {
|
||||
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
||||
"role": "reconciler",
|
||||
},
|
||||
"gitea_record_irrecoverable_decision_lock_provenance": {
|
||||
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
||||
"role": "reconciler",
|
||||
},
|
||||
"consume_irrecoverable_decision_lock_provenance": {
|
||||
"permission": "gitea.pr.merge",
|
||||
"role": "merger",
|
||||
},
|
||||
"gitea_consume_irrecoverable_decision_lock_provenance": {
|
||||
"permission": "gitea.pr.merge",
|
||||
"role": "merger",
|
||||
},
|
||||
"delete_branch": {
|
||||
"permission": "gitea.branch.delete",
|
||||
"role": "author",
|
||||
|
||||
+15
-47
@@ -26,14 +26,6 @@ def _reset_mutation_authority(monkeypatch):
|
||||
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
|
||||
so durable load/save never touches host state even after env clears.
|
||||
"""
|
||||
import session_context_binding as session_ctx
|
||||
|
||||
# Each pytest item is an independent logical MCP session. Reset both before
|
||||
# and after the item; the post-yield reset is in a finally block so an
|
||||
# assertion, exception, or unittest teardown failure cannot pollute the
|
||||
# next item. Production code has no automatic per-call reset path.
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
|
||||
for env_key in [
|
||||
"GITEA_SESSION_PROFILE_LOCK",
|
||||
"GITEA_ACTIVE_WORKTREE",
|
||||
@@ -88,15 +80,9 @@ def _reset_mutation_authority(monkeypatch):
|
||||
try:
|
||||
import mcp_server
|
||||
except Exception:
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
_state_tmp.cleanup()
|
||||
_state_tmp.cleanup()
|
||||
yield
|
||||
return
|
||||
import gitea_config
|
||||
|
||||
monkeypatch.setattr(gitea_config, "_active_profile_override", None)
|
||||
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
|
||||
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
|
||||
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
|
||||
@@ -127,37 +113,19 @@ def _reset_mutation_authority(monkeypatch):
|
||||
capability_stop_terminal.clear()
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
import capability_stop_terminal
|
||||
capability_stop_terminal.clear()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import review_workflow_load
|
||||
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
mcp_server._REVIEW_DECISION_LOCK = None
|
||||
except Exception:
|
||||
pass
|
||||
gitea_config._active_profile_override = None
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
_state_tmp.cleanup()
|
||||
|
||||
|
||||
# #714: deterministic workspace remotes only (no host git dependency).
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _deterministic_workspace_remotes():
|
||||
try:
|
||||
from mutation_profile_fixture import install_deterministic_remote_urls
|
||||
install_deterministic_remote_urls()
|
||||
import capability_stop_terminal
|
||||
capability_stop_terminal.clear()
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
try:
|
||||
import review_workflow_load
|
||||
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
mcp_server._REVIEW_DECISION_LOCK = None
|
||||
except Exception:
|
||||
pass
|
||||
_state_tmp.cleanup()
|
||||
|
||||
@@ -1,483 +0,0 @@
|
||||
"""Centralized config-backed mutation fixture for #714.
|
||||
|
||||
Mutation tests must opt into this helper explicitly. It never grants
|
||||
mutation authority via environment variables alone.
|
||||
|
||||
Design rules:
|
||||
- temporary v2 configuration with non-empty ``allowed_repositories``
|
||||
- profile-scoped operations (not every mutation op for every test)
|
||||
- deterministic workspace remotes (no host Git dependency)
|
||||
- one canonical repository per profile by default
|
||||
- isolated state + cleanup in ``finally``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from copy import deepcopy
|
||||
from typing import Iterable, Sequence
|
||||
from unittest.mock import patch
|
||||
|
||||
PRGS_SLUG = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||
PRGS_URL = f"https://gitea.prgs.cc/{PRGS_SLUG}.git"
|
||||
MDCPS_SLUG = "913443/eAgenda"
|
||||
MDCPS_URL = f"https://gitea.dadeschools.net/{MDCPS_SLUG}.git"
|
||||
EXAMPLE_SLUG = "Example-Org/Example-Repo"
|
||||
EXAMPLE_URL = f"https://gitea.example.com/{EXAMPLE_SLUG}.git"
|
||||
TIMESHEET_SLUG = "Scaled-Tech-Consulting/Timesheet"
|
||||
|
||||
|
||||
def _author_ops() -> list[str]:
|
||||
return [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.close",
|
||||
"gitea.issue.comment",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
]
|
||||
|
||||
|
||||
def _author_forbidden() -> list[str]:
|
||||
return [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.review",
|
||||
]
|
||||
|
||||
|
||||
def _reviewer_ops() -> list[str]:
|
||||
return [
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
]
|
||||
|
||||
|
||||
def _merger_ops() -> list[str]:
|
||||
return [
|
||||
"gitea.read",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.comment",
|
||||
]
|
||||
|
||||
|
||||
def _profile(
|
||||
*,
|
||||
name: str,
|
||||
context: str,
|
||||
role: str,
|
||||
base_url: str,
|
||||
auth_env: str,
|
||||
allowed_operations: Sequence[str],
|
||||
forbidden_operations: Sequence[str],
|
||||
allowed_repositories: Sequence[str],
|
||||
username: str | None = None,
|
||||
) -> dict:
|
||||
body = {
|
||||
"enabled": True,
|
||||
"context": context,
|
||||
"role": role,
|
||||
"base_url": base_url,
|
||||
"auth": {"type": "env", "name": auth_env},
|
||||
"allowed_operations": list(allowed_operations),
|
||||
"forbidden_operations": list(forbidden_operations),
|
||||
"allowed_repositories": list(allowed_repositories),
|
||||
"execution_profile": name,
|
||||
}
|
||||
if username:
|
||||
body["username"] = username
|
||||
return body
|
||||
|
||||
|
||||
def build_dual_remote_config(
|
||||
*,
|
||||
allowed_operations: Iterable[str] | None = None,
|
||||
allowed_repositories_prgs: Sequence[str] | None = None,
|
||||
allowed_repositories_mdcps: Sequence[str] | None = None,
|
||||
extra_profiles: dict | None = None,
|
||||
include_example_repo: bool = False,
|
||||
) -> dict:
|
||||
"""Build a minimal dual-host v2 config.
|
||||
|
||||
Each profile authorizes only its host-canonical repository by default.
|
||||
``include_example_repo`` adds Example-Org/Example-Repo when a test patches
|
||||
REMOTES to that synthetic unit-test identity.
|
||||
"""
|
||||
ops = list(allowed_operations or _author_ops())
|
||||
forb = _author_forbidden()
|
||||
prgs_repos = list(allowed_repositories_prgs or [PRGS_SLUG])
|
||||
mdcps_repos = list(allowed_repositories_mdcps or [MDCPS_SLUG])
|
||||
if include_example_repo:
|
||||
for repos in (prgs_repos, mdcps_repos):
|
||||
if EXAMPLE_SLUG not in repos:
|
||||
repos.append(EXAMPLE_SLUG)
|
||||
|
||||
profiles = {
|
||||
"test-author-prgs": _profile(
|
||||
name="test-author-prgs",
|
||||
context="prgs",
|
||||
role="author",
|
||||
base_url="https://gitea.prgs.cc",
|
||||
auth_env="GITEA_TOKEN_TEST",
|
||||
allowed_operations=ops,
|
||||
forbidden_operations=forb,
|
||||
allowed_repositories=prgs_repos,
|
||||
),
|
||||
"test-author-dadeschools": _profile(
|
||||
name="test-author-dadeschools",
|
||||
context="mdcps",
|
||||
role="author",
|
||||
base_url="https://gitea.dadeschools.net",
|
||||
auth_env="GITEA_TOKEN_TEST",
|
||||
allowed_operations=ops,
|
||||
forbidden_operations=forb,
|
||||
allowed_repositories=mdcps_repos,
|
||||
),
|
||||
"test-reviewer-prgs": _profile(
|
||||
name="test-reviewer-prgs",
|
||||
context="prgs",
|
||||
role="reviewer",
|
||||
base_url="https://gitea.prgs.cc",
|
||||
auth_env="GITEA_TOKEN_TEST",
|
||||
allowed_operations=_reviewer_ops(),
|
||||
forbidden_operations=[
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.merge",
|
||||
"gitea.issue.create",
|
||||
],
|
||||
allowed_repositories=prgs_repos,
|
||||
),
|
||||
"test-merger-prgs": _profile(
|
||||
name="test-merger-prgs",
|
||||
context="prgs",
|
||||
role="merger",
|
||||
base_url="https://gitea.prgs.cc",
|
||||
auth_env="GITEA_TOKEN_TEST",
|
||||
allowed_operations=_merger_ops(),
|
||||
forbidden_operations=[
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.approve",
|
||||
"gitea.issue.create",
|
||||
],
|
||||
allowed_repositories=prgs_repos,
|
||||
),
|
||||
}
|
||||
if extra_profiles:
|
||||
profiles.update(deepcopy(extra_profiles))
|
||||
|
||||
# Legacy aliases used by older tests — same host scope as the base profile.
|
||||
for alias, base in (
|
||||
("gitea-author", "test-author-prgs"),
|
||||
("author-test", "test-author-dadeschools"),
|
||||
("author", "test-author-dadeschools"),
|
||||
("full-author", "test-author-dadeschools"),
|
||||
("test-author", "test-author-dadeschools"),
|
||||
("prgs-author", "test-author-prgs"),
|
||||
("gitea-reviewer", "test-reviewer-prgs"),
|
||||
("prgs-reviewer", "test-reviewer-prgs"),
|
||||
("gitea-merger", "test-merger-prgs"),
|
||||
("prgs-merger", "test-merger-prgs"),
|
||||
):
|
||||
if alias not in profiles and base in profiles:
|
||||
clone = deepcopy(profiles[base])
|
||||
clone["execution_profile"] = alias
|
||||
profiles[alias] = clone
|
||||
|
||||
return {
|
||||
"version": 2,
|
||||
"rules": {"allow_runtime_switching": True},
|
||||
"contexts": {
|
||||
"prgs": {
|
||||
"enabled": True,
|
||||
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||
},
|
||||
"mdcps": {
|
||||
"enabled": True,
|
||||
"gitea": {
|
||||
"enabled": True,
|
||||
"base_url": "https://gitea.dadeschools.net",
|
||||
},
|
||||
},
|
||||
},
|
||||
"profiles": profiles,
|
||||
}
|
||||
|
||||
|
||||
def build_v2_config(**kwargs):
|
||||
"""Back-compat alias."""
|
||||
return build_dual_remote_config(
|
||||
allowed_operations=kwargs.get("allowed_operations"),
|
||||
extra_profiles=kwargs.get("extra_profiles"),
|
||||
include_example_repo=bool(kwargs.get("include_example_repo")),
|
||||
)
|
||||
|
||||
|
||||
def inject_allowed_repositories(
|
||||
config: dict,
|
||||
repos: Sequence[str],
|
||||
*,
|
||||
profiles: Sequence[str] | None = None,
|
||||
) -> dict:
|
||||
"""Return a deep copy of *config* with allowlists set on selected profiles."""
|
||||
out = deepcopy(config)
|
||||
targets = set(profiles) if profiles is not None else set(out.get("profiles") or {})
|
||||
for name, prof in (out.get("profiles") or {}).items():
|
||||
if name in targets:
|
||||
prof["allowed_repositories"] = list(repos)
|
||||
return out
|
||||
|
||||
|
||||
def ensure_all_profiles_have_scope(
|
||||
config: dict,
|
||||
default_repos: Sequence[str],
|
||||
) -> dict:
|
||||
"""Add non-empty allowed_repositories to every profile that lacks one."""
|
||||
out = deepcopy(config)
|
||||
for _name, prof in (out.get("profiles") or {}).items():
|
||||
raw = prof.get("allowed_repositories")
|
||||
if not isinstance(raw, (list, tuple)) or not raw:
|
||||
prof["allowed_repositories"] = list(default_repos)
|
||||
return out
|
||||
|
||||
|
||||
@contextmanager
|
||||
def mutation_profile_env(
|
||||
*,
|
||||
profile_name: str = "test-author-dadeschools",
|
||||
allowed_operations: Iterable[str] | None = None,
|
||||
allowed_repositories: Sequence[str] | None = None,
|
||||
workspace_urls: dict | None = None,
|
||||
clear_env: bool = False,
|
||||
extra_env: dict | None = None,
|
||||
extra_profiles: dict | None = None,
|
||||
include_example_repo: bool = False,
|
||||
config: dict | None = None,
|
||||
):
|
||||
"""Context manager: config-backed mutation authority + deterministic remotes."""
|
||||
import gitea_config
|
||||
import gitea_mcp_server as srv
|
||||
import session_context_binding as session_ctx
|
||||
|
||||
if config is None:
|
||||
prgs_repos = None
|
||||
mdcps_repos = None
|
||||
if allowed_repositories is not None:
|
||||
# Caller-specified single allowlist applied to both host profiles.
|
||||
prgs_repos = list(allowed_repositories)
|
||||
mdcps_repos = list(allowed_repositories)
|
||||
cfg = build_dual_remote_config(
|
||||
allowed_operations=allowed_operations,
|
||||
allowed_repositories_prgs=prgs_repos,
|
||||
allowed_repositories_mdcps=mdcps_repos,
|
||||
extra_profiles=extra_profiles,
|
||||
include_example_repo=include_example_repo,
|
||||
)
|
||||
else:
|
||||
cfg = deepcopy(config)
|
||||
if allowed_repositories is not None:
|
||||
cfg = ensure_all_profiles_have_scope(cfg, list(allowed_repositories))
|
||||
|
||||
urls = (
|
||||
{"prgs": PRGS_URL, "dadeschools": MDCPS_URL}
|
||||
if workspace_urls is None
|
||||
else dict(workspace_urls)
|
||||
)
|
||||
|
||||
tmp = tempfile.TemporaryDirectory(prefix="gitea-mut-cfg-")
|
||||
try:
|
||||
cfg_path = os.path.join(tmp.name, "profiles.json")
|
||||
with open(cfg_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(cfg, fh)
|
||||
env = {
|
||||
"GITEA_MCP_CONFIG": cfg_path,
|
||||
"GITEA_MCP_PROFILE": profile_name,
|
||||
"GITEA_TOKEN_TEST": "test-token",
|
||||
"PYTEST_CURRENT_TEST": os.environ.get(
|
||||
"PYTEST_CURRENT_TEST", "mutation_profile_env"
|
||||
),
|
||||
}
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
|
||||
def _remote_url(name: str):
|
||||
if name in urls:
|
||||
return urls[name]
|
||||
# Prefer live REMOTES (tests often patch Example-Org).
|
||||
try:
|
||||
entry = srv.REMOTES.get(name) or {}
|
||||
host = entry.get("host")
|
||||
org = entry.get("org")
|
||||
repo = entry.get("repo")
|
||||
if host and org and repo:
|
||||
if (
|
||||
name == "prgs"
|
||||
and org == "Scaled-Tech-Consulting"
|
||||
and repo == "Timesheet"
|
||||
):
|
||||
return PRGS_URL
|
||||
if name == "dadeschools" and repo == "Timesheet":
|
||||
return MDCPS_URL
|
||||
return f"https://{host}/{org}/{repo}.git"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
gitea_config._active_profile_override = None
|
||||
try:
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
payload = {
|
||||
"env": env,
|
||||
"config_path": cfg_path,
|
||||
"profile_name": profile_name,
|
||||
"urls": urls,
|
||||
"config": cfg,
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=clear_env):
|
||||
os.environ.setdefault(
|
||||
"PYTEST_CURRENT_TEST",
|
||||
env.get("PYTEST_CURRENT_TEST", "mutation_profile_env"),
|
||||
)
|
||||
with patch.object(srv, "_local_git_remote_url", side_effect=_remote_url):
|
||||
mcp_srv = None
|
||||
try:
|
||||
import mcp_server as mcp_srv # type: ignore
|
||||
except Exception:
|
||||
mcp_srv = None
|
||||
if mcp_srv is not None:
|
||||
with patch.object(
|
||||
mcp_srv, "_local_git_remote_url", side_effect=_remote_url
|
||||
):
|
||||
yield payload
|
||||
else:
|
||||
yield payload
|
||||
finally:
|
||||
try:
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
except Exception:
|
||||
pass
|
||||
gitea_config._active_profile_override = None
|
||||
tmp.cleanup()
|
||||
|
||||
|
||||
# Process-lifetime shared config for mass-migrating env-only mutation tests.
|
||||
_SHARED_CFG_PATH = None
|
||||
_SHARED_CFG_DIR = None
|
||||
_SHARED_CFG_WITH_EXAMPLE_PATH = None
|
||||
|
||||
|
||||
def shared_mutation_config_path(*, include_example_repo: bool = False) -> str:
|
||||
"""Write (once) a dual-remote mutation config and return its path."""
|
||||
global _SHARED_CFG_PATH, _SHARED_CFG_DIR, _SHARED_CFG_WITH_EXAMPLE_PATH
|
||||
import atexit
|
||||
import shutil
|
||||
|
||||
if include_example_repo:
|
||||
if _SHARED_CFG_WITH_EXAMPLE_PATH and os.path.isfile(
|
||||
_SHARED_CFG_WITH_EXAMPLE_PATH
|
||||
):
|
||||
return _SHARED_CFG_WITH_EXAMPLE_PATH
|
||||
if _SHARED_CFG_DIR is None:
|
||||
_SHARED_CFG_DIR = tempfile.mkdtemp(prefix="gitea-shared-mut-cfg-")
|
||||
atexit.register(lambda: shutil.rmtree(_SHARED_CFG_DIR, ignore_errors=True))
|
||||
path = os.path.join(_SHARED_CFG_DIR, "profiles-with-example.json")
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(build_dual_remote_config(include_example_repo=True), fh)
|
||||
_SHARED_CFG_WITH_EXAMPLE_PATH = path
|
||||
return path
|
||||
|
||||
if _SHARED_CFG_PATH and os.path.isfile(_SHARED_CFG_PATH):
|
||||
return _SHARED_CFG_PATH
|
||||
if _SHARED_CFG_DIR is None:
|
||||
_SHARED_CFG_DIR = tempfile.mkdtemp(prefix="gitea-shared-mut-cfg-")
|
||||
atexit.register(lambda: shutil.rmtree(_SHARED_CFG_DIR, ignore_errors=True))
|
||||
_SHARED_CFG_PATH = os.path.join(_SHARED_CFG_DIR, "profiles.json")
|
||||
with open(_SHARED_CFG_PATH, "w", encoding="utf-8") as fh:
|
||||
json.dump(build_dual_remote_config(), fh)
|
||||
return _SHARED_CFG_PATH
|
||||
|
||||
|
||||
def shared_mutation_env(
|
||||
profile_name: str = "test-author-dadeschools",
|
||||
*,
|
||||
include_example_repo: bool = False,
|
||||
**extra,
|
||||
) -> dict:
|
||||
"""Env dict for mutation tests: config-backed + optional extras.
|
||||
|
||||
Always carries ``PYTEST_CURRENT_TEST`` so ``patch.dict(..., clear=True)``
|
||||
does not strip the pytest marker and trip the #695 native-transport wall.
|
||||
"""
|
||||
env = {
|
||||
"GITEA_MCP_CONFIG": shared_mutation_config_path(
|
||||
include_example_repo=include_example_repo
|
||||
),
|
||||
"GITEA_MCP_PROFILE": profile_name,
|
||||
"GITEA_TOKEN_TEST": "test-token",
|
||||
"PYTEST_CURRENT_TEST": os.environ.get(
|
||||
"PYTEST_CURRENT_TEST", "mutation_profile_env"
|
||||
),
|
||||
}
|
||||
env.update(extra)
|
||||
# Callers must not be able to drop the pytest marker by accident.
|
||||
env.setdefault(
|
||||
"PYTEST_CURRENT_TEST",
|
||||
os.environ.get("PYTEST_CURRENT_TEST", "mutation_profile_env"),
|
||||
)
|
||||
return env
|
||||
|
||||
|
||||
def install_deterministic_remote_urls() -> None:
|
||||
"""Patch server modules so workspace remotes are deterministic.
|
||||
|
||||
Prefer live ``REMOTES`` entries (so tests that patch Example-Org keep
|
||||
workspace alignment). Map the historical prgs→Timesheet default to
|
||||
Gitea-Tools so session binding matches the control repository under test.
|
||||
"""
|
||||
import gitea_mcp_server as srv
|
||||
|
||||
def _url(name: str):
|
||||
try:
|
||||
entry = srv.REMOTES.get(name) or {}
|
||||
host = entry.get("host")
|
||||
org = entry.get("org")
|
||||
repo = entry.get("repo")
|
||||
if host and org and repo:
|
||||
if (
|
||||
name == "prgs"
|
||||
and org == "Scaled-Tech-Consulting"
|
||||
and repo == "Timesheet"
|
||||
):
|
||||
return PRGS_URL
|
||||
if name == "dadeschools" and repo == "Timesheet":
|
||||
# Prefer mdcps eAgenda canonical for dual-config tests.
|
||||
return MDCPS_URL
|
||||
return f"https://{host}/{org}/{repo}.git"
|
||||
except Exception:
|
||||
pass
|
||||
if name == "prgs":
|
||||
return PRGS_URL
|
||||
if name == "dadeschools":
|
||||
return MDCPS_URL
|
||||
return None
|
||||
|
||||
srv._local_git_remote_url = _url # type: ignore[method-assign]
|
||||
try:
|
||||
import mcp_server as mcp_srv
|
||||
|
||||
mcp_srv._local_git_remote_url = _url # type: ignore[method-assign]
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,7 +1,3 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Tests for agent temp artifact detection and preflight warnings (#261)."""
|
||||
import json
|
||||
import os
|
||||
@@ -69,9 +65,11 @@ class TestPreflightWarnings(unittest.TestCase):
|
||||
# Issue-write tools are profile-gated (#69); gitea_lock_issue requires
|
||||
# gitea.issue.comment (see task_capability_map), so the gate must be
|
||||
# seeded exactly like tests/test_mcp_server.py::TestIssueLocking (#359).
|
||||
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||
"test-author-prgs",
|
||||
)
|
||||
ISSUE_WRITE_ENV = {
|
||||
"GITEA_ALLOWED_OPERATIONS": (
|
||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class TestIssueLockArtifactWarning(unittest.TestCase):
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Regression tests for gitea_assess_conflict_fix_push structured failures (#519)."""
|
||||
|
||||
import os
|
||||
|
||||
+16
-46
@@ -1,7 +1,3 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Tests for Gitea MCP mutating-action audit logging (issue #18).
|
||||
|
||||
Covers the pure audit module (redaction, event building, sink writes) and the
|
||||
@@ -165,23 +161,11 @@ class _AuditWiringBase(unittest.TestCase):
|
||||
self._dir.cleanup()
|
||||
|
||||
def _env(self, **extra):
|
||||
# Default: prgs-aligned author with create/close (remote=prgs tests).
|
||||
# Callers override GITEA_MCP_PROFILE for merger/reviewer paths.
|
||||
profile = extra.pop("GITEA_MCP_PROFILE", None) or extra.pop(
|
||||
"GITEA_PROFILE_NAME", None
|
||||
) or "test-author-prgs"
|
||||
env = shared_mutation_env(
|
||||
profile,
|
||||
GITEA_AUDIT_LOG=self.audit_path,
|
||||
GITEA_TOKEN_TEST="test-token",
|
||||
)
|
||||
# Strip legacy env-only authority knobs if callers still pass them;
|
||||
# config-backed profiles own operations and repositories (#714).
|
||||
extra.pop("GITEA_ALLOWED_OPERATIONS", None)
|
||||
extra.pop("GITEA_FORBIDDEN_OPERATIONS", None)
|
||||
extra.pop("GITEA_PROFILE_NAME", None)
|
||||
env = {"GITEA_AUDIT_LOG": self.audit_path,
|
||||
"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": (
|
||||
"read,merge,gitea.issue.create,gitea.issue.close")}
|
||||
env.update(extra)
|
||||
env["GITEA_MCP_PROFILE"] = profile
|
||||
return env
|
||||
|
||||
def _records(self):
|
||||
@@ -212,7 +196,7 @@ class TestSimpleToolAudit(_AuditWiringBase):
|
||||
rec = recs[0]
|
||||
self.assertEqual(rec["action"], "create_issue")
|
||||
self.assertEqual(rec["result"], "succeeded")
|
||||
self.assertIn(rec["profile_name"], ("gitea-author", "test-author-prgs", "prgs-author"))
|
||||
self.assertEqual(rec["profile_name"], "gitea-author")
|
||||
self.assertEqual(rec["authenticated_username"], "author-bot")
|
||||
self.assertEqual(rec["issue_number"], 11)
|
||||
self.assertEqual(rec["request_metadata"]["title"], "Add thing")
|
||||
@@ -255,11 +239,10 @@ class TestSimpleToolAudit(_AuditWiringBase):
|
||||
def test_disabled_writes_nothing_and_no_extra_call(self, _auth, _get_all, mock_api, _role):
|
||||
# No GITEA_AUDIT_LOG -> audit is a no-op: one create POST, no file.
|
||||
mock_api.return_value = {"number": 1, "html_url": "http://x/1"}
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
shared_mutation_env("test-author-prgs"),
|
||||
clear=True,
|
||||
):
|
||||
with patch.dict(os.environ, {
|
||||
"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
||||
}, clear=True):
|
||||
gitea_create_issue(title="x", remote="prgs")
|
||||
issue_posts = [
|
||||
c for c in mock_api.call_args_list if c.args[0] == "POST"
|
||||
@@ -359,7 +342,8 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
self._pr("author-bot"), approval, # gate 7 feedback
|
||||
{}, {"merged_commit_sha": "c1"},
|
||||
]
|
||||
env = self._env(GITEA_MCP_PROFILE="test-merger-prgs")
|
||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
||||
GITEA_ALLOWED_OPERATIONS="read,merge")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8",
|
||||
@@ -378,7 +362,8 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
def test_merge_blocked_audited(self, _auth, mock_api):
|
||||
# Self-author merge is blocked; must still be recorded as blocked.
|
||||
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
||||
env = self._env(GITEA_MCP_PROFILE="test-merger-prgs")
|
||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
||||
GITEA_ALLOWED_OPERATIONS="read,merge")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", remote="prgs")
|
||||
@@ -400,23 +385,11 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
||||
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
|
||||
]
|
||||
env = self._env(GITEA_MCP_PROFILE="test-reviewer-prgs")
|
||||
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
|
||||
GITEA_ALLOWED_OPERATIONS="read,review,approve")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
import session_context_binding as _sc
|
||||
from tests.test_mcp_server import (
|
||||
_init_reviewer_session,
|
||||
_install_owned_reviewer_lease,
|
||||
)
|
||||
from mcp_server import gitea_mark_final_review_decision
|
||||
|
||||
# Rebind after profile env is applied so durable session state
|
||||
# matches the active reviewer profile (#714 / #695).
|
||||
_sc._reset_session_context_for_testing()
|
||||
_init_reviewer_session("prgs")
|
||||
lease = _install_owned_reviewer_lease(8)
|
||||
lease.start()
|
||||
self.addCleanup(lease.stop)
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
gitea_mark_final_review_decision(
|
||||
8, "approve", expected_head_sha="abc123", remote="prgs",
|
||||
)
|
||||
@@ -425,10 +398,7 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
body="LGTM", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
)
|
||||
self.assertTrue(
|
||||
r["performed"],
|
||||
msg=f"submit_pr_review blocked: {r}",
|
||||
)
|
||||
self.assertTrue(r["performed"])
|
||||
recs = self._records()
|
||||
self.assertEqual(len(recs), 1)
|
||||
self.assertEqual(recs[0]["action"], "submit_pr_review")
|
||||
|
||||
@@ -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
@@ -29,7 +29,6 @@ CONFIG = {
|
||||
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
|
||||
"forbidden_operations": ["gitea.pr.create"],
|
||||
"execution_profile": "commit-author",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
},
|
||||
"pr-only-author": {
|
||||
"enabled": True,
|
||||
@@ -40,7 +39,6 @@ CONFIG = {
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.create"],
|
||||
"forbidden_operations": ["gitea.repo.commit"],
|
||||
"execution_profile": "pr-only-author",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
},
|
||||
"reviewer-profile": {
|
||||
"enabled": True,
|
||||
@@ -53,7 +51,6 @@ CONFIG = {
|
||||
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push",
|
||||
],
|
||||
"execution_profile": "reviewer-profile",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
},
|
||||
},
|
||||
"rules": {"allow_runtime_switching": False},
|
||||
|
||||
@@ -35,7 +35,6 @@ CONFIG = {
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
"execution_profile": "full-author",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
},
|
||||
"reviewer-no-commit": {
|
||||
"enabled": True,
|
||||
@@ -50,7 +49,6 @@ CONFIG = {
|
||||
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push"
|
||||
],
|
||||
"execution_profile": "reviewer-no-commit",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
},
|
||||
},
|
||||
"rules": {"allow_runtime_switching": False},
|
||||
|
||||
@@ -35,7 +35,6 @@ CONFIG = {
|
||||
"gitea.pr.review",
|
||||
],
|
||||
"execution_profile": "full-author",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -228,7 +227,6 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_commit_files_traversal_blocked(self, _auth, mock_api):
|
||||
mock_api.return_value = {"login": "author-user"}
|
||||
# Remove active lock file to ensure it fails on traversal/invalid locks
|
||||
os.remove(self.lock_file_path)
|
||||
|
||||
@@ -250,7 +248,6 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_commit_files_outside_scope_blocked(self, _auth, mock_api):
|
||||
mock_api.return_value = {"login": "author-user"}
|
||||
with patch.dict(os.environ, self._env("full-author"), clear=True):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
mcp_server.gitea_commit_files(
|
||||
@@ -269,7 +266,6 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_commit_files_multiple_sources_blocked(self, _auth, mock_api):
|
||||
mock_api.return_value = {"login": "author-user"}
|
||||
with patch.dict(os.environ, self._env("full-author"), clear=True):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
mcp_server.gitea_commit_files(
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Tests for canonical JSON runtime-profile configuration (gitea_config) and
|
||||
its integration into gitea_auth.get_profile / get_auth_header.
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import os
|
||||
"""Tests for create_issue.py.
|
||||
|
||||
Every test mocks auth functions so no real network calls or keychain
|
||||
@@ -13,7 +12,7 @@ import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import contextlib
|
||||
from unittest.mock import patch, MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# The module under test lives in the repo root, not a package.
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
@@ -93,26 +92,6 @@ class TestRemoteResolution(unittest.TestCase):
|
||||
# ---------------------------------------------------------------------------
|
||||
@unittest.skipIf(_SKIP, _REASON)
|
||||
class TestAPIPayload(unittest.TestCase):
|
||||
"""#714: omitted --org/--repo uses workspace-bound repo (Gitea-Tools),
|
||||
not the historical REMOTES Timesheet default. Intentional security-policy
|
||||
migration of legacy omitted-target assertions.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self._ws_env = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_TEST_WORKSPACE_REMOTE_URL": (
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||
)
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
self._ws_env.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._ws_env.stop()
|
||||
|
||||
"""Ensure the JSON payload sent to Gitea is correct."""
|
||||
|
||||
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
||||
@@ -163,7 +142,7 @@ class TestAPIPayload(unittest.TestCase):
|
||||
url = mock_api.call_args[0][1]
|
||||
self.assertEqual(
|
||||
url,
|
||||
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools/issues",
|
||||
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/issues",
|
||||
)
|
||||
|
||||
|
||||
|
||||
+2
-23
@@ -1,4 +1,3 @@
|
||||
import os
|
||||
"""Tests for create_pr.py.
|
||||
|
||||
Every test mocks `get_credentials` and `urllib.request.urlopen` so no real
|
||||
@@ -9,7 +8,7 @@ import json
|
||||
import sys
|
||||
import unittest
|
||||
import contextlib
|
||||
from unittest.mock import patch, MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
import create_pr # noqa: E402
|
||||
@@ -75,26 +74,6 @@ class TestRemoteResolution(unittest.TestCase):
|
||||
# API payload
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAPIPayload(unittest.TestCase):
|
||||
"""#714: omitted --org/--repo uses workspace-bound repo (Gitea-Tools),
|
||||
not the historical REMOTES Timesheet default. Intentional security-policy
|
||||
migration of legacy omitted-target assertions.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self._ws_env = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_TEST_WORKSPACE_REMOTE_URL": (
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||
)
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
self._ws_env.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._ws_env.stop()
|
||||
|
||||
|
||||
@patch("create_pr.get_credentials", return_value=FAKE_CREDS)
|
||||
def test_payload_fields(self, _cred):
|
||||
@@ -134,7 +113,7 @@ class TestAPIPayload(unittest.TestCase):
|
||||
url = MockReq.call_args[0][0]
|
||||
self.assertEqual(
|
||||
url,
|
||||
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools/pulls",
|
||||
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/pulls",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""#685: gitea_resolve_task_capability must be side-effect free.
|
||||
|
||||
Stale-runtime detection remains fail-closed, but the resolver must never:
|
||||
* touch mcp_config.json (or any MCP client config)
|
||||
* spawn recovery threads
|
||||
* call os._exit / terminate the serving process
|
||||
* claim that an auto-restart was triggered
|
||||
|
||||
Recovery is owned by the IDE/client reconnect path only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import sys
|
||||
|
||||
ROOT = str(Path(__file__).resolve().parent.parent)
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import gitea_mcp_server as mcp_server
|
||||
|
||||
|
||||
ROLE_PROFILES = (
|
||||
("create_issue", "prgs-author", "author"),
|
||||
("review_pr", "prgs-reviewer", "reviewer"),
|
||||
("merge_pr", "prgs-merger", "merger"),
|
||||
("reconciliation_cleanup", "prgs-reconciler", "reconciler"),
|
||||
)
|
||||
|
||||
|
||||
def _stale_self_ps_mocks(profile: str = "prgs-author"):
|
||||
"""Build subprocess mocks: self PID is stale vs code mtime."""
|
||||
mock_getpid = MagicMock(return_value=12345)
|
||||
mock_exists = MagicMock(return_value=True)
|
||||
code_time = datetime(2026, 7, 8, 14, 0, 0)
|
||||
mock_getmtime = MagicMock(return_value=code_time.timestamp())
|
||||
|
||||
ps_output = (
|
||||
" PID LSTART COMMAND\n"
|
||||
"12345 Wed Jul 8 13:00:00 2026 /path/to/python mcp_server.py\n"
|
||||
)
|
||||
mock_run_ps = MagicMock()
|
||||
mock_run_ps.stdout = ps_output
|
||||
|
||||
mock_run_env = MagicMock()
|
||||
mock_run_env.stdout = f"GITEA_MCP_PROFILE={profile}"
|
||||
|
||||
mock_run_git = MagicMock()
|
||||
mock_run_git.stdout = "SAME"
|
||||
|
||||
def side_effect(args, **kwargs):
|
||||
if args[0] == "ps" and "eww" in args:
|
||||
return mock_run_env
|
||||
if args[0] == "ps":
|
||||
return mock_run_ps
|
||||
if args[0] == "git":
|
||||
return mock_run_git
|
||||
raise ValueError(f"Unexpected subprocess args: {args}")
|
||||
|
||||
mock_run = MagicMock(side_effect=side_effect)
|
||||
return mock_getpid, mock_exists, mock_getmtime, mock_run
|
||||
|
||||
|
||||
class TestIssue685DiagnosticsNoSideEffects(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
|
||||
@patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False)
|
||||
@patch("subprocess.run")
|
||||
@patch("os.path.getmtime")
|
||||
@patch("os.path.exists")
|
||||
@patch("os.getpid")
|
||||
@patch("os.utime")
|
||||
@patch("threading.Thread")
|
||||
@patch("os._exit")
|
||||
def test_stale_self_does_not_touch_config_or_exit(
|
||||
self,
|
||||
mock_exit,
|
||||
mock_thread,
|
||||
mock_utime,
|
||||
mock_getpid,
|
||||
mock_exists,
|
||||
mock_getmtime,
|
||||
mock_run,
|
||||
):
|
||||
mock_getpid.return_value = 12345
|
||||
mock_exists.return_value = True
|
||||
mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp()
|
||||
mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect
|
||||
|
||||
before_threads = threading.active_count()
|
||||
reasons = mcp_server._check_mcp_runtimes_diagnostics(
|
||||
"create_issue", ["prgs-author"]
|
||||
)
|
||||
after_threads = threading.active_count()
|
||||
|
||||
self.assertTrue(
|
||||
any("stale-runtime" in r and "active Gitea MCP server process is stale" in r
|
||||
for r in reasons),
|
||||
reasons,
|
||||
)
|
||||
# Must not claim auto-restart / config touch
|
||||
blob = " ".join(reasons)
|
||||
self.assertNotIn("Auto-restart has been triggered", blob)
|
||||
self.assertNotIn("touched mcp_config", blob)
|
||||
self.assertNotIn("will cleanly exit", blob)
|
||||
|
||||
mock_utime.assert_not_called()
|
||||
mock_thread.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
self.assertEqual(before_threads, after_threads)
|
||||
|
||||
@patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False)
|
||||
@patch("subprocess.run")
|
||||
@patch("os.path.getmtime")
|
||||
@patch("os.path.exists")
|
||||
@patch("os.getpid")
|
||||
@patch("os.utime")
|
||||
def test_repeated_stale_calls_do_not_trigger_restart_loop(
|
||||
self, mock_utime, mock_getpid, mock_exists, mock_getmtime, mock_run
|
||||
):
|
||||
mock_getpid.return_value = 12345
|
||||
mock_exists.return_value = True
|
||||
mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp()
|
||||
mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect
|
||||
|
||||
for _ in range(5):
|
||||
reasons = mcp_server._check_mcp_runtimes_diagnostics(
|
||||
"create_issue", ["prgs-author"]
|
||||
)
|
||||
self.assertTrue(any("stale-runtime" in r for r in reasons))
|
||||
|
||||
mock_utime.assert_not_called()
|
||||
|
||||
def test_trigger_mcp_auto_restart_removed(self):
|
||||
"""#685 AC: auto-restart helper is removed (unreachable from read-only)."""
|
||||
self.assertFalse(hasattr(mcp_server, "_trigger_mcp_auto_restart"))
|
||||
self.assertFalse(hasattr(mcp_server, "_restart_triggered"))
|
||||
|
||||
|
||||
class TestIssue685ResolverTypedBlocker(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
if hasattr(mcp_server, "capability_stop_terminal"):
|
||||
mcp_server.capability_stop_terminal.clear()
|
||||
|
||||
def _resolve_with_stale_runtime(self, task: str, profile_name: str, role: str):
|
||||
allowed = [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.branch.delete",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.close",
|
||||
"gitea.repo.commit",
|
||||
]
|
||||
profile = {
|
||||
"profile_name": profile_name,
|
||||
"role": role,
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
config = {
|
||||
"profiles": {
|
||||
profile_name: {
|
||||
"role": role,
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mock_getpid, mock_exists, mock_getmtime, mock_run = _stale_self_ps_mocks(
|
||||
profile_name
|
||||
)
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_FORCE_MCP_RUNTIME_CHECK": "1",
|
||||
"GITEA_MCP_PROFILE": profile_name,
|
||||
},
|
||||
clear=False,
|
||||
), patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
||||
mcp_server.gitea_config, "load_config", return_value=config
|
||||
), patch.object(
|
||||
mcp_server, "_authenticated_username", return_value="test-user"
|
||||
), patch.object(
|
||||
mcp_server, "_ensure_matching_profile", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "record_preflight_check", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "record_mutation_authority", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "init_review_decision_lock", return_value=None
|
||||
), patch(
|
||||
"subprocess.run", mock_run
|
||||
), patch(
|
||||
"os.path.getmtime", mock_getmtime
|
||||
), patch(
|
||||
"os.path.exists", mock_exists
|
||||
), patch(
|
||||
"os.getpid", mock_getpid
|
||||
), patch(
|
||||
"os.utime"
|
||||
) as mock_utime, patch(
|
||||
"threading.Thread"
|
||||
) as mock_thread, patch(
|
||||
"os._exit"
|
||||
) as mock_exit:
|
||||
result = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
|
||||
return result, mock_utime, mock_thread, mock_exit
|
||||
|
||||
def test_stale_returns_typed_blocker_fields(self):
|
||||
result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime(
|
||||
"create_issue", "prgs-author", "author"
|
||||
)
|
||||
self.assertTrue(result.get("restart_required"), result)
|
||||
self.assertTrue(result.get("stop_required"), result)
|
||||
self.assertEqual(result.get("blocker_kind"), "runtime_reconnect_required")
|
||||
self.assertIs(result.get("mutation_performed"), False)
|
||||
action = result.get("exact_safe_next_action") or ""
|
||||
self.assertIn("reconnect", action.lower())
|
||||
self.assertNotIn("None; ready for operations", action)
|
||||
reason = result.get("reason") or ""
|
||||
self.assertIn("stale-runtime", reason)
|
||||
self.assertNotIn("Auto-restart has been triggered", reason)
|
||||
mock_utime.assert_not_called()
|
||||
mock_thread.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
|
||||
def test_all_four_role_profiles_get_same_side_effect_free_contract(self):
|
||||
for task, profile, role in ROLE_PROFILES:
|
||||
with self.subTest(task=task, profile=profile):
|
||||
# Skip tasks that may be unknown on this branch
|
||||
try:
|
||||
import task_capability_map as tcm
|
||||
|
||||
tcm.required_permission(task)
|
||||
except Exception:
|
||||
self.skipTest(f"task {task} not in capability map")
|
||||
|
||||
result, mock_utime, mock_thread, mock_exit = (
|
||||
self._resolve_with_stale_runtime(task, profile, role)
|
||||
)
|
||||
self.assertTrue(
|
||||
result.get("restart_required") or result.get("stop_required"),
|
||||
result,
|
||||
)
|
||||
self.assertEqual(
|
||||
result.get("blocker_kind"), "runtime_reconnect_required", result
|
||||
)
|
||||
self.assertIs(result.get("mutation_performed"), False, result)
|
||||
mock_utime.assert_not_called()
|
||||
mock_thread.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
|
||||
def test_config_mtime_and_contents_unchanged(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cfg = os.path.join(tmp, "mcp_config.json")
|
||||
original = '{"servers": {"gitea-author": {}}}'
|
||||
with open(cfg, "w", encoding="utf-8") as fh:
|
||||
fh.write(original)
|
||||
mtime_before = os.path.getmtime(cfg)
|
||||
|
||||
result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime(
|
||||
"create_issue", "prgs-author", "author"
|
||||
)
|
||||
# Force-path also must not use real utime when diagnostics runs
|
||||
with open(cfg, encoding="utf-8") as fh:
|
||||
after = fh.read()
|
||||
self.assertEqual(after, original)
|
||||
self.assertEqual(os.path.getmtime(cfg), mtime_before)
|
||||
mock_utime.assert_not_called()
|
||||
self.assertTrue(result.get("restart_required"), result)
|
||||
|
||||
|
||||
class TestIssue685MutationGatesStillFailClosed(unittest.TestCase):
|
||||
def test_parity_stale_still_reports_restart_required(self):
|
||||
"""Mutation-facing parity gate remains fail-closed when heads differ."""
|
||||
import master_parity_gate as mpg
|
||||
|
||||
out = mpg.assess_master_parity(
|
||||
{"startup_head": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
)
|
||||
self.assertFalse(out.get("in_parity"))
|
||||
self.assertTrue(out.get("restart_required") or out.get("stale"))
|
||||
|
||||
|
||||
class TestIssue685DocstringReadOnlyContract(unittest.TestCase):
|
||||
def test_resolve_docstring_declares_side_effect_free(self):
|
||||
doc = mcp_server.gitea_resolve_task_capability.__doc__ or ""
|
||||
lower = doc.lower()
|
||||
self.assertTrue(
|
||||
"side-effect" in lower or "read-only" in lower or "does not mutate" in lower,
|
||||
doc,
|
||||
)
|
||||
self.assertNotIn("auto-restart", lower)
|
||||
self.assertNotIn("os._exit", lower)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,509 +0,0 @@
|
||||
"""#714: production first-bind pins the workspace-verified repository scope.
|
||||
|
||||
These tests drive the real entry points (``gitea_whoami``,
|
||||
``gitea_get_runtime_context``, ``gitea_resolve_task_capability``,
|
||||
``gitea_activate_profile``) in production order. They deliberately do not
|
||||
construct a ``_SessionContext`` directly: the defect they cover is that the
|
||||
production first-bind path left ``repository``/``org`` unbound, so
|
||||
``assess_session_context`` skipped its repository/org drift checks and a
|
||||
same-host mutation against another repository was not blocked.
|
||||
|
||||
The trusted repository identity comes from the workspace-aligned git remote
|
||||
(``Scaled-Tech-Consulting/Gitea-Tools`` for this checkout), never from
|
||||
``REMOTES`` (whose ``prgs`` default repo is ``Timesheet``) and never from a
|
||||
caller-supplied ``org``/``repo`` argument.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import gitea_config # noqa: E402
|
||||
import gitea_mcp_server as srv # noqa: E402
|
||||
import mcp_server # noqa: E402
|
||||
import session_context_binding as session_ctx # noqa: E402
|
||||
|
||||
WORKSPACE_ORG = "Scaled-Tech-Consulting"
|
||||
WORKSPACE_REPO = "Gitea-Tools"
|
||||
WORKSPACE_SLUG = f"{WORKSPACE_ORG}/{WORKSPACE_REPO}"
|
||||
WORKSPACE_URL = f"https://gitea.prgs.cc/{WORKSPACE_SLUG}.git"
|
||||
|
||||
_BASE_AUTHOR_OPS = [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
]
|
||||
|
||||
|
||||
def _config(allowed_repositories=None):
|
||||
profile = {
|
||||
"enabled": True,
|
||||
"context": "prgs",
|
||||
"role": "author",
|
||||
"username": "jcwalker3",
|
||||
"base_url": "https://gitea.prgs.cc",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"},
|
||||
"allowed_operations": list(_BASE_AUTHOR_OPS),
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
"execution_profile": "prgs-author",
|
||||
}
|
||||
if allowed_repositories is not None:
|
||||
profile["allowed_repositories"] = list(allowed_repositories)
|
||||
return {
|
||||
"version": 2,
|
||||
# v2-contexts normalizes the config and keeps only rules.* — a
|
||||
# top-level allow_runtime_switching would be dropped.
|
||||
"rules": {"allow_runtime_switching": True},
|
||||
"contexts": {
|
||||
"prgs": {
|
||||
"enabled": True,
|
||||
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||
},
|
||||
"mdcps": {
|
||||
"enabled": True,
|
||||
"gitea": {
|
||||
"enabled": True,
|
||||
"base_url": "https://gitea.dadeschools.net",
|
||||
},
|
||||
},
|
||||
},
|
||||
"profiles": {"prgs-author": profile},
|
||||
}
|
||||
|
||||
|
||||
class _ProductionOrderBase(unittest.TestCase):
|
||||
"""Real entry points, pinned workspace remote, mocked network only."""
|
||||
|
||||
allowed_repositories = [WORKSPACE_SLUG]
|
||||
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(_config(self.allowed_repositories)))
|
||||
self._env = {
|
||||
"GITEA_MCP_CONFIG": self.config_path,
|
||||
"GITEA_MCP_PROFILE": "prgs-author",
|
||||
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
|
||||
}
|
||||
gitea_config._active_profile_override = None
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
srv._MUTATION_AUTHORITY = None
|
||||
# Pin the workspace git remote so the trusted source is deterministic
|
||||
# and never depends on the developer's checkout layout.
|
||||
self._remote_url = patch.object(
|
||||
srv, "_local_git_remote_url", side_effect=self._local_remote_url
|
||||
)
|
||||
self._remote_url.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._remote_url.stop()
|
||||
gitea_config._active_profile_override = None
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
srv._MUTATION_AUTHORITY = None
|
||||
self._dir.cleanup()
|
||||
|
||||
def _local_remote_url(self, remote_name):
|
||||
return WORKSPACE_URL if remote_name == "prgs" else None
|
||||
|
||||
def _api(self, method, url, header):
|
||||
return {
|
||||
"login": "jcwalker3",
|
||||
"full_name": "Test",
|
||||
"id": 1,
|
||||
"email": "[email protected]",
|
||||
}
|
||||
|
||||
def _live(self):
|
||||
return patch("gitea_mcp_server.api_request", side_effect=self._api)
|
||||
|
||||
|
||||
class TestProductionFirstBindPinsRepository(_ProductionOrderBase):
|
||||
def test_whoami_first_bind_is_complete(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
ctx = session_ctx.get_session_context()
|
||||
self.assertIsNotNone(ctx)
|
||||
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||
self.assertEqual(ctx["remote"], "prgs")
|
||||
self.assertEqual(ctx["host"], "gitea.prgs.cc")
|
||||
|
||||
def test_runtime_context_first_bind_is_complete(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_get_runtime_context(remote="prgs")
|
||||
ctx = session_ctx.get_session_context()
|
||||
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||
|
||||
def test_capability_preflight_first_bind_is_complete(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_resolve_task_capability(task="comment_issue", remote="prgs")
|
||||
ctx = session_ctx.get_session_context()
|
||||
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||
|
||||
def test_activate_profile_binds_complete_context(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
|
||||
ctx = session_ctx.get_session_context()
|
||||
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||
|
||||
|
||||
class TestProductionOrderRepositoryDriftBlocks(_ProductionOrderBase):
|
||||
def test_whoami_then_same_host_other_repository_blocks(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any("Other-Tools" in r for r in blocked["reasons"]), blocked["reasons"]
|
||||
)
|
||||
|
||||
def test_whoami_then_timesheet_blocks(self):
|
||||
"""REMOTES['prgs'].repo is Timesheet — a default target, not a scope."""
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs",
|
||||
org=WORKSPACE_ORG,
|
||||
repo="Timesheet",
|
||||
org_explicit=True,
|
||||
repo_explicit=True,
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any("Timesheet" in r for r in blocked["reasons"]), blocked["reasons"]
|
||||
)
|
||||
|
||||
def test_whoami_then_same_host_other_org_blocks(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs", org="Other-Org", repo=WORKSPACE_REPO
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any("Other-Org" in r for r in blocked["reasons"]), blocked["reasons"]
|
||||
)
|
||||
|
||||
def test_runtime_context_then_other_repository_blocks(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_get_runtime_context(remote="prgs")
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
|
||||
def test_capability_preflight_then_other_repository_blocks(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_resolve_task_capability(task="comment_issue", remote="prgs")
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
|
||||
def test_activate_profile_then_other_repository_blocks(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs", org="Other-Org", repo="Other-Tools"
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
|
||||
def test_cross_host_still_blocks(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
blocked = srv._session_context_mutation_block(remote="dadeschools")
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any("cross-host" in r for r in blocked["reasons"]), blocked["reasons"]
|
||||
)
|
||||
|
||||
def test_authorized_repository_mutation_remains_allowed(self):
|
||||
"""Normal PR #715 author comment/push path must stay functional."""
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
self.assertIsNone(
|
||||
srv._session_context_mutation_block(
|
||||
remote="prgs", org=WORKSPACE_ORG, repo=WORKSPACE_REPO
|
||||
)
|
||||
)
|
||||
# A bare call with no override resolves to the same bound scope.
|
||||
self.assertIsNone(srv._session_context_mutation_block(remote="prgs"))
|
||||
|
||||
|
||||
class TestMutationRequestCannotEstablishBinding(_ProductionOrderBase):
|
||||
def test_caller_values_cannot_establish_binding_on_first_mutation(self):
|
||||
"""A mutation-first session must not be pinned by request values."""
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
self.assertIsNone(session_ctx.get_session_context())
|
||||
srv._session_context_mutation_block(
|
||||
remote="prgs", org="Attacker-Org", repo="Attacker-Repo"
|
||||
)
|
||||
ctx = session_ctx.get_session_context()
|
||||
self.assertIsNotNone(ctx)
|
||||
# Bound to the verified workspace, never to the request values.
|
||||
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||
|
||||
def test_mutation_first_with_attacker_values_is_blocked(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs", org="Attacker-Org", repo="Attacker-Repo"
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
|
||||
def test_later_call_cannot_overwrite_bound_repository(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
before = session_ctx.get_session_context()
|
||||
for _ in range(3):
|
||||
srv._session_context_mutation_block(
|
||||
remote="prgs", org="Other-Org", repo="Other-Tools"
|
||||
)
|
||||
self.assertEqual(session_ctx.get_session_context(), before)
|
||||
|
||||
|
||||
class TestUnverifiedWorkspaceFailsClosed(_ProductionOrderBase):
|
||||
def _local_remote_url(self, remote_name):
|
||||
return None # no verifiable workspace repository
|
||||
|
||||
def test_mutation_blocks_when_workspace_repository_unverified(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
ctx = session_ctx.get_session_context()
|
||||
self.assertIsNone(ctx["repository"])
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs", org=WORKSPACE_ORG, repo=WORKSPACE_REPO
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any(
|
||||
"no verified workspace repository" in r or "unverified" in r
|
||||
for r in blocked["reasons"]
|
||||
),
|
||||
blocked["reasons"],
|
||||
)
|
||||
|
||||
def test_activate_profile_rejects_unverifiable_workspace(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
res = srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
|
||||
self.assertFalse(res.get("success", True))
|
||||
self.assertEqual(res.get("blocker_kind"), "repository_scope")
|
||||
|
||||
|
||||
class TestUnauthorizedWorkspaceFailsClosed(_ProductionOrderBase):
|
||||
allowed_repositories = ["Scaled-Tech-Consulting/Some-Other-Project"]
|
||||
|
||||
def test_workspace_absent_from_allowlist_blocks_mutation(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
blocked = srv._session_context_mutation_block(remote="prgs")
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any("not authorized" in r for r in blocked["reasons"]),
|
||||
blocked["reasons"],
|
||||
)
|
||||
|
||||
def test_workspace_absent_from_allowlist_blocks_activation(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
res = srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
|
||||
self.assertFalse(res.get("success", True))
|
||||
self.assertEqual(res.get("blocker_kind"), "repository_scope")
|
||||
|
||||
|
||||
class TestTimesheetNotAuthorizedInThisPhase(_ProductionOrderBase):
|
||||
"""Cross-project use is paused: only Gitea-Tools is authorized."""
|
||||
|
||||
def test_timesheet_workspace_is_rejected(self):
|
||||
def timesheet_remote(remote_name):
|
||||
return "https://gitea.prgs.cc/Scaled-Tech-Consulting/Timesheet.git"
|
||||
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
with patch.object(
|
||||
srv, "_local_git_remote_url", side_effect=timesheet_remote
|
||||
):
|
||||
blocked = srv._session_context_mutation_block(remote="prgs")
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any("not authorized" in r for r in blocked["reasons"]),
|
||||
blocked["reasons"],
|
||||
)
|
||||
|
||||
|
||||
class TestConcurrentFirstBindSelectsOneRepository(_ProductionOrderBase):
|
||||
def test_concurrent_initialization_cannot_change_selected_repository(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
self.assertIsNone(session_ctx.get_session_context())
|
||||
thread_count = 8
|
||||
barrier = threading.Barrier(thread_count)
|
||||
results = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def racer(index: int) -> None:
|
||||
barrier.wait()
|
||||
srv._session_context_mutation_block(
|
||||
remote="prgs", org=f"Racer-Org-{index}", repo=f"Racer-Repo-{index}"
|
||||
)
|
||||
with lock:
|
||||
results.append(session_ctx.get_session_context())
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=racer, args=(i,)) for i in range(thread_count)
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(timeout=10)
|
||||
|
||||
self.assertFalse(any(t.is_alive() for t in threads))
|
||||
winner = session_ctx.get_session_context()
|
||||
self.assertEqual(winner["org"], WORKSPACE_ORG)
|
||||
self.assertEqual(winner["repository"], WORKSPACE_REPO)
|
||||
self.assertEqual(results, [winner] * thread_count)
|
||||
|
||||
|
||||
class TestRepositoryScopeUnits(unittest.TestCase):
|
||||
def test_absent_scope_is_not_enforced_for_read_diagnostics(self):
|
||||
scope = session_ctx.assess_repository_scope(
|
||||
workspace_slug=WORKSPACE_SLUG, allowed=[], profile_name="legacy"
|
||||
)
|
||||
self.assertTrue(scope["proven"])
|
||||
self.assertFalse(scope["scope_enforced"])
|
||||
|
||||
def test_require_scope_blocks_empty_or_missing_allowlist(self):
|
||||
scope = session_ctx.assess_repository_scope(
|
||||
workspace_slug=WORKSPACE_SLUG,
|
||||
allowed=[],
|
||||
profile_name="legacy",
|
||||
require_scope=True,
|
||||
)
|
||||
self.assertTrue(scope["block"])
|
||||
self.assertTrue(any("allowed_repositories" in r for r in scope["reasons"]))
|
||||
|
||||
def test_declared_scope_authorizes_only_listed_repository(self):
|
||||
allowed = session_ctx.declared_allowed_repositories(
|
||||
{"allowed_repositories": [WORKSPACE_SLUG]}
|
||||
)
|
||||
self.assertEqual(allowed, [WORKSPACE_SLUG])
|
||||
self.assertTrue(
|
||||
session_ctx.assess_repository_scope(
|
||||
workspace_slug=WORKSPACE_SLUG, allowed=allowed
|
||||
)["proven"]
|
||||
)
|
||||
self.assertTrue(
|
||||
session_ctx.assess_repository_scope(
|
||||
workspace_slug="Scaled-Tech-Consulting/Timesheet", allowed=allowed
|
||||
)["block"]
|
||||
)
|
||||
|
||||
def test_missing_workspace_slug_blocks_when_scope_declared(self):
|
||||
scope = session_ctx.assess_repository_scope(
|
||||
workspace_slug=None, allowed=[WORKSPACE_SLUG]
|
||||
)
|
||||
self.assertTrue(scope["block"])
|
||||
|
||||
def test_override_must_match_binding(self):
|
||||
self.assertTrue(
|
||||
session_ctx.assess_repository_override(
|
||||
requested_org=WORKSPACE_ORG,
|
||||
requested_repo="Other",
|
||||
bound_org=WORKSPACE_ORG,
|
||||
bound_repo=WORKSPACE_REPO,
|
||||
)["block"]
|
||||
)
|
||||
self.assertTrue(
|
||||
session_ctx.assess_repository_override(
|
||||
requested_org="Other-Org",
|
||||
requested_repo=WORKSPACE_REPO,
|
||||
bound_org=WORKSPACE_ORG,
|
||||
bound_repo=WORKSPACE_REPO,
|
||||
)["block"]
|
||||
)
|
||||
self.assertTrue(
|
||||
session_ctx.assess_repository_override(
|
||||
requested_org=WORKSPACE_ORG,
|
||||
requested_repo=WORKSPACE_REPO,
|
||||
bound_org=WORKSPACE_ORG,
|
||||
bound_repo=WORKSPACE_REPO,
|
||||
)["proven"]
|
||||
)
|
||||
|
||||
def test_malformed_allowlist_entries_are_ignored_in_non_strict_mode(self):
|
||||
self.assertEqual(
|
||||
session_ctx.declared_allowed_repositories(
|
||||
{"allowed_repositories": ["not-a-slug", 17, None, WORKSPACE_SLUG]}
|
||||
),
|
||||
[WORKSPACE_SLUG],
|
||||
)
|
||||
|
||||
def test_strict_mode_rejects_malformed_allowlist_entries(self):
|
||||
with self.assertRaises(ValueError):
|
||||
session_ctx.declared_allowed_repositories(
|
||||
{"allowed_repositories": ["not-a-slug", WORKSPACE_SLUG]},
|
||||
strict=True,
|
||||
)
|
||||
|
||||
|
||||
class TestRemotesDefaultNotCallerOverride(_ProductionOrderBase):
|
||||
def test_resolved_timesheet_default_does_not_block_when_bound_to_workspace(self):
|
||||
"""Tools historically pass REMOTES-filled Timesheet after _resolve; that
|
||||
must not be treated as an explicit override against Gitea-Tools."""
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
# Simulate create_issue after resolve: org/repo are REMOTES defaults
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Timesheet",
|
||||
)
|
||||
self.assertIsNone(blocked, getattr(blocked, "get", lambda *_: blocked)("reasons") if blocked else None)
|
||||
|
||||
def test_git_unavailable_blocks_mutation(self):
|
||||
def no_remote(_name):
|
||||
return None
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
with patch.object(srv, "_local_git_remote_url", side_effect=no_remote):
|
||||
blocked = srv._session_context_mutation_block(remote="prgs")
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any(
|
||||
"workspace" in r.lower() or "allowed_repositories" in r or "unverified" in r
|
||||
for r in blocked["reasons"]
|
||||
),
|
||||
blocked["reasons"],
|
||||
)
|
||||
|
||||
def test_explicit_mismatch_still_blocks(self):
|
||||
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||
srv.gitea_whoami(remote="prgs")
|
||||
blocked = srv._session_context_mutation_block(
|
||||
remote="prgs",
|
||||
org=WORKSPACE_ORG,
|
||||
repo="Other-Tools",
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,608 +0,0 @@
|
||||
"""#714: fail closed on cross-host MCP profile drift and capability substitution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import gitea_config # noqa: E402
|
||||
import mcp_server # noqa: E402
|
||||
import session_context_binding as session_ctx # noqa: E402
|
||||
|
||||
|
||||
CONFIG_714 = {
|
||||
"version": 2,
|
||||
"allow_runtime_switching": True,
|
||||
"contexts": {
|
||||
"prgs": {
|
||||
"enabled": True,
|
||||
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||
},
|
||||
"mdcps": {
|
||||
"enabled": True,
|
||||
"gitea": {"enabled": True, "base_url": "https://gitea.dadeschools.net"},
|
||||
},
|
||||
},
|
||||
"profiles": {
|
||||
"prgs-author": {
|
||||
"enabled": True,
|
||||
"context": "prgs",
|
||||
"role": "author",
|
||||
"username": "jcwalker3",
|
||||
"base_url": "https://gitea.prgs.cc",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"},
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||
"execution_profile": "prgs-author",
|
||||
},
|
||||
"mdcps-reviewer": {
|
||||
"enabled": True,
|
||||
"context": "mdcps",
|
||||
"role": "reviewer",
|
||||
"username": "913443",
|
||||
"base_url": "https://gitea.dadeschools.net",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_MDCPS_REVIEWER"},
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.comment",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.merge",
|
||||
"gitea.repo.commit",
|
||||
],
|
||||
"allowed_repositories": ["913443/eAgenda"],
|
||||
"execution_profile": "mdcps-reviewer",
|
||||
},
|
||||
"mdcps-author": {
|
||||
"enabled": True,
|
||||
"context": "mdcps",
|
||||
"role": "author",
|
||||
"username": "913443",
|
||||
"base_url": "https://gitea.dadeschools.net",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_MDCPS_AUTHOR"},
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
"allowed_repositories": ["913443/eAgenda"],
|
||||
"execution_profile": "mdcps-author",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestIssue714SessionContextImmutability(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(CONFIG_714))
|
||||
self._remotes = patch.dict(
|
||||
mcp_server.REMOTES,
|
||||
{
|
||||
"dadeschools": {
|
||||
"host": "gitea.dadeschools.net",
|
||||
"org": "913443",
|
||||
"repo": "eAgenda",
|
||||
},
|
||||
"prgs": {
|
||||
"host": "gitea.prgs.cc",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
},
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
self._remotes.start()
|
||||
def _url(name):
|
||||
if name == "dadeschools":
|
||||
return "https://gitea.dadeschools.net/913443/eAgenda.git"
|
||||
if name == "prgs":
|
||||
return "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||
return None
|
||||
self._url_patch = patch.object(mcp_server, "_local_git_remote_url", side_effect=_url)
|
||||
self._url_patch.start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
gitea_config._active_profile_override = None
|
||||
mcp_server._MUTATION_AUTHORITY = None
|
||||
self._env = {
|
||||
"GITEA_MCP_CONFIG": self.config_path,
|
||||
"GITEA_MCP_PROFILE": "mdcps-reviewer",
|
||||
"GITEA_TOKEN_MDCPS_REVIEWER": "mdcps-reviewer-token",
|
||||
"GITEA_TOKEN_MDCPS_AUTHOR": "mdcps-author-token",
|
||||
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
self._url_patch.stop()
|
||||
self._remotes.stop()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
gitea_config._active_profile_override = None
|
||||
mcp_server._MUTATION_AUTHORITY = None
|
||||
self._dir.cleanup()
|
||||
|
||||
def _api_side_effect(self, method, url, header):
|
||||
# Token identity mapping for whoami endpoint
|
||||
auth = str(header)
|
||||
if "mdcps-reviewer-token" in auth or "mdcps-author-token" in auth:
|
||||
login = "913443"
|
||||
else:
|
||||
login = "jcwalker3"
|
||||
return {"login": login, "full_name": "Test", "id": 1, "email": "[email protected]"}
|
||||
|
||||
def test_pin_mdcps_reviewer_never_drifts_to_prgs_author(self):
|
||||
"""Reproduce the incident: pin mdcps-reviewer then resolve comment_issue."""
|
||||
with patch.dict(os.environ, self._env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||
act = mcp_server.gitea_activate_profile(
|
||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||
)
|
||||
self.assertTrue(act["success"])
|
||||
self.assertEqual(act["after_profile"], "mdcps-reviewer")
|
||||
|
||||
who1 = mcp_server.gitea_whoami(remote="dadeschools")
|
||||
self.assertEqual(who1["profile"]["profile_name"], "mdcps-reviewer")
|
||||
self.assertEqual(who1["username"], "913443")
|
||||
|
||||
# Capability that mdcps-reviewer lacks — must NOT switch to prgs-author
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="comment_issue", remote="dadeschools"
|
||||
)
|
||||
self.assertEqual(res["active_profile"], "mdcps-reviewer")
|
||||
self.assertFalse(res["allowed_in_current_session"])
|
||||
self.assertFalse(res.get("auto_profile_substitution", True))
|
||||
self.assertNotIn("prgs-author", res["matching_configured_profile"])
|
||||
self.assertIn("mdcps-author", res["matching_configured_profile"])
|
||||
|
||||
who2 = mcp_server.gitea_whoami(remote="dadeschools")
|
||||
rt = mcp_server.gitea_get_runtime_context(remote="dadeschools")
|
||||
self.assertEqual(who2["profile"]["profile_name"], "mdcps-reviewer")
|
||||
self.assertEqual(rt["active_profile"], "mdcps-reviewer")
|
||||
# Still pinned — never prgs-author
|
||||
self.assertEqual(
|
||||
gitea_config.selected_profile_name(), "mdcps-reviewer"
|
||||
)
|
||||
|
||||
def test_repeated_calls_remain_on_mdcps_reviewer(self):
|
||||
with patch.dict(os.environ, self._env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||
mcp_server.gitea_activate_profile(
|
||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||
)
|
||||
for _ in range(5):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="review_pr", remote="dadeschools"
|
||||
)
|
||||
self.assertEqual(res["active_profile"], "mdcps-reviewer")
|
||||
who = mcp_server.gitea_whoami(remote="dadeschools")
|
||||
self.assertEqual(who["profile"]["profile_name"], "mdcps-reviewer")
|
||||
rt = mcp_server.gitea_get_runtime_context(remote="dadeschools")
|
||||
self.assertEqual(rt["active_profile"], "mdcps-reviewer")
|
||||
|
||||
def test_dadeschools_request_cannot_resolve_through_prgs_author(self):
|
||||
with patch.dict(os.environ, self._env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||
mcp_server.gitea_activate_profile(
|
||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||
)
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="comment_issue", remote="dadeschools"
|
||||
)
|
||||
self.assertNotEqual(res["active_profile"], "prgs-author")
|
||||
self.assertNotIn("prgs-author", res["matching_configured_profile"])
|
||||
# Gate must not silently switch either
|
||||
blocked = mcp_server._profile_permission_block(
|
||||
"gitea.issue.comment", remote="dadeschools"
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertFalse(blocked.get("success", True))
|
||||
self.assertEqual(
|
||||
gitea_config.selected_profile_name(), "mdcps-reviewer"
|
||||
)
|
||||
|
||||
def test_prgs_request_cannot_resolve_through_mdcps_profile(self):
|
||||
"""Reverse of the incident: a pinned MDCPS profile must never serve a
|
||||
prgs request, nor be silently swapped for the prgs-side profile."""
|
||||
with patch.dict(os.environ, self._env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||
mcp_server.gitea_activate_profile(
|
||||
profile_name="mdcps-author", remote="dadeschools"
|
||||
)
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="create_issue", remote="prgs"
|
||||
)
|
||||
self.assertNotEqual(res["active_profile"], "prgs-author")
|
||||
self.assertEqual(res["active_profile"], "mdcps-author")
|
||||
self.assertFalse(res["allowed_in_current_session"])
|
||||
self.assertTrue(res["stop_required"])
|
||||
blocked = mcp_server._session_context_mutation_block(remote="prgs")
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(any("cross-host" in r for r in blocked["reasons"]))
|
||||
self.assertEqual(gitea_config.selected_profile_name(), "mdcps-author")
|
||||
|
||||
def test_unsupported_capability_fails_closed_structured(self):
|
||||
with patch.dict(os.environ, self._env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||
mcp_server.gitea_activate_profile(
|
||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||
)
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="comment_issue", remote="dadeschools"
|
||||
)
|
||||
self.assertFalse(res["allowed_in_current_session"])
|
||||
self.assertTrue(res["stop_required"])
|
||||
self.assertIn("reason", res)
|
||||
self.assertIn("exact_safe_next_action", res)
|
||||
self.assertIn("activate_profile", res["exact_safe_next_action"])
|
||||
# Unknown task still fail closed
|
||||
with self.assertRaises(ValueError):
|
||||
mcp_server.gitea_resolve_task_capability(
|
||||
task="reopen_issue", remote="dadeschools"
|
||||
)
|
||||
|
||||
def test_identity_mismatch_blocks_mutation(self):
|
||||
"""Profile expects 913443 but authenticated as jcwalker3."""
|
||||
|
||||
def wrong_identity(method, url, header):
|
||||
return {
|
||||
"login": "jcwalker3",
|
||||
"full_name": "Wrong",
|
||||
"id": 9,
|
||||
"email": "[email protected]",
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, self._env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=wrong_identity):
|
||||
mcp_server.gitea_activate_profile(
|
||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||
)
|
||||
blocked = mcp_server._session_context_mutation_block(
|
||||
remote="dadeschools"
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any("identity mismatch" in r for r in blocked["reasons"])
|
||||
)
|
||||
|
||||
def test_repository_host_mismatch_blocks_mutation(self):
|
||||
"""mdcps-reviewer cannot serve prgs remote."""
|
||||
with patch.dict(os.environ, self._env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||
mcp_server.gitea_activate_profile(
|
||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||
)
|
||||
blocked = mcp_server._session_context_mutation_block(remote="prgs")
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(
|
||||
any("cross-host" in r for r in blocked["reasons"])
|
||||
)
|
||||
|
||||
def test_drift_cannot_reach_write(self):
|
||||
"""Simulated mid-session profile override cannot pass permission gate."""
|
||||
with patch.dict(os.environ, self._env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||
mcp_server.gitea_activate_profile(
|
||||
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||
)
|
||||
# Bind session as mdcps-reviewer
|
||||
self.assertEqual(
|
||||
session_ctx.get_session_context()["profile_name"],
|
||||
"mdcps-reviewer",
|
||||
)
|
||||
# Hostile override without activate_profile
|
||||
gitea_config._active_profile_override = "prgs-author"
|
||||
blocked = mcp_server._session_context_mutation_block(
|
||||
remote="dadeschools"
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertTrue(any("drift" in r or "cross-host" in r for r in blocked["reasons"]))
|
||||
|
||||
def test_static_prgs_author_still_works(self):
|
||||
env = {
|
||||
"GITEA_MCP_CONFIG": self.config_path,
|
||||
"GITEA_MCP_PROFILE": "prgs-author",
|
||||
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||
gitea_config._active_profile_override = None
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="create_issue", remote="prgs"
|
||||
)
|
||||
self.assertEqual(res["active_profile"], "prgs-author")
|
||||
self.assertTrue(res["allowed_in_current_session"])
|
||||
self.assertEqual(res["active_identity"], "jcwalker3")
|
||||
|
||||
def test_static_mdcps_author_still_works(self):
|
||||
env = {
|
||||
"GITEA_MCP_CONFIG": self.config_path,
|
||||
"GITEA_MCP_PROFILE": "mdcps-author",
|
||||
"GITEA_TOKEN_MDCPS_AUTHOR": "mdcps-author-token",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||
gitea_config._active_profile_override = None
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="comment_issue", remote="dadeschools"
|
||||
)
|
||||
self.assertEqual(res["active_profile"], "mdcps-author")
|
||||
self.assertTrue(res["allowed_in_current_session"])
|
||||
self.assertNotIn("prgs-author", res["matching_configured_profile"])
|
||||
|
||||
|
||||
class TestSessionContextBindingUnit(unittest.TestCase):
|
||||
def test_profile_matches_remote_by_base_url(self):
|
||||
remotes = {
|
||||
"dadeschools": {"host": "gitea.dadeschools.net"},
|
||||
"prgs": {"host": "gitea.prgs.cc"},
|
||||
}
|
||||
mdcps = {"base_url": "https://gitea.dadeschools.net", "context": "mdcps"}
|
||||
prgs = {"base_url": "https://gitea.prgs.cc", "context": "prgs"}
|
||||
self.assertTrue(
|
||||
session_ctx.profile_matches_remote(mdcps, "dadeschools", remotes)
|
||||
)
|
||||
self.assertFalse(session_ctx.profile_matches_remote(mdcps, "prgs", remotes))
|
||||
self.assertTrue(session_ctx.profile_matches_remote(prgs, "prgs", remotes))
|
||||
|
||||
def test_bind_and_detect_drift(self):
|
||||
session_ctx.bind_session_context(
|
||||
profile_name="mdcps-reviewer",
|
||||
remote="dadeschools",
|
||||
host="gitea.dadeschools.net",
|
||||
identity="913443",
|
||||
source="test",
|
||||
)
|
||||
ok = session_ctx.assess_session_context(
|
||||
profile_name="mdcps-reviewer",
|
||||
remote="dadeschools",
|
||||
host="gitea.dadeschools.net",
|
||||
identity="913443",
|
||||
)
|
||||
self.assertTrue(ok["proven"])
|
||||
bad = session_ctx.assess_session_context(
|
||||
profile_name="prgs-author",
|
||||
remote="dadeschools",
|
||||
host="gitea.dadeschools.net",
|
||||
identity="jcwalker3",
|
||||
)
|
||||
self.assertTrue(bad["block"])
|
||||
self.assertTrue(any("drift" in r for r in bad["reasons"]))
|
||||
|
||||
def test_bound_context_survives_multiple_calls_and_exceptions(self):
|
||||
original = session_ctx.bind_session_context(
|
||||
profile_name="mdcps-reviewer",
|
||||
remote="dadeschools",
|
||||
host="gitea.dadeschools.net",
|
||||
identity="913443",
|
||||
source="test",
|
||||
)
|
||||
|
||||
try:
|
||||
for _ in range(3):
|
||||
observed = session_ctx.seed_session_context_if_unbound(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
source="interleaved-test-call",
|
||||
)
|
||||
self.assertEqual(observed, original)
|
||||
raise RuntimeError("simulated caller failure")
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
self.assertEqual(session_ctx.get_session_context(), original)
|
||||
drift = session_ctx.assess_session_context(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
require_bound=True,
|
||||
)
|
||||
self.assertTrue(drift["block"])
|
||||
|
||||
def test_parallel_calls_cannot_overwrite_established_binding(self):
|
||||
original = session_ctx.bind_session_context(
|
||||
profile_name="mdcps-reviewer",
|
||||
remote="dadeschools",
|
||||
host="gitea.dadeschools.net",
|
||||
identity="913443",
|
||||
source="test",
|
||||
)
|
||||
barrier = threading.Barrier(9)
|
||||
results = []
|
||||
results_lock = threading.Lock()
|
||||
|
||||
def competing_seed(index: int) -> None:
|
||||
barrier.wait()
|
||||
result = session_ctx.seed_session_context_if_unbound(
|
||||
profile_name=f"prgs-author-{index}",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity=f"other-{index}",
|
||||
source="parallel-test-call",
|
||||
)
|
||||
with results_lock:
|
||||
results.append(result)
|
||||
|
||||
threads = [threading.Thread(target=competing_seed, args=(i,)) for i in range(8)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
barrier.wait()
|
||||
for thread in threads:
|
||||
thread.join(timeout=5)
|
||||
|
||||
self.assertFalse(any(thread.is_alive() for thread in threads))
|
||||
self.assertEqual(results, [original] * 8)
|
||||
self.assertEqual(session_ctx.get_session_context(), original)
|
||||
|
||||
def test_concurrent_initialization_has_single_winning_binding(self):
|
||||
"""Racing first-binds from an unbound context: exactly one winner, and
|
||||
every racer observes that same complete binding (never a partial one)."""
|
||||
self.assertIsNone(session_ctx.get_session_context())
|
||||
thread_count = 8
|
||||
barrier = threading.Barrier(thread_count)
|
||||
results = []
|
||||
results_lock = threading.Lock()
|
||||
|
||||
def racing_seed(index: int) -> None:
|
||||
barrier.wait()
|
||||
result = session_ctx.seed_session_context_if_unbound(
|
||||
profile_name=f"prgs-author-{index}",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity=f"user-{index}",
|
||||
source="concurrent-init-test",
|
||||
)
|
||||
with results_lock:
|
||||
results.append(result)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=racing_seed, args=(i,))
|
||||
for i in range(thread_count)
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(timeout=5)
|
||||
|
||||
self.assertFalse(any(thread.is_alive() for thread in threads))
|
||||
winner = session_ctx.get_session_context()
|
||||
self.assertIsNotNone(winner)
|
||||
self.assertEqual(len(results), thread_count)
|
||||
self.assertEqual(results, [winner] * thread_count)
|
||||
# A losing racer's identity must never be spliced into the winner.
|
||||
self.assertEqual(
|
||||
winner["identity"], winner["profile_name"].replace("prgs-author-", "user-")
|
||||
)
|
||||
|
||||
def test_repository_mismatch_blocks_before_mutation(self):
|
||||
"""Same host and profile, different repository, must still fail closed."""
|
||||
session_ctx.bind_session_context(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
repository="Gitea-Tools",
|
||||
org="Scaled-Tech-Consulting",
|
||||
source="test",
|
||||
)
|
||||
same = session_ctx.assess_session_context(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
repository="Gitea-Tools",
|
||||
org="Scaled-Tech-Consulting",
|
||||
require_bound=True,
|
||||
)
|
||||
self.assertTrue(same["proven"])
|
||||
other_repo = session_ctx.assess_session_context(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
repository="eAgenda",
|
||||
org="Scaled-Tech-Consulting",
|
||||
require_bound=True,
|
||||
)
|
||||
self.assertTrue(other_repo["block"])
|
||||
self.assertTrue(
|
||||
any("repository drift" in r for r in other_repo["reasons"])
|
||||
)
|
||||
other_org = session_ctx.assess_session_context(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
repository="Gitea-Tools",
|
||||
org="913443",
|
||||
require_bound=True,
|
||||
)
|
||||
self.assertTrue(other_org["block"])
|
||||
self.assertTrue(any("org drift" in r for r in other_org["reasons"]))
|
||||
|
||||
def test_returned_snapshot_cannot_mutate_binding(self):
|
||||
session_ctx.bind_session_context(
|
||||
profile_name="mdcps-reviewer",
|
||||
remote="dadeschools",
|
||||
host="gitea.dadeschools.net",
|
||||
identity="913443",
|
||||
source="test",
|
||||
)
|
||||
snapshot = session_ctx.get_session_context()
|
||||
snapshot["profile_name"] = "prgs-author"
|
||||
self.assertEqual(
|
||||
session_ctx.get_session_context()["profile_name"], "mdcps-reviewer"
|
||||
)
|
||||
|
||||
|
||||
class TestSessionContextTestBoundaryIsolation(unittest.TestCase):
|
||||
def test_mdcps_binding_starts_clean_and_does_not_escape_test(self):
|
||||
self.assertIsNone(session_ctx.get_session_context())
|
||||
session_ctx.bind_session_context(
|
||||
profile_name="mdcps-reviewer",
|
||||
remote="dadeschools",
|
||||
host="gitea.dadeschools.net",
|
||||
identity="913443",
|
||||
source="test-boundary",
|
||||
)
|
||||
|
||||
def test_prgs_binding_starts_clean_and_does_not_escape_test(self):
|
||||
self.assertIsNone(session_ctx.get_session_context())
|
||||
session_ctx.bind_session_context(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
source="test-boundary",
|
||||
)
|
||||
|
||||
def test_reset_helper_is_rejected_outside_pytest_boundary(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with self.assertRaises(RuntimeError):
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -1,7 +1,3 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Tests for the pre-create issue content gate (Issue #582)."""
|
||||
import sys
|
||||
import unittest
|
||||
@@ -19,10 +15,9 @@ from issue_content_gate import ( # noqa: E402
|
||||
from mcp_server import gitea_create_issue # noqa: E402
|
||||
|
||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||
"test-author-prgs",
|
||||
GITEA_ALLOWED_OPERATIONS="gitea.issue.create",
|
||||
)
|
||||
ISSUE_WRITE_ENV = {
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
||||
}
|
||||
|
||||
|
||||
class TestNormalizeAndPointers(unittest.TestCase):
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Tests for pre-create issue duplicate gate (Issue #207)."""
|
||||
import sys
|
||||
import unittest
|
||||
@@ -23,10 +19,9 @@ from mcp_server import gitea_create_issue # noqa: E402
|
||||
from review_proofs import assess_duplicate_search_proof as proof_assess # noqa: E402
|
||||
|
||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||
"test-author-prgs",
|
||||
GITEA_ALLOWED_OPERATIONS="gitea.issue.create",
|
||||
)
|
||||
ISSUE_WRITE_ENV = {
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
||||
}
|
||||
CANONICAL_TITLE = (
|
||||
"Add hard queue-target resolution wall before PR inventory "
|
||||
"or empty-queue claims"
|
||||
@@ -167,7 +162,7 @@ class TestCreateIssueMCPGate(unittest.TestCase):
|
||||
mock_role_check.return_value = (True, [])
|
||||
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
|
||||
with patch.dict(__import__("os").environ, ISSUE_WRITE_ENV, clear=True):
|
||||
result = gitea_create_issue(title="Unique new issue", body="body text", remote="prgs")
|
||||
result = gitea_create_issue(title="Unique new issue", body="body text")
|
||||
self.assertEqual(result["number"], 1)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Tests for early duplicate-work detection (#400)."""
|
||||
import os
|
||||
import sys
|
||||
@@ -148,12 +144,10 @@ class TestInjectableDuplicateFetcher(unittest.TestCase):
|
||||
},
|
||||
):
|
||||
with tempfile.TemporaryDirectory() as lock_dir:
|
||||
env = shared_mutation_env(
|
||||
"test-author-prgs",
|
||||
include_example_repo=True,
|
||||
GITEA_ISSUE_LOCK_DIR=lock_dir,
|
||||
)
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
with patch.dict(os.environ, {
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment",
|
||||
"GITEA_ISSUE_LOCK_DIR": lock_dir,
|
||||
}, clear=True):
|
||||
mcp_server.gitea_lock_issue(
|
||||
issue_number=400,
|
||||
branch_name="feat/issue-400-duplicate-work-preflight",
|
||||
@@ -167,11 +161,7 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self._env_patch = patch.dict(
|
||||
os.environ,
|
||||
shared_mutation_env(
|
||||
"test-author-prgs",
|
||||
include_example_repo=True,
|
||||
GITEA_ISSUE_LOCK_DIR=self._dir.name,
|
||||
),
|
||||
{"GITEA_ISSUE_LOCK_DIR": self._dir.name},
|
||||
clear=False,
|
||||
)
|
||||
self._env_patch.start()
|
||||
@@ -181,11 +171,6 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
|
||||
})
|
||||
self._remotes.start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
try:
|
||||
import session_context_binding as _sc
|
||||
_sc._reset_session_context_for_testing()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
@@ -220,8 +205,6 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
|
||||
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
|
||||
"forbidden_operations": [],
|
||||
"audit_label": "test-author",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
"base_url": "https://gitea.example.com",
|
||||
})
|
||||
@patch("mcp_server.get_auth_header", return_value="token x")
|
||||
def test_commit_files_blocked_on_recheck(self, _auth, _profile, mock_gate):
|
||||
@@ -256,8 +239,6 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.create"],
|
||||
"forbidden_operations": [],
|
||||
"audit_label": "test-author",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
"base_url": "https://gitea.example.com",
|
||||
})
|
||||
@patch("mcp_server.get_auth_header", return_value="token x")
|
||||
def test_create_pr_returns_handoff_on_duplicate(self, _auth, _profile, mock_gate):
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import install_deterministic_remote_urls # noqa: E402
|
||||
install_deterministic_remote_urls()
|
||||
"""Regression tests: issue-write tool gates match gitea_resolve_task_capability (#69)."""
|
||||
import json
|
||||
import os
|
||||
@@ -46,7 +41,6 @@ CONFIG = {
|
||||
"gitea.read", "gitea.issue.create", "gitea.issue.close",
|
||||
"gitea.issue.comment"],
|
||||
"forbidden_operations": [],
|
||||
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "Example-Org/Example-Repo", "913443/eAgenda"],
|
||||
"execution_profile": "full-author",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Negative tests for LLM-Agent-SHA attribution (#86, Phase 0).
|
||||
|
||||
``LLM-Agent-SHA`` (docs/llm-agent-sha.md) is attribution metadata ONLY. These
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
"""Regression tests for gitea_lock_issue MCP tool registration (#521)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -17,20 +13,22 @@ from mcp_server import gitea_create_pr, gitea_lock_issue
|
||||
|
||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||
|
||||
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||
"test-author-prgs",
|
||||
)
|
||||
ISSUE_WRITE_ENV = {
|
||||
"GITEA_ALLOWED_OPERATIONS": (
|
||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
||||
),
|
||||
}
|
||||
|
||||
CREATE_PR_ENV = shared_mutation_env(
|
||||
"test-author-prgs",
|
||||
GITEA_ALLOWED_OPERATIONS=(
|
||||
CREATE_PR_ENV = {
|
||||
"GITEA_PROFILE_NAME": "author-test",
|
||||
"GITEA_ALLOWED_OPERATIONS": (
|
||||
"gitea.read,gitea.pr.create,gitea.branch.push,"
|
||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
||||
),
|
||||
GITEA_FORBIDDEN_OPERATIONS=(
|
||||
"GITEA_FORBIDDEN_OPERATIONS": (
|
||||
"gitea.pr.approve,gitea.pr.merge,gitea.pr.review"
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _clean_master_git_state_for_lock():
|
||||
|
||||
+114
-154
@@ -1,11 +1,3 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import (
|
||||
mutation_profile_env,
|
||||
shared_mutation_env,
|
||||
install_deterministic_remote_urls,
|
||||
) # noqa: E402
|
||||
"""Tests for the MCP server tool functions.
|
||||
|
||||
Each tool is tested by calling the underlying function directly (not through
|
||||
@@ -55,7 +47,6 @@ from gitea_auth import get_profile # noqa: E402
|
||||
import gitea_config # noqa: E402
|
||||
|
||||
import mcp_server
|
||||
install_deterministic_remote_urls()
|
||||
import issue_lock_store
|
||||
import gitea_auth
|
||||
|
||||
@@ -230,26 +221,22 @@ def _seed_ready_review_decision(
|
||||
|
||||
|
||||
# Issue-write tools are profile-gated (#69).
|
||||
# Default remote is dadeschools — use the host-aligned config profile so
|
||||
# cross-host session binding does not fail closed (#714).
|
||||
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||
"test-author-dadeschools",
|
||||
GITEA_ALLOWED_OPERATIONS=(
|
||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment,"
|
||||
"gitea.pr.create,gitea.branch.push,gitea.repo.commit,gitea.read"
|
||||
),
|
||||
)
|
||||
|
||||
# create_pr is gated on author profile + namespace (#69, #209).
|
||||
# Default remote is dadeschools; use matching config-backed author profile.
|
||||
CREATE_PR_ENV = shared_mutation_env(
|
||||
"test-author-dadeschools",
|
||||
GITEA_ALLOWED_OPERATIONS=(
|
||||
"gitea.read,gitea.pr.create,gitea.branch.push,"
|
||||
ISSUE_WRITE_ENV = {
|
||||
"GITEA_ALLOWED_OPERATIONS": (
|
||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
||||
),
|
||||
GITEA_FORBIDDEN_OPERATIONS="gitea.pr.approve,gitea.pr.merge,gitea.pr.review",
|
||||
)
|
||||
}
|
||||
|
||||
# create_pr is gated on author profile + namespace (#69, #209).
|
||||
CREATE_PR_ENV = {
|
||||
"GITEA_PROFILE_NAME": "author-test",
|
||||
"GITEA_ALLOWED_OPERATIONS": (
|
||||
"gitea.read,gitea.pr.create,gitea.branch.push"
|
||||
),
|
||||
"GITEA_FORBIDDEN_OPERATIONS": (
|
||||
"gitea.pr.approve,gitea.pr.merge,gitea.pr.review"
|
||||
),
|
||||
}
|
||||
|
||||
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
|
||||
|
||||
@@ -300,7 +287,6 @@ def _bind_test_lock(**overrides) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create Issue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCreateIssue(unittest.TestCase):
|
||||
|
||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
@@ -310,7 +296,7 @@ class TestCreateIssue(unittest.TestCase):
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_creates_issue(self, _auth, _get_all, mock_api, _role):
|
||||
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
|
||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||
result = gitea_create_issue(title="Test issue", body="body text")
|
||||
self.assertEqual(result["number"], 1)
|
||||
self.assertNotIn("url", result)
|
||||
@@ -328,7 +314,7 @@ class TestCreateIssue(unittest.TestCase):
|
||||
def test_create_issue_reveal_opt_in_includes_url(self, _auth, _get_all, mock_api, _role):
|
||||
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
|
||||
env = {**ISSUE_WRITE_ENV, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_create_issue(title="Test issue", body="body text")
|
||||
self.assertIn("issues/1", result["url"])
|
||||
|
||||
@@ -339,18 +325,7 @@ class TestCreateIssue(unittest.TestCase):
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_creates_on_prgs(self, _auth, _get_all, mock_api, _role):
|
||||
mock_api.return_value = {"number": 5, "html_url": "https://gitea.prgs.cc/issues/5"}
|
||||
env = {
|
||||
**ISSUE_WRITE_ENV,
|
||||
"GITEA_MCP_PROFILE": "test-author-prgs",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
import gitea_config
|
||||
gitea_config._active_profile_override = None
|
||||
try:
|
||||
import session_context_binding as _sc
|
||||
_sc._reset_session_context_for_testing()
|
||||
except Exception:
|
||||
pass
|
||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||
result = gitea_create_issue(title="Test", remote="prgs")
|
||||
self.assertEqual(result["number"], 5)
|
||||
url = mock_api.call_args[0][1]
|
||||
@@ -363,7 +338,7 @@ class TestCreateIssue(unittest.TestCase):
|
||||
@patch("mcp_server.api_get_all", return_value=[])
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_issue_required_workflow_labels_blocks(self, _auth, _get_all, mock_api, _role):
|
||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||
result = gitea_create_issue(
|
||||
title="Test issue",
|
||||
require_workflow_labels=True,
|
||||
@@ -403,7 +378,7 @@ class TestCreatePR(unittest.TestCase):
|
||||
mock_api.side_effect = api_side_effect
|
||||
with tempfile.TemporaryDirectory() as lock_dir:
|
||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
|
||||
result = gitea_create_pr(
|
||||
title="feat: X Closes #123",
|
||||
@@ -446,7 +421,7 @@ class TestCreatePR(unittest.TestCase):
|
||||
mock_api.side_effect = api_side_effect
|
||||
with tempfile.TemporaryDirectory() as lock_dir:
|
||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
|
||||
result = gitea_create_pr(
|
||||
title="feat: X Closes #123",
|
||||
@@ -472,7 +447,7 @@ class TestCreatePR(unittest.TestCase):
|
||||
mock_api.return_value = _issue_with_labels("type:feature", "status:in-progress")
|
||||
with tempfile.TemporaryDirectory() as lock_dir:
|
||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
|
||||
result = gitea_create_pr(
|
||||
title="feat: X Closes #123",
|
||||
@@ -495,7 +470,7 @@ class TestCreatePR(unittest.TestCase):
|
||||
worktree = os.path.realpath(os.getcwd())
|
||||
with tempfile.TemporaryDirectory() as lock_dir:
|
||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
_bind_test_lock(
|
||||
issue_number=123,
|
||||
branch_name="feat/x",
|
||||
@@ -520,7 +495,7 @@ class TestCloseIssue(unittest.TestCase):
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_closes_issue(self, _auth, mock_api):
|
||||
mock_api.return_value = {"state": "closed"}
|
||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||
result = gitea_close_issue(issue_number=42)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertIn("42", result["message"])
|
||||
@@ -626,7 +601,7 @@ class TestMarkIssue(unittest.TestCase):
|
||||
return {}
|
||||
|
||||
mock_api.side_effect = api_side_effect
|
||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||
result = gitea_mark_issue(issue_number=5, action="start")
|
||||
self.assertTrue(result["success"])
|
||||
self.assertIn("claimed", result["message"])
|
||||
@@ -641,7 +616,7 @@ class TestMarkIssue(unittest.TestCase):
|
||||
mock_api.side_effect = [
|
||||
None,
|
||||
]
|
||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||
result = gitea_mark_issue(issue_number=5, action="done")
|
||||
self.assertTrue(result["success"])
|
||||
self.assertIn("released", result["message"])
|
||||
@@ -654,7 +629,7 @@ class TestMarkIssue(unittest.TestCase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_missing_label_raises(self, _auth, mock_api, _labels):
|
||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||
with self.assertRaises(RuntimeError):
|
||||
gitea_mark_issue(issue_number=5, action="start")
|
||||
|
||||
@@ -666,12 +641,12 @@ class TestAuthErrors(unittest.TestCase):
|
||||
|
||||
@patch("mcp_server.get_auth_header", return_value=None)
|
||||
def test_no_credentials_raises(self, _auth):
|
||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||
with self.assertRaises(RuntimeError):
|
||||
gitea_create_issue(title="test")
|
||||
|
||||
def test_unknown_remote_raises(self):
|
||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||
with self.assertRaises(ValueError):
|
||||
gitea_create_issue(title="test", remote="nonexistent")
|
||||
|
||||
@@ -885,7 +860,7 @@ class TestMergePR(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8),
|
||||
expected_head_sha="abc123", do="squash",
|
||||
@@ -918,7 +893,7 @@ class TestMergePR(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8),
|
||||
expected_changed_files=["b.py", "a.py"],
|
||||
@@ -939,7 +914,7 @@ class TestMergePR(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8),
|
||||
expected_head_sha="abc123", remote="prgs",
|
||||
@@ -970,7 +945,7 @@ class TestMergePR(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8),
|
||||
expected_head_sha="abc123", remote="prgs",
|
||||
@@ -987,7 +962,7 @@ class TestMergePR(unittest.TestCase):
|
||||
def test_missing_confirmation_blocks_with_no_api_call(self, _auth, mock_api):
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(pr_number=8, confirmation="", expected_head_sha="abc123", remote="prgs")
|
||||
self.assertFalse(r["performed"])
|
||||
self.assertTrue(any("explicit confirmation required" in x for x in r["reasons"]))
|
||||
@@ -998,7 +973,7 @@ class TestMergePR(unittest.TestCase):
|
||||
def test_wrong_confirmation_blocks(self, _auth, mock_api):
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 9", expected_head_sha="abc123", remote="prgs")
|
||||
self.assertFalse(r["performed"])
|
||||
mock_api.assert_not_called()
|
||||
@@ -1010,7 +985,7 @@ class TestMergePR(unittest.TestCase):
|
||||
def test_reviewer_profile_cannot_merge(self, _auth, mock_api):
|
||||
env = {"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,approve"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8), remote="prgs"
|
||||
@@ -1024,7 +999,7 @@ class TestMergePR(unittest.TestCase):
|
||||
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||
self.assertFalse(r["performed"])
|
||||
@@ -1035,7 +1010,7 @@ class TestMergePR(unittest.TestCase):
|
||||
def test_unknown_identity_blocks(self, _auth):
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||
self.assertFalse(r["performed"])
|
||||
@@ -1047,7 +1022,7 @@ class TestMergePR(unittest.TestCase):
|
||||
def test_unknown_profile_blocks(self, _auth, mock_api):
|
||||
mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")]
|
||||
env = {} # no GITEA_ALLOWED_OPERATIONS → empty allowed ops
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||
self.assertFalse(r["performed"])
|
||||
@@ -1119,7 +1094,7 @@ class TestMergePR(unittest.TestCase):
|
||||
def test_profile_without_merge_permission_blocks(self, _auth, mock_api):
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||
@@ -1135,7 +1110,7 @@ class TestMergePR(unittest.TestCase):
|
||||
{"login": "merger-bot"}, self._pr("author-bot", state="closed")]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||
self.assertFalse(r["performed"])
|
||||
@@ -1149,7 +1124,7 @@ class TestMergePR(unittest.TestCase):
|
||||
{"login": "merger-bot"}, self._pr("author-bot", mergeable=False)]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||
self.assertFalse(r["performed"])
|
||||
@@ -1163,7 +1138,7 @@ class TestMergePR(unittest.TestCase):
|
||||
{"login": "merger-bot"}, self._pr("author-bot", mergeable=None)]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||
self.assertFalse(r["performed"])
|
||||
@@ -1181,7 +1156,7 @@ class TestMergePR(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8),
|
||||
expected_head_sha="deadbeef", remote="prgs")
|
||||
@@ -1202,7 +1177,7 @@ class TestMergePR(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8),
|
||||
expected_changed_files=["a.py", "b.py"],
|
||||
@@ -1235,7 +1210,7 @@ class TestMergePR(unittest.TestCase):
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge",
|
||||
"GITEA_TOKEN": "super-secret-token"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8),
|
||||
expected_head_sha="abc123", remote="prgs",
|
||||
@@ -1254,7 +1229,7 @@ class TestMergePR(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8),
|
||||
expected_head_sha="abc123", remote="prgs",
|
||||
@@ -1277,7 +1252,7 @@ class TestMergePR(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8), remote="prgs",
|
||||
expected_head_sha=new_sha)
|
||||
@@ -1300,7 +1275,7 @@ class TestMergePR(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8),
|
||||
expected_head_sha="abc123", remote="prgs",
|
||||
@@ -1332,7 +1307,7 @@ class TestMergePR(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8),
|
||||
expected_head_sha="abc123", remote="prgs",
|
||||
@@ -1471,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)
|
||||
@@ -1639,8 +1620,10 @@ class TestCommitFiles(unittest.TestCase):
|
||||
files = [
|
||||
{"operation": "create", "path": "test.txt", "content": "SGVsbG8="}
|
||||
]
|
||||
env = shared_mutation_env("test-author-dadeschools")
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
env = {
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.repo.commit",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_commit_files(
|
||||
files=files,
|
||||
message="Initial commit",
|
||||
@@ -1755,7 +1738,7 @@ class TestRuntimeProfile(unittest.TestCase):
|
||||
"GITEA_ALLOWED_OPERATIONS": "read, review , approve",
|
||||
"GITEA_BASE_URL": "https://gitea.example.invalid",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
p = get_profile()
|
||||
self.assertEqual(p["profile_name"], "gitea-reviewer")
|
||||
self.assertEqual(p["allowed_operations"], ["read", "review", "approve"])
|
||||
@@ -1766,7 +1749,7 @@ class TestRuntimeProfile(unittest.TestCase):
|
||||
"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_TOKEN": "super-secret-token",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
p = get_profile()
|
||||
blob = repr(p).lower()
|
||||
# The token VALUE must never appear. (The field name
|
||||
@@ -1783,7 +1766,7 @@ class TestRuntimeProfile(unittest.TestCase):
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve",
|
||||
"GITEA_TOKEN": "super-secret-token",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_whoami(remote="prgs")
|
||||
self.assertEqual(result["profile"]["profile_name"], "gitea-reviewer")
|
||||
self.assertEqual(
|
||||
@@ -1804,7 +1787,7 @@ class TestRuntimeProfile(unittest.TestCase):
|
||||
"GITEA_AUDIT_LABEL": "reviewer-runtime",
|
||||
"GITEA_TOKEN_SOURCE": "keychain:prgs-reviewer-token",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_whoami(remote="prgs")
|
||||
profile = result["profile"]
|
||||
self.assertEqual(profile["environment"], None)
|
||||
@@ -1873,7 +1856,7 @@ class TestProfileDiscovery(unittest.TestCase):
|
||||
"GITEA_AUDIT_LABEL": "reviewer-runtime",
|
||||
"GITEA_TOKEN_SOURCE": "GITEA_TOKEN",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
p = get_profile()
|
||||
self.assertEqual(p["forbidden_operations"], ["merge", "branch.push"])
|
||||
self.assertEqual(p["audit_label"], "reviewer-runtime")
|
||||
@@ -1889,7 +1872,7 @@ class TestProfileDiscovery(unittest.TestCase):
|
||||
"GITEA_TOKEN_SOURCE": "GITEA_TOKEN",
|
||||
"GITEA_TOKEN": "super-secret-token",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_get_profile(remote="prgs")
|
||||
self.assertEqual(result["profile_name"], "gitea-reviewer")
|
||||
self.assertEqual(result["allowed_operations"], ["read", "review", "approve"])
|
||||
@@ -1910,7 +1893,7 @@ class TestProfileDiscovery(unittest.TestCase):
|
||||
def test_discovery_never_exposes_secrets(self, _auth, mock_api):
|
||||
mock_api.return_value = {"id": 3, "login": "reviewer-bot"}
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_TOKEN": "super-secret-token"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_get_profile(remote="prgs")
|
||||
blob = repr(result).lower()
|
||||
for secret in ("super-secret-token", "token dgvzd", "authorization", "basic ", FAKE_AUTH.lower()):
|
||||
@@ -1992,7 +1975,7 @@ class TestPrEligibility(unittest.TestCase):
|
||||
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_check_pr_eligibility(pr_number=5, action="review", remote="prgs")
|
||||
self.assertTrue(r["eligible"])
|
||||
self.assertEqual(r["authenticated_user"], "reviewer-bot")
|
||||
@@ -2005,7 +1988,7 @@ class TestPrEligibility(unittest.TestCase):
|
||||
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
||||
self.assertFalse(r["eligible"])
|
||||
self.assertIn("authenticated user is PR author", r["reasons"])
|
||||
@@ -2016,7 +1999,7 @@ class TestPrEligibility(unittest.TestCase):
|
||||
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_check_pr_eligibility(pr_number=8, action="approve", remote="prgs")
|
||||
self.assertFalse(r["eligible"])
|
||||
self.assertIn("authenticated user is PR author", r["reasons"])
|
||||
@@ -2031,7 +2014,7 @@ class TestPrEligibility(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
||||
self.assertFalse(r["eligible"])
|
||||
self.assertIsNone(r["mergeable"])
|
||||
@@ -2043,7 +2026,7 @@ class TestPrEligibility(unittest.TestCase):
|
||||
mock_api.side_effect = [{"login": "author-bot"}, self._pr("someone-else")]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,pr.create"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
||||
self.assertFalse(r["eligible"])
|
||||
self.assertIn("profile is not allowed to merge", r["reasons"])
|
||||
@@ -2055,7 +2038,7 @@ class TestPrEligibility(unittest.TestCase):
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review",
|
||||
"GITEA_FORBIDDEN_OPERATIONS": "merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
||||
self.assertFalse(r["eligible"])
|
||||
self.assertIn("profile forbids 'merge'", r["reasons"])
|
||||
@@ -2066,7 +2049,7 @@ class TestPrEligibility(unittest.TestCase):
|
||||
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot", state="closed")]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_check_pr_eligibility(pr_number=8, action="approve", remote="prgs")
|
||||
self.assertFalse(r["eligible"])
|
||||
self.assertIn("PR is not open (state=closed)", r["reasons"])
|
||||
@@ -2075,7 +2058,7 @@ class TestPrEligibility(unittest.TestCase):
|
||||
def test_unknown_identity_fails_closed(self, _auth):
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
||||
self.assertFalse(r["eligible"])
|
||||
self.assertIn("authenticated identity could not be determined", r["reasons"])
|
||||
@@ -2094,7 +2077,7 @@ class TestPrEligibility(unittest.TestCase):
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review",
|
||||
"GITEA_TOKEN": "super-secret-token"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_check_pr_eligibility(pr_number=5, action="review", remote="prgs")
|
||||
blob = repr(r).lower()
|
||||
for secret in ("super-secret-token", "authorization", "basic ", FAKE_AUTH.lower()):
|
||||
@@ -2291,7 +2274,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_submit_pr_review(
|
||||
pr_number=8, action="approve", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
@@ -2310,7 +2293,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_submit_pr_review(
|
||||
pr_number=8, action="approve", body="LGTM", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
@@ -2340,7 +2323,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_submit_pr_review(
|
||||
pr_number=8, action="approve", body="LGTM", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
@@ -2360,7 +2343,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_submit_pr_review(
|
||||
pr_number=8, action="approve", body="LGTM", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
@@ -2386,7 +2369,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,request_changes"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_submit_pr_review(
|
||||
pr_number=8, action="request_changes",
|
||||
body="needs work", remote="prgs",
|
||||
@@ -2407,7 +2390,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review"} # no request_changes
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_submit_pr_review(
|
||||
pr_number=8, action="request_changes", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
@@ -2428,7 +2411,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
gitea_mark_final_review_decision(8, "comment", remote="prgs", expected_head_sha="abc123")
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_submit_pr_review(
|
||||
pr_number=8, action="comment", body="finding", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
@@ -2446,7 +2429,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
gitea_mark_final_review_decision(
|
||||
8, "comment", remote="prgs", expected_head_sha="abc123",
|
||||
)
|
||||
@@ -2462,7 +2445,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
def test_unknown_identity_blocks(self, _auth):
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_submit_pr_review(
|
||||
pr_number=8, action="approve", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
@@ -2477,7 +2460,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,pr.create"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_submit_pr_review(
|
||||
pr_number=8, action="approve", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
@@ -2496,7 +2479,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_submit_pr_review(
|
||||
pr_number=8, action="approve",
|
||||
expected_head_sha="abc123", remote="prgs",
|
||||
@@ -2520,7 +2503,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_submit_pr_review(
|
||||
pr_number=8, action="approve",
|
||||
expected_head_sha="abc123", remote="prgs",
|
||||
@@ -2549,7 +2532,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve",
|
||||
"GITEA_TOKEN": "super-secret-token"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
gitea_mark_final_review_decision(5, "approve", remote="prgs", expected_head_sha="abc123")
|
||||
r = gitea_submit_pr_review(
|
||||
pr_number=5, action="approve", remote="prgs",
|
||||
@@ -2569,7 +2552,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_submit_pr_review(
|
||||
pr_number=8, action="approve", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
@@ -2646,7 +2629,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
try:
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_submit_pr_review(
|
||||
pr_number=8, action="approve", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
@@ -2679,7 +2662,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve,request_changes"}
|
||||
with patch("mcp_server.gitea_get_pr_review_feedback",
|
||||
return_value=dict(_NO_BLOCKER_FEEDBACK)):
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
first = gitea_submit_pr_review(
|
||||
pr_number=8, action="approve", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
@@ -2704,7 +2687,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
def test_remote_org_repo_validation_mismatch(self, _auth, mock_api):
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
# Mark decision for specific remote, org, repo
|
||||
gitea_mark_final_review_decision(8, "approve", remote="prgs", expected_head_sha="abc123", org="MyOrg", repo="MyRepo")
|
||||
|
||||
@@ -2742,29 +2725,13 @@ if __name__ == "__main__":
|
||||
class TestTrackerHygieneCleanup(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._mut_env = patch.dict(
|
||||
os.environ,
|
||||
shared_mutation_env("test-author-dadeschools"),
|
||||
clear=False,
|
||||
)
|
||||
self._mut_env.start()
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
self.mock_api = patch("mcp_server.api_request").start()
|
||||
self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
||||
patch("gitea_audit.audit_enabled", return_value=True).start()
|
||||
self.mock_audit = patch("gitea_audit.write_event").start()
|
||||
# gitea.pr.close: closing a PR via gitea_edit_pr is capability-gated (#216).
|
||||
patch("mcp_server.get_profile", return_value={
|
||||
"profile_name": "test-author-dadeschools",
|
||||
"allowed_operations": [
|
||||
"read", "merge", "edit", "close",
|
||||
"gitea.pr.close", "gitea.issue.close", "gitea.read",
|
||||
],
|
||||
"audit_label": "test",
|
||||
"forbidden_operations": [],
|
||||
"allowed_repositories": ["913443/eAgenda"],
|
||||
"base_url": "https://gitea.dadeschools.net",
|
||||
}).start()
|
||||
patch("mcp_server.get_profile", return_value={"profile_name": "test", "allowed_operations": ["read", "merge", "edit", "close", "gitea.pr.close", "gitea.issue.close"], "audit_label": "test", "forbidden_operations": []}).start()
|
||||
patch("mcp_server._list_pr_lease_comments", return_value=[]).start()
|
||||
patch(
|
||||
"mcp_server._pr_work_lease_reviewer_block",
|
||||
@@ -2772,11 +2739,6 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
|
||||
).start()
|
||||
|
||||
def tearDown(self):
|
||||
try:
|
||||
self._mut_env.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
patch.stopall()
|
||||
|
||||
def test_close_issue_removes_in_progress(self):
|
||||
@@ -3182,7 +3144,7 @@ class TestEndpointRedaction(unittest.TestCase):
|
||||
"GITEA_BASE_URL": "https://gitea.example.invalid",
|
||||
"GITEA_TOKEN_SOURCE": "keychain:some-item-id",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_get_profile(remote="prgs",
|
||||
resolve_identity=False)
|
||||
blob = repr(result)
|
||||
@@ -3204,7 +3166,7 @@ class TestEndpointRedaction(unittest.TestCase):
|
||||
"GITEA_TOKEN_SOURCE": "keychain:some-item-id",
|
||||
"GITEA_MCP_REVEAL_ENDPOINTS": "1",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_get_profile(remote="prgs",
|
||||
resolve_identity=False)
|
||||
self.assertEqual(result["server"], "https://gitea.prgs.cc")
|
||||
@@ -3216,7 +3178,7 @@ class TestEndpointRedaction(unittest.TestCase):
|
||||
try:
|
||||
env = {"GITEA_MCP_CONFIG": path,
|
||||
"GITEA_MCP_PROFILE": "prgs-author"}
|
||||
with patch.dict(os.environ, env, clear=False), \
|
||||
with patch.dict(os.environ, env, clear=True), \
|
||||
patch("gitea_config._keychain_token", return_value="x"):
|
||||
result = gitea_audit_config()
|
||||
finally:
|
||||
@@ -3304,7 +3266,7 @@ class TestIssueCommentTools(unittest.TestCase):
|
||||
def test_list_reveal_opt_in_includes_url(self, _auth, mock_api):
|
||||
mock_api.return_value = [self._comment()]
|
||||
env = dict(self.AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1")
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
||||
self.assertIn("issuecomment-101", result["comments"][0]["url"])
|
||||
|
||||
@@ -3313,7 +3275,7 @@ class TestIssueCommentTools(unittest.TestCase):
|
||||
def test_list_blocked_without_read_permission(self, _auth, mock_api):
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-writer-only",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(result["reasons"])
|
||||
@@ -3360,7 +3322,7 @@ class TestIssueCommentTools(unittest.TestCase):
|
||||
def test_create_reveal_opt_in_includes_url(self, _auth, mock_api):
|
||||
mock_api.return_value = self._comment(555)
|
||||
env = dict(self.AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1")
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_create_issue_comment(
|
||||
issue_number=9, body="posted", remote="prgs")
|
||||
self.assertIn("issuecomment-555", result["url"])
|
||||
@@ -3372,7 +3334,7 @@ class TestIssueCommentTools(unittest.TestCase):
|
||||
"GITEA_ALLOWED_OPERATIONS":
|
||||
"gitea.read,gitea.pr.review,gitea.pr.comment,"
|
||||
"gitea.pr.approve,gitea.pr.merge"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_create_issue_comment(
|
||||
issue_number=9, body="posted", remote="prgs")
|
||||
self.assertFalse(result["success"])
|
||||
@@ -3386,7 +3348,7 @@ class TestIssueCommentTools(unittest.TestCase):
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
|
||||
"GITEA_FORBIDDEN_OPERATIONS": "gitea.issue.comment"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_create_issue_comment(
|
||||
issue_number=9, body="posted", remote="prgs")
|
||||
self.assertFalse(result["success"])
|
||||
@@ -3522,7 +3484,7 @@ class TestIssueCommentPermissionSeparation(unittest.TestCase):
|
||||
"GITEA_ALLOWED_OPERATIONS":
|
||||
"gitea.branch.create,gitea.branch.push,gitea.pr.comment,"
|
||||
"gitea.pr.create,gitea.read,gitea.repo.commit"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_create_issue_comment(
|
||||
issue_number=51, body="audit findings", remote="prgs")
|
||||
self.assertFalse(result["success"])
|
||||
@@ -3707,12 +3669,11 @@ class TestIssueLocking(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._lock_dir = tempfile.TemporaryDirectory()
|
||||
# Most locking tests target remote=prgs; host-align profile (#714).
|
||||
env = {
|
||||
**shared_mutation_env("test-author-prgs"),
|
||||
**ISSUE_WRITE_ENV,
|
||||
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
||||
}
|
||||
self._env_patcher = patch.dict(os.environ, env, clear=False)
|
||||
self._env_patcher = patch.dict(os.environ, env, clear=True)
|
||||
self._env_patcher.start()
|
||||
self._dup_fetcher_patcher = patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
@@ -3726,9 +3687,8 @@ class TestIssueLocking(unittest.TestCase):
|
||||
self._lock_dir.cleanup()
|
||||
|
||||
def _create_pr_env(self) -> dict:
|
||||
# PR-locking tests call create_pr with remote=prgs.
|
||||
return {
|
||||
**shared_mutation_env("test-author-prgs"),
|
||||
**CREATE_PR_ENV,
|
||||
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
||||
}
|
||||
|
||||
@@ -3747,7 +3707,7 @@ class TestIssueLocking(unittest.TestCase):
|
||||
self.assertIn("created_at", res["work_lease"])
|
||||
self.assertIn("expires_at", res["work_lease"])
|
||||
self.assertIn("last_heartbeat_at", res["work_lease"])
|
||||
self.assertIn(res["work_lease"]["claimant"]["profile"], ("gitea-default", "test-author-prgs", "prgs-author", "gitea-author"))
|
||||
self.assertEqual(res["work_lease"]["claimant"]["profile"], "gitea-default")
|
||||
self.assertIn("lock_file_path", res)
|
||||
lock = issue_lock_store.read_lock_file(res["lock_file_path"])
|
||||
self.assertIn("worktree_path", lock)
|
||||
@@ -3866,7 +3826,7 @@ class TestIssueLocking(unittest.TestCase):
|
||||
@patch("mcp_server.api_get_all", return_value=[])
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_lock_issue_blocks_active_same_operation_lease(self, _auth, _api, _git_state):
|
||||
prgs_repo = "Gitea-Tools" # #714 session-bound repo (not REMOTES Timesheet default)
|
||||
prgs_repo = mcp_server.REMOTES["prgs"]["repo"]
|
||||
issue_lock_store.save_lock_file(
|
||||
issue_lock_store.lock_file_path(
|
||||
remote="prgs",
|
||||
@@ -3903,7 +3863,7 @@ class TestIssueLocking(unittest.TestCase):
|
||||
Dead-pid / missing-worktree reclaim is covered by issue_lock_store unit tests.
|
||||
This MCP path must remain fail-closed for sticky expired foreign ownership.
|
||||
"""
|
||||
prgs_repo = "Gitea-Tools" # #714 session-bound repo (not REMOTES Timesheet default)
|
||||
prgs_repo = mcp_server.REMOTES["prgs"]["repo"]
|
||||
# Present worktree + live pid => reclaim_allowed=False even though expires_at is past.
|
||||
sticky_worktree = tempfile.mkdtemp(prefix="gitea-sticky-lease-")
|
||||
self.addCleanup(lambda: shutil.rmtree(sticky_worktree, ignore_errors=True))
|
||||
@@ -4097,12 +4057,12 @@ class TestIssueLocking(unittest.TestCase):
|
||||
worktree = os.path.realpath(os.getcwd())
|
||||
with tempfile.TemporaryDirectory() as lock_dir:
|
||||
env = {**self._create_pr_env(), "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
issue_lock_store.save_lock_file(
|
||||
issue_lock_store.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools", # #714 session-bound
|
||||
repo=mcp_server.REMOTES["prgs"]["repo"],
|
||||
issue_number=447,
|
||||
lock_dir=lock_dir,
|
||||
),
|
||||
@@ -4111,7 +4071,7 @@ class TestIssueLocking(unittest.TestCase):
|
||||
branch_name="feat/issue-447-lock-provenance",
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools", # #714 session-bound
|
||||
repo=mcp_server.REMOTES["prgs"]["repo"],
|
||||
worktree_path=worktree,
|
||||
lock_provenance=None,
|
||||
),
|
||||
|
||||
@@ -111,21 +111,13 @@ class TestMcpStaleRuntime(unittest.TestCase):
|
||||
reasons = gitea_mcp_server._check_mcp_runtimes_diagnostics("create_issue", ["prgs-author"])
|
||||
self.assertTrue(any("stale-runtime: The active Gitea MCP server process is stale" in r for r in reasons))
|
||||
|
||||
@patch("threading.Thread")
|
||||
@patch("os.utime")
|
||||
@patch("os.path.exists")
|
||||
@patch.dict("os.environ", {"MCP_CONFIG_PATH": "/tmp/mcp_config.json"})
|
||||
def test_auto_restart_trigger_touches_and_spawns(self, mock_exists, mock_utime, mock_thread):
|
||||
mock_exists.return_value = True
|
||||
gitea_mcp_server._restart_triggered = False
|
||||
|
||||
# Ensure we are not skipped in test mode for testing purposes
|
||||
with patch("gitea_mcp_server._preflight_in_test_mode", return_value=False):
|
||||
gitea_mcp_server._trigger_mcp_auto_restart()
|
||||
|
||||
mock_utime.assert_called_once_with("/tmp/mcp_config.json", None)
|
||||
mock_thread.assert_called_once()
|
||||
self.assertTrue(gitea_mcp_server._restart_triggered)
|
||||
def test_auto_restart_helper_removed_from_read_only_path(self):
|
||||
"""#685: config-touch / os._exit self-recovery is no longer on the server."""
|
||||
self.assertFalse(
|
||||
hasattr(gitea_mcp_server, "_trigger_mcp_auto_restart"),
|
||||
"_trigger_mcp_auto_restart must not remain (side-effect-free resolver)",
|
||||
)
|
||||
self.assertFalse(hasattr(gitea_mcp_server, "_restart_triggered"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Merger lease adoption / recovery (#536)."""
|
||||
|
||||
import os
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Operation-name normalization table and enforcement tests — issue #106.
|
||||
|
||||
Covers the required matrix from #106:
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
"""Tests for operation-scoped role selection (#228, updated by #714).
|
||||
|
||||
#714 removes silent automatic profile substitution. Capability resolution
|
||||
and mutation gates evaluate only the active profile; explicit
|
||||
``gitea_activate_profile`` is required to switch roles.
|
||||
"""
|
||||
"""Tests for operation-scoped role selection and automatic dispatch switching (#228)."""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
@@ -20,7 +15,6 @@ from reviewer_worktree import assess_author_worktree_continuity
|
||||
|
||||
CONFIG_TEST = {
|
||||
"version": 2,
|
||||
"allow_runtime_switching": True,
|
||||
"contexts": {
|
||||
"ctx": {
|
||||
"enabled": True,
|
||||
@@ -36,11 +30,9 @@ CONFIG_TEST = {
|
||||
"context": "ctx",
|
||||
"role": "author",
|
||||
"username": "author-user",
|
||||
"base_url": "https://gitea.example.com",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||
"allowed_operations": ["gitea.read", "gitea.issue.create", "gitea.pr.create", "gitea.branch.push", "gitea.issue.comment"],
|
||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "913443/eAgenda"],
|
||||
"execution_profile": "author-profile"
|
||||
},
|
||||
"reviewer-profile": {
|
||||
@@ -48,11 +40,9 @@ CONFIG_TEST = {
|
||||
"context": "ctx",
|
||||
"role": "reviewer",
|
||||
"username": "reviewer-user",
|
||||
"base_url": "https://gitea.example.com",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.issue.comment"],
|
||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
||||
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "913443/eAgenda"],
|
||||
"execution_profile": "reviewer-profile"
|
||||
}
|
||||
}
|
||||
@@ -67,15 +57,6 @@ class TestOperationScopedRoles(unittest.TestCase):
|
||||
})
|
||||
self._remotes_patch.start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
self._url = patch.object(
|
||||
mcp_server,
|
||||
"_local_git_remote_url",
|
||||
side_effect=lambda n: {
|
||||
"prgs": "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git",
|
||||
"dadeschools": "https://gitea.dadeschools.net/913443/eAgenda.git",
|
||||
}.get(n),
|
||||
)
|
||||
self._url.start()
|
||||
gitea_config._active_profile_override = None
|
||||
mcp_server._MUTATION_AUTHORITY = None
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
@@ -83,11 +64,6 @@ class TestOperationScopedRoles(unittest.TestCase):
|
||||
self._write_config(CONFIG_TEST)
|
||||
|
||||
def tearDown(self):
|
||||
try:
|
||||
self._url.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._remotes_patch.stop()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
gitea_config._active_profile_override = None
|
||||
@@ -109,71 +85,62 @@ class TestOperationScopedRoles(unittest.TestCase):
|
||||
return env
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
def test_no_auto_switch_to_reviewer(self, mock_api):
|
||||
# #714: capability resolution must not silently switch author → reviewer
|
||||
def test_auto_switch_to_reviewer(self, mock_api):
|
||||
# mock identity resolution to return username matching profile
|
||||
mock_api.side_effect = lambda method, url, header: (
|
||||
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
||||
)
|
||||
with patch.dict(os.environ, self._env("author-profile")):
|
||||
# initially we are author-profile
|
||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
||||
self.assertEqual(mcp_server.get_profile()["profile_name"], "author-profile")
|
||||
|
||||
# resolve a reviewer task (review_pr)
|
||||
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||
|
||||
self.assertFalse(res["allowed_in_current_session"])
|
||||
self.assertFalse(res["available_in_session"])
|
||||
self.assertEqual(res["active_profile"], "author-profile")
|
||||
self.assertEqual(res["active_identity"], "author-user")
|
||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
||||
self.assertIn("reviewer-profile", res["matching_configured_profile"])
|
||||
self.assertFalse(res.get("auto_profile_substitution", True))
|
||||
|
||||
# verify it automatically switched to reviewer-profile
|
||||
self.assertTrue(res["allowed_in_current_session"])
|
||||
self.assertTrue(res["available_in_session"])
|
||||
self.assertEqual(res["active_profile"], "reviewer-profile")
|
||||
self.assertEqual(res["active_identity"], "reviewer-user")
|
||||
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
def test_no_auto_switch_to_author(self, mock_api):
|
||||
def test_auto_switch_to_author(self, mock_api):
|
||||
mock_api.side_effect = lambda method, url, header: (
|
||||
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
||||
)
|
||||
with patch.dict(os.environ, self._env("reviewer-profile")):
|
||||
# initially we are reviewer-profile
|
||||
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
||||
|
||||
# resolve an author task (create_issue)
|
||||
res = mcp_server.gitea_resolve_task_capability(task="create_issue", remote="prgs")
|
||||
|
||||
self.assertFalse(res["allowed_in_current_session"])
|
||||
self.assertFalse(res["available_in_session"])
|
||||
self.assertEqual(res["active_profile"], "reviewer-profile")
|
||||
self.assertEqual(res["active_identity"], "reviewer-user")
|
||||
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
||||
self.assertIn("author-profile", res["matching_configured_profile"])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
def test_explicit_activate_profile_still_works(self, mock_api):
|
||||
mock_api.side_effect = lambda method, url, header: (
|
||||
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
||||
)
|
||||
with patch.dict(os.environ, self._env("author-profile")):
|
||||
act = mcp_server.gitea_activate_profile(
|
||||
profile_name="reviewer-profile", remote="prgs"
|
||||
)
|
||||
self.assertTrue(act["success"])
|
||||
self.assertEqual(act["after_profile"], "reviewer-profile")
|
||||
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||
|
||||
# verify it automatically switched to author-profile
|
||||
self.assertTrue(res["allowed_in_current_session"])
|
||||
self.assertEqual(res["active_profile"], "reviewer-profile")
|
||||
self.assertTrue(res["available_in_session"])
|
||||
self.assertEqual(res["active_profile"], "author-profile")
|
||||
self.assertEqual(res["active_identity"], "author-user")
|
||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
def test_denied_when_matching_profile_token_missing(self, mock_api):
|
||||
def test_restart_required_when_unattached(self, mock_api):
|
||||
mock_api.side_effect = lambda method, url, header: {"login": "author-user"}
|
||||
# launch without reviewer token in env
|
||||
with patch.dict(os.environ, self._env("author-profile", with_reviewer_token=False)):
|
||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
||||
|
||||
# resolve a reviewer task (review_pr)
|
||||
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||
|
||||
|
||||
# verify it did NOT switch and reports restart_required
|
||||
self.assertFalse(res["allowed_in_current_session"])
|
||||
self.assertFalse(res["available_in_session"])
|
||||
self.assertTrue(res["configured"])
|
||||
self.assertTrue(res["restart_required"])
|
||||
self.assertTrue(res["stop_required"])
|
||||
self.assertEqual(res["active_profile"], "author-profile")
|
||||
self.assertIn("Reviewer profile exists but MCP server was added after session startup and is not attached", res["reason"])
|
||||
|
||||
def test_author_continuity_dirty_worktree(self):
|
||||
# author is allowed to keep dirty worktree
|
||||
@@ -194,21 +161,20 @@ class TestOperationScopedRoles(unittest.TestCase):
|
||||
self.assertFalse(res2["proven"])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
def test_mutating_actions_do_not_auto_switch(self, mock_api):
|
||||
def test_mutating_actions_auto_switch(self, mock_api):
|
||||
mock_api.side_effect = lambda method, url, header: (
|
||||
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
||||
)
|
||||
with patch.dict(os.environ, self._env("author-profile")):
|
||||
# verify we are author-profile
|
||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
||||
|
||||
blocked = mcp_server._profile_permission_block(
|
||||
"gitea.pr.merge", remote="prgs"
|
||||
)
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertFalse(blocked.get("success", True))
|
||||
# call a reviewer mutation check helper (like _profile_permission_block with reviewer permission)
|
||||
blocked = mcp_server._profile_permission_block("gitea.pr.merge", remote="prgs")
|
||||
self.assertIsNone(blocked) # should switch to reviewer-profile and allow it (no permission block)
|
||||
|
||||
# profile must remain author — no silent substitution
|
||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
||||
# verify we dynamically switched to reviewer-profile
|
||||
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Tests for the operator guide / project skills MCP tools (#128).
|
||||
|
||||
Read-only capability-discovery tools: mcp_get_control_plane_guide,
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Post-merge moot reviewer-lease handling (#515).
|
||||
|
||||
Covers:
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Regression tests for non-list API payloads on PR/issue comment listing (#485)."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -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()
|
||||
@@ -1,7 +1,3 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Regression coverage for the remote/repo mismatch guard (#530).
|
||||
|
||||
Bare ``remote=prgs`` historically resolved to ``Scaled-Tech-Consulting/Timesheet``
|
||||
@@ -166,12 +162,12 @@ class TestResolveServerWiring(unittest.TestCase):
|
||||
self.addCleanup(self.server.REMOTES.__setitem__, "prgs", original)
|
||||
self.server.REMOTES["prgs"] = {**original, "repo": repo}
|
||||
|
||||
def test_resolve_prefers_workspace_over_timesheet_default(self):
|
||||
"""#714: bare remote=prgs must use workspace Gitea-Tools, not REMOTES Timesheet."""
|
||||
def test_resolve_blocks_on_default_repo_mismatch(self):
|
||||
self._set_prgs_default_repo("Timesheet")
|
||||
host, org, repo = self.server._resolve("prgs", None, None, None)
|
||||
self.assertEqual(org, "Scaled-Tech-Consulting")
|
||||
self.assertEqual(repo, "Gitea-Tools")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
self.server._resolve("prgs", None, None, None)
|
||||
self.assertIn("Timesheet", str(ctx.exception))
|
||||
self.assertIn("Gitea-Tools", str(ctx.exception))
|
||||
|
||||
def test_resolve_allows_explicit_org_and_repo(self):
|
||||
self._set_prgs_default_repo("Timesheet")
|
||||
|
||||
@@ -188,8 +188,8 @@ class TestResolveTaskCapability(unittest.TestCase):
|
||||
self.assertEqual(res["required_role_kind"], "author")
|
||||
self.assertTrue(res["allowed_in_current_session"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
|
||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_resolve_work_issue_reviewer_profile_blocked(self, _auth, _api):
|
||||
with patch.dict(os.environ, self._env("reviewer-profile")):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
"""Tests for reviewer/author namespace mismatch walls (#209)."""
|
||||
import json
|
||||
import os
|
||||
@@ -40,7 +36,6 @@ CONFIG = {
|
||||
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
|
||||
],
|
||||
"execution_profile": "prgs-author",
|
||||
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||
},
|
||||
"prgs-reviewer": {
|
||||
"enabled": True,
|
||||
@@ -56,7 +51,6 @@ CONFIG = {
|
||||
"gitea.pr.create", "gitea.branch.push", "gitea.issue.create",
|
||||
],
|
||||
"execution_profile": "prgs-reviewer",
|
||||
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||
},
|
||||
"prgs-reviewer-issue-writer": {
|
||||
"enabled": True,
|
||||
@@ -69,13 +63,12 @@ CONFIG = {
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.create"],
|
||||
"execution_profile": "prgs-reviewer-issue-writer",
|
||||
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||
},
|
||||
},
|
||||
"rules": {"allow_runtime_switching": False},
|
||||
}
|
||||
|
||||
ISSUE_WRITE_ENV = {} # config-backed; operations come from profile allowlist (#714)
|
||||
ISSUE_WRITE_ENV = {"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create"}
|
||||
|
||||
|
||||
class NamespaceGateBase(unittest.TestCase):
|
||||
@@ -206,7 +199,7 @@ class TestMutationAuditFields(NamespaceGateBase):
|
||||
def test_successful_create_issue_audit_includes_namespace_fields(
|
||||
self, _auth, mock_api, _get_all, _role
|
||||
):
|
||||
env = self._env("prgs-author", audit=True)
|
||||
env = {**self._env("prgs-author", audit=True), **ISSUE_WRITE_ENV}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
mcp_server.gitea_create_issue(title="audited", remote="prgs")
|
||||
with open(self.audit_path, encoding="utf-8") as fh:
|
||||
|
||||
@@ -36,7 +36,6 @@ CONFIG = {
|
||||
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
|
||||
],
|
||||
"execution_profile": "prgs-author",
|
||||
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||
},
|
||||
"prgs-reviewer": {
|
||||
"enabled": True,
|
||||
@@ -52,7 +51,6 @@ CONFIG = {
|
||||
"gitea.pr.create", "gitea.branch.push", "gitea.issue.create",
|
||||
],
|
||||
"execution_profile": "prgs-reviewer",
|
||||
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||
},
|
||||
"prgs-merger": {
|
||||
"enabled": True,
|
||||
@@ -67,7 +65,6 @@ CONFIG = {
|
||||
"gitea.pr.create", "gitea.branch.push", "gitea.pr.approve",
|
||||
],
|
||||
"execution_profile": "prgs-merger",
|
||||
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||
},
|
||||
"prgs-reconciler": {
|
||||
"enabled": True,
|
||||
@@ -84,7 +81,6 @@ CONFIG = {
|
||||
"gitea.branch.push",
|
||||
],
|
||||
"execution_profile": "prgs-reconciler",
|
||||
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||
},
|
||||
},
|
||||
"rules": {"allow_runtime_switching": False},
|
||||
|
||||
@@ -35,8 +35,7 @@ CONFIG_SWITCHING_DISABLED = {
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.create", "gitea.branch.push"],
|
||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||
"execution_profile": "author-profile",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
"execution_profile": "author-profile"
|
||||
},
|
||||
"reviewer-profile": {
|
||||
"enabled": True,
|
||||
@@ -46,8 +45,7 @@ CONFIG_SWITCHING_DISABLED = {
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve"],
|
||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
|
||||
"execution_profile": "reviewer-profile",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
"execution_profile": "reviewer-profile"
|
||||
},
|
||||
"merger-profile": {
|
||||
"enabled": True,
|
||||
@@ -57,8 +55,7 @@ CONFIG_SWITCHING_DISABLED = {
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_MERGER"},
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.merge"],
|
||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.approve"],
|
||||
"execution_profile": "merger-profile",
|
||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||
"execution_profile": "merger-profile"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Documentation acceptance for the stable control runtime ADR (#615 / PR #616).
|
||||
|
||||
Enforces review 443 remediation:
|
||||
|
||||
* F1 — operator guide / runbooks cross-link the ADR (issue #615 AC2).
|
||||
* F2 — LLM sessions are not instructed to kill/restart/relaunch MCP; process
|
||||
restart is operator-owned; ADR is the authoritative split.
|
||||
* F3 — routine post-merge master-parity staleness has a sanctioned response
|
||||
(stop → report → operator reload → re-verify parity) and is not a full
|
||||
promotion ledger requirement.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
ADR = (
|
||||
REPO_ROOT
|
||||
/ "docs"
|
||||
/ "architecture"
|
||||
/ "mcp-stable-control-runtime-policy-adr.md"
|
||||
)
|
||||
ADR_REL = "architecture/mcp-stable-control-runtime-policy-adr.md"
|
||||
ADR_BASENAME = "mcp-stable-control-runtime-policy-adr.md"
|
||||
|
||||
CROSS_LINK_DOCS = (
|
||||
REPO_ROOT / "docs" / "wiki" / "Operator-Guide.md",
|
||||
REPO_ROOT / "docs" / "wiki" / "Runbooks.md",
|
||||
REPO_ROOT / "docs" / "llm-workflow-runbooks.md",
|
||||
)
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
assert path.is_file(), f"missing {path.relative_to(REPO_ROOT)}"
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_adr_exists_with_policy_core():
|
||||
text = _read(ADR)
|
||||
assert text.lstrip().startswith("#"), "ADR lacks a title"
|
||||
assert "#615" in text
|
||||
assert "stable control runtime" in text.lower()
|
||||
assert "2.3" in text and "2.4" in text and "2.5" in text and "2.6" in text
|
||||
|
||||
|
||||
def test_f1_operator_docs_cross_link_adr():
|
||||
for path in CROSS_LINK_DOCS:
|
||||
text = _read(path)
|
||||
assert ADR_BASENAME in text, (
|
||||
f"{path.relative_to(REPO_ROOT)} must cross-link {ADR_BASENAME} "
|
||||
f"(issue #615 acceptance criterion 2)"
|
||||
)
|
||||
|
||||
|
||||
def test_f1_adr_lists_cross_links_as_acceptance_not_optional_tooling():
|
||||
text = _read(ADR)
|
||||
# Acceptance section must require guide/runbook cross-links.
|
||||
assert "Operator guide / runbooks cross-link" in text or (
|
||||
"operator guide" in text.lower() and "cross-link" in text.lower()
|
||||
and "Acceptance" in text
|
||||
)
|
||||
# Cross-link must not remain only as optional tooling item #4.
|
||||
optional = text.split("## 5. Implementation follow-ups", 1)[-1].split(
|
||||
"## 6. Acceptance", 1
|
||||
)[0]
|
||||
assert "Operator Guide wiki cross-link to this ADR" not in optional, (
|
||||
"ADR §5 must not list operator-guide cross-link as optional tooling"
|
||||
)
|
||||
assert "Not optional" in text or "must** cross-link" in text.lower() or (
|
||||
"must cross-link" in text.lower()
|
||||
)
|
||||
|
||||
|
||||
def test_f2_runbook_fallback_is_operator_owned_not_llm_restart():
|
||||
runbooks = _read(REPO_ROOT / "docs" / "llm-workflow-runbooks.md")
|
||||
# Forbidden historical self-service instruction.
|
||||
forbidden = (
|
||||
"the LLM must relaunch or restart the client/MCP with the correct "
|
||||
"profile environment variable before claiming or working on any tasks"
|
||||
)
|
||||
assert forbidden not in runbooks, (
|
||||
"llm-workflow-runbooks must not instruct the LLM to relaunch/restart MCP"
|
||||
)
|
||||
assert "operator-owned" in runbooks.lower() or "Operator-owned" in runbooks
|
||||
assert ADR_BASENAME in runbooks
|
||||
|
||||
|
||||
def test_f2_workspace_rebind_does_not_require_llm_process_relaunch():
|
||||
runbooks = _read(REPO_ROOT / "docs" / "llm-workflow-runbooks.md")
|
||||
section = runbooks.split("### Safe reconnect / rebind procedure", 1)[-1]
|
||||
section = section.split("## Safety notes", 1)[0]
|
||||
# Must not tell the LLM alone to relaunch the MCP process as step 2.
|
||||
assert "Reconnect or relaunch the correct namespace MCP server from the intended" not in section
|
||||
assert "Operator-owned" in section or "operator" in section.lower()
|
||||
assert "worktree_path" in section
|
||||
collapsed = " ".join(section.lower().split())
|
||||
assert "client reconnect" in collapsed
|
||||
|
||||
|
||||
def test_f2_adr_forbids_llm_restart_and_supersedes_self_service_relaunch():
|
||||
text = _read(ADR)
|
||||
lower = text.lower()
|
||||
assert "must not" in lower and "restart" in lower
|
||||
assert "operator" in lower and "reload" in lower
|
||||
assert "supersedes" in lower
|
||||
assert "llm" in lower
|
||||
|
||||
|
||||
def test_f3_adr_defines_post_merge_parity_staleness_response():
|
||||
text = _read(ADR)
|
||||
lower = text.lower()
|
||||
assert "2.6" in text
|
||||
assert "post-merge" in lower or "post merge" in lower
|
||||
assert "parity" in lower and "stale" in lower
|
||||
# Sanctioned steps: stop, report, operator reload, re-verify.
|
||||
assert "stop" in lower
|
||||
assert "report" in lower
|
||||
assert "operator" in lower
|
||||
assert "parity is verified" in lower or "re-verif" in lower or (
|
||||
"startup/current-head" in lower
|
||||
)
|
||||
# Not a full promotion ledger for routine reload.
|
||||
assert "not a §2.4 promotion" in lower or "not a section 2.4 promotion" in lower or (
|
||||
"not a §2.4" in text or "Not a §2.4 promotion" in text
|
||||
)
|
||||
# Stale parity listed among unhealthy triggers.
|
||||
assert "master parity is stale" in lower or "stale master parity" in lower
|
||||
|
||||
|
||||
def test_f3_adr_forbids_llm_bypass_of_parity_gate():
|
||||
text = _read(ADR)
|
||||
lower = text.lower()
|
||||
assert "bypass" in lower or "self-reset" in lower or "self-service" in lower
|
||||
assert "parity" in lower
|
||||
|
||||
|
||||
def test_cross_links_do_not_embed_secrets_or_raw_hosts_in_wiki_snippets():
|
||||
for path in CROSS_LINK_DOCS:
|
||||
text = _read(path)
|
||||
for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"):
|
||||
assert marker not in text, f"{path} contains {marker!r}"
|
||||
@@ -1,11 +1,15 @@
|
||||
"""Stale #332 review-decision lock cleanup (#594).
|
||||
|
||||
Proves:
|
||||
a. active terminal locks still block unrelated terminal reviews
|
||||
b. merged/closed PR locks can be cleared canonically
|
||||
c. cleanup cannot bypass review gates on open PRs
|
||||
d. after moot cleanup, mark_ready for a different PR can proceed
|
||||
(PR #592-style after PR #586-style stale approve)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Invariant tests pinning task_capability_map role assignments (#722/#723).
|
||||
|
||||
Added with the operator-authorized break-glass repair for incident #722:
|
||||
commit 970e68b remapped ten reviewer tasks to ``role="merger"`` while every
|
||||
configured merger profile forbids the review permissions, so no configured
|
||||
profile could resolve any formal review task (``matching_configured_profile``
|
||||
was empty repository-wide). These tests fail loudly if that class of
|
||||
regression recurs:
|
||||
|
||||
- every role-exclusive formal-review task must be satisfiable by at least one
|
||||
canonical role profile (permission AND role together);
|
||||
- the capability map must agree with ``role_session_router`` task sets;
|
||||
- ``adopt_merger_pr_lease`` stays merger-only (the legitimate hunk of
|
||||
970e68b, preserved by the repair);
|
||||
- merger profiles must not be able to resolve review_pr/approve_pr.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
import gitea_config
|
||||
from role_session_router import MERGER_TASKS, REVIEWER_TASKS
|
||||
from task_capability_map import required_permission, required_role
|
||||
|
||||
# Canonical role-profile permission shape. Mirrors the configured
|
||||
# author/reviewer/merger/reconciler profiles (profiles.json v2 role split):
|
||||
# reviewers review/approve/request changes but never merge; mergers merge but
|
||||
# never review/approve/request changes.
|
||||
CANONICAL_ROLE_PROFILES = {
|
||||
"author": {
|
||||
"allowed": [
|
||||
"gitea.read",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
],
|
||||
"forbidden": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.merge",
|
||||
],
|
||||
},
|
||||
"reviewer": {
|
||||
"allowed": [
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
],
|
||||
"forbidden": [
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.merge",
|
||||
],
|
||||
},
|
||||
"merger": {
|
||||
"allowed": [
|
||||
"gitea.read",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
],
|
||||
"forbidden": [
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
},
|
||||
"reconciler": {
|
||||
"allowed": [
|
||||
"gitea.read",
|
||||
"gitea.pr.close",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.branch.delete",
|
||||
"gitea.decision_lock.irrecoverable_recovery",
|
||||
],
|
||||
"forbidden": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Role-exclusive formal-review tasks (mirrors the resolver's role-exclusive
|
||||
# handling for review work): permission alone is not enough — the profile's
|
||||
# role kind must also match, so both dimensions are pinned here.
|
||||
FORMAL_REVIEW_TASKS = (
|
||||
"review_pr",
|
||||
"approve_pr",
|
||||
"request_changes_pr",
|
||||
"blind_pr_queue_review",
|
||||
"pr_queue_cleanup",
|
||||
"pr-queue-cleanup",
|
||||
)
|
||||
|
||||
|
||||
def _profile_satisfies(role_name, task):
|
||||
"""True when the canonical *role_name* profile can perform *task*."""
|
||||
profile = CANONICAL_ROLE_PROFILES[role_name]
|
||||
ok, _reason = gitea_config.check_operation(
|
||||
required_permission(task), profile["allowed"], profile["forbidden"]
|
||||
)
|
||||
return ok and role_name == required_role(task)
|
||||
|
||||
|
||||
class TestFormalReviewProfileCoverage(unittest.TestCase):
|
||||
"""#722: some configured profile must be able to formally review."""
|
||||
|
||||
def test_every_formal_review_task_has_a_satisfying_role_profile(self):
|
||||
for task in FORMAL_REVIEW_TASKS:
|
||||
with self.subTest(task=task):
|
||||
satisfying = [
|
||||
role
|
||||
for role in CANONICAL_ROLE_PROFILES
|
||||
if _profile_satisfies(role, task)
|
||||
]
|
||||
self.assertTrue(
|
||||
satisfying,
|
||||
f"no canonical role profile satisfies both permission "
|
||||
f"{required_permission(task)!r} and role "
|
||||
f"{required_role(task)!r} for task {task!r} — formal "
|
||||
f"review would be impossible for every configured "
|
||||
f"profile (incident #722)",
|
||||
)
|
||||
|
||||
def test_formal_review_tasks_are_reviewer_role(self):
|
||||
for task in FORMAL_REVIEW_TASKS:
|
||||
with self.subTest(task=task):
|
||||
self.assertEqual(required_role(task), "reviewer")
|
||||
|
||||
|
||||
class TestMapRouterAgreement(unittest.TestCase):
|
||||
"""#723 AC2: the map and the role session router must not drift."""
|
||||
|
||||
def test_reviewer_tasks_map_to_reviewer_role(self):
|
||||
for task in sorted(REVIEWER_TASKS):
|
||||
with self.subTest(task=task):
|
||||
self.assertEqual(
|
||||
required_role(task),
|
||||
"reviewer",
|
||||
f"router classifies {task!r} as a reviewer task but the "
|
||||
f"capability map assigns role {required_role(task)!r}",
|
||||
)
|
||||
|
||||
def test_merger_tasks_map_to_merger_role(self):
|
||||
for task in sorted(MERGER_TASKS):
|
||||
with self.subTest(task=task):
|
||||
self.assertEqual(
|
||||
required_role(task),
|
||||
"merger",
|
||||
f"router classifies {task!r} as a merger task but the "
|
||||
f"capability map assigns role {required_role(task)!r}",
|
||||
)
|
||||
|
||||
|
||||
class TestMergerBoundary(unittest.TestCase):
|
||||
"""Preserve the legitimate hunk of 970e68b and the merger fence."""
|
||||
|
||||
def test_adopt_merger_pr_lease_requires_merger_role(self):
|
||||
self.assertEqual(required_role("adopt_merger_pr_lease"), "merger")
|
||||
self.assertEqual(
|
||||
required_permission("adopt_merger_pr_lease"), "gitea.pr.comment"
|
||||
)
|
||||
|
||||
def test_merge_pr_requires_merger_role(self):
|
||||
self.assertEqual(required_role("merge_pr"), "merger")
|
||||
self.assertEqual(required_permission("merge_pr"), "gitea.pr.merge")
|
||||
|
||||
def test_merger_profile_cannot_resolve_formal_review_tasks(self):
|
||||
merger = CANONICAL_ROLE_PROFILES["merger"]
|
||||
for task in ("review_pr", "approve_pr", "request_changes_pr"):
|
||||
with self.subTest(task=task):
|
||||
ok, reason = gitea_config.check_operation(
|
||||
required_permission(task),
|
||||
merger["allowed"],
|
||||
merger["forbidden"],
|
||||
)
|
||||
self.assertFalse(
|
||||
ok,
|
||||
f"merger profile must not hold {task!r} permission "
|
||||
f"(got reason {reason!r})",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,11 +1,7 @@
|
||||
"""Tests for canonical workflow skill naming and preflight (#551)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
Reference in New Issue
Block a user