Compare commits

..
5 Commits
Author SHA1 Message Date
sysadmin dc899d23c8 fix(session): require config-backed mutation authority and workspace-bound targets (#714)
Close the #714 remediation gap left at 943d402 where env-only mutation
profiles, REMOTES Timesheet defaults treated as explicit caller input, and
machine-dependent Git remotes produced false greens.

- Fail closed when mutations lack a config-backed profile with non-empty
  allowed_repositories; env ops cannot invent mutation authority.
- Preserve omitted-vs-explicit org/repo provenance through the mutation
  gate; omitted targets bind to the verified workspace repository.
- Prefer workspace-aligned Git remotes over historical Timesheet defaults
  in MCP _resolve and CLI resolve_remote.
- Centralize deterministic config-backed mutation fixtures; migrate
  mutation tests off env-only authority without weakening security
  assertions.

Full suite: 2744 passed, 6 skipped.
2026-07-15 21:13:21 -04:00
sysadminandClaude Opus 4.8 943d40270e fix(session): bind workspace-verified repository scope at first bind (#714)
The independent review reproduced a real hole: the production first-bind path
never pinned repository/org, so the drift checks it feeds were skipped.

Root cause
----------
gitea_whoami, gitea_get_runtime_context, gitea_resolve_task_capability and
gitea_activate_profile all seeded the session context without repository or
org. First-bind-wins then made the mutation gate's later seed a no-op, and
assess_session_context only compares repository/org when the bound fields are
non-null. Result, reproduced in production order with the real REMOTES:

  after whoami:               repository=None org=None
  same-host repo=Other-Tools  -> NOT BLOCKED
  same-host org=Other-Org     -> NOT BLOCKED
  cross-host                  -> blocked (this part always worked)

Binding REMOTES defaults would not fix it: REMOTES['prgs'].repo is 'Timesheet'
(a default *target*, not an authorization scope, see #530), so pinning it fails
closed on every legitimate Gitea-Tools mutation. There was no trusted source
for repository scope, so this adds one.

Design
------
- New optional profile field `allowed_repositories`: canonical owner/repository
  slugs. An authorization boundary, never the binding itself. Config-only —
  no env var can widen or forge it.
- The session repository is derived from the verified, workspace-aligned git
  remote, never from caller-supplied org/repo, and validated against that
  allowlist. The session binds immutably to exactly one canonical slug; org is
  derived from it. A profile authorizing several repositories still binds to
  the single verified one.
- Every first-bind entry point now pins the same complete context. Activation
  rejects an unauthorized/unverifiable workspace. Mutations fail closed when
  repository/org is unverified, and a tool-level org/repo override that
  disagrees with the binding is rejected before the write.
- A mutation request can no longer establish, complete, or replace the binding
  (it previously seeded from `org or REMOTES[...]`, i.e. caller-controlled).
- Enforcement is opt-in per profile: a profile without the field keeps prior
  behaviour, so existing static-profile namespaces stay functional.

First-bind-wins, immutability, the RLock, profile isolation, host/identity
validation and the private pytest-only reset are unchanged.

gitea_auth.get_profile() built an explicit whitelist dict and silently dropped
`allowed_repositories`; the new integration tests caught that the scope was
never enforced through the real path.

Tests
-----
New tests/test_issue_714_production_first_bind.py drives the real entry points
in production order rather than constructing _SessionContext directly. Covers
complete first bind via each entry point, same-host repo/org drift, Timesheet
and other-repo/other-org rejection, unverified and unauthorized workspaces,
caller values unable to establish the binding, concurrent init selecting one
repository, and the PR #715 author path staying functional. Mutation-verified:
disabling trusted derivation, override validation, the scope check, or the
get_profile passthrough each fails these tests (9/8/4/5 failures).

No security assertion was weakened, removed, skipped, or rewritten.

Focused: 26 passed. Issue #714 total: 46 passed. Prior failing modules: 240
passed. Config/profile/remote modules: 130 passed. Full suite: 2739 passed,
6 skipped, 1 pre-existing warning, 161 subtests in 25.80s.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-15 16:50:41 -04:00
sysadminandClaude Opus 4.8 d936da5c87 test(session): cover reverse cross-host denial, concurrent init, repo drift (#714)
The formal REQUEST_CHANGES review on PR #715 was resolved by the test
isolation commit (29ffe14); the full suite is green. Auditing the required
session-lifecycle coverage surfaced three gaps that the suite did not prove:

- Every cross-host test pinned mdcps-reviewer and denied prgs. The reverse
  direction (a pinned MDCPS profile must never serve a prgs request, nor be
  swapped for prgs-author) was unproven.
- test_parallel_calls_cannot_overwrite_established_binding binds first and
  then races, so concurrent *initialization* from an unbound context — N
  threads racing to first-bind, exactly one winner, no torn context — was
  unproven.
- assess_session_context checks repository and org drift, but no test bound a
  repository/org and asserted a same-host mismatch fails closed.

Adds three tests covering those cases. Each was mutation-verified: disabling
the first-bind-wins guard, the cross-host denial, and the repository/org drift
checks makes the corresponding test fail.

Additive only — no production code changed and no existing security assertion
weakened, skipped, or rewritten.

Focused: 20 passed. Full suite: 2713 passed, 6 skipped, 1 pre-existing warning,
161 subtests passed in 23.05s.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-15 15:51:52 -04:00
sysadmin 29ffe1407f fix: isolate immutable session context tests (#714) 2026-07-15 02:26:27 -04:00
sysadminandGrok 632c568865 fix(session): pin immutable MCP context; ban cross-host profile substitution (Closes #714)
Capability resolution and mutation gates no longer auto-switch profiles.
Session profile/remote/host/identity are bound after pin or first seed,
and drift or cross-host resolution fails closed before writes.

Co-Authored-By: Grok <[email protected]>
2026-07-14 23:05:41 -04:00
57 changed files with 3697 additions and 10268 deletions
-9
View File
@@ -55,12 +55,3 @@ GITEA_MCP_PROFILE=prgs
# GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456 # GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456
# GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456 # GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456
# GITEA_ACTIVE_WORKTREE=/path/to/repo/branches/session-override # 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
-505
View File
@@ -7,19 +7,6 @@ from typing import Any
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"}) 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 = ( _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+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), re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I),
@@ -85,11 +72,6 @@ def assess_merged_pr_branch_cleanup(
reasons.append("PR head branch is missing") reasons.append("PR head branch is missing")
if head_branch in protected: if head_branch in protected:
reasons.append(f"branch '{head_branch}' is 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: if head_branch in open_pr_heads:
reasons.append("an open PR still references this head branch") reasons.append("an open PR still references this head branch")
if head_on_target is False: if head_on_target is False:
@@ -114,490 +96,3 @@ def assess_merged_pr_branch_cleanup(
"block_reasons": reasons, "block_reasons": reasons,
"recommended_action": "delete_remote_branch" if safe else "keep_remote_branch", "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",
}
+42 -185
View File
@@ -45,6 +45,7 @@ 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). | | `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. | | `allowed_operations` | list | Operation categories this profile may perform. |
| `forbidden_operations` | list | Operation categories this profile must never 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.** | | `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. | | `audit_label` | string | Short label attached to audit records for actions by this profile. |
| `can_approve_prs` | bool | May submit an approving PR review. | | `can_approve_prs` | bool | May submit an approving PR review. |
@@ -57,6 +58,47 @@ authenticated capability set. Each profile defines the following fields:
name), never the token itself. Token values are never part of a profile object, name), never the token itself. Token values are never part of a profile object,
never logged, never returned by a tool, and never committed. 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 ## Example profiles
The following are the reference profiles. Booleans express intended capability The following are the reference profiles. Booleans express intended capability
@@ -238,43 +280,11 @@ narrow operation set:
- `gitea.issue.comment` - `gitea.issue.comment`
- `gitea.issue.close` - `gitea.issue.close`
- `gitea.pr.close` - `gitea.pr.close`
- `gitea.branch.delete` (merged-branch cleanup only — see below)
Forbidden on reconciler profiles: `gitea.pr.approve`, `gitea.pr.merge`, Forbidden on reconciler profiles: `gitea.pr.approve`, `gitea.pr.merge`,
`gitea.pr.review`, `gitea.pr.create`, `gitea.branch.push`, and `gitea.pr.review`, `gitea.pr.create`, `gitea.branch.push`, and
`gitea.repo.commit`. `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 Launch a static `gitea-reconciler` MCP namespace with
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by `GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
`reconciler_profile.assess_reconciler_profile` (#304). Use the `reconciler_profile.assess_reconciler_profile` (#304). Use the
@@ -283,159 +293,6 @@ Launch a static `gitea-reconciler` MCP namespace with
fresh target-branch fetch, recorded target SHA, and ancestor proof. PRs whose fresh target-branch fetch, recorded target SHA, and ancestor proof. PRs whose
heads are not already landed cannot be closed through this path. 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 identitys
`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 ## Identity and fail-closed rules
Before **any** mutating action, a workflow must know both: Before **any** mutating action, a workflow must know both:
+1
View File
@@ -41,6 +41,7 @@
"execution_profile": "example-author", "execution_profile": "example-author",
"audit_label": "example-author", "audit_label": "example-author",
"auth": { "type": "keychain", "id": "example-gitea-author-token" }, "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"], "allowed_operations": ["read", "branch", "commit", "push", "open_pr", "comment", "issue.comment"],
"forbidden_operations": ["approve", "request_changes", "merge"] "forbidden_operations": ["approve", "request_changes", "merge"]
}, },
+66 -210
View File
@@ -182,11 +182,51 @@ def get_auth_header(host):
def resolve_remote(args): def resolve_remote(args):
"""Given parsed argparse args with --remote/--host/--org/--repo, """Given parsed argparse args with --remote/--host/--org/--repo,
return (host, org, repo) with overrides applied.""" 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.
"""
profile = REMOTES[args.remote] profile = REMOTES[args.remote]
host = args.host or profile["host"] host = args.host or profile["host"]
org = args.org or profile["org"] org_explicit = getattr(args, "org", None) is not None
repo = args.repo or profile["repo"] 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
return host, org, repo return host, org, repo
@@ -243,192 +283,6 @@ def _redact(text):
return str(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): def _add_query(url, **params):
"""Return *url* with the given query parameters added or overridden. """Return *url* with the given query parameters added or overridden.
@@ -501,24 +355,23 @@ def api_request(method, url, auth_header, payload=None, *,
"""Make an authenticated JSON request to the Gitea API. """Make an authenticated JSON request to the Gitea API.
Returns parsed JSON on success (or ``None`` for an empty body), and raises Returns parsed JSON on success (or ``None`` for an empty body), and raises
a classified client error on failure. ``RuntimeError`` on failure.
On HTTP 429 the request is retried up to *max_retries* times: honoring a 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 valid ``Retry-After`` header (seconds or HTTP-date) when present, otherwise
using capped jittered exponential backoff. Successful responses are using capped jittered exponential backoff. Successful responses are
unchanged. unchanged.
Failures raise typed exceptions with **fixed messages only** (#699). HTTP All failures are converted to a ``RuntimeError`` with a clear, secret
response bodies are read solely for local 403 reason refinement and are -redacted message (no raw stack traces or credential material):
never stored on exceptions or returned to callers:
- HTTP 401 → :class:`GiteaAuthError` (``auth_invalid_token``) - Non-429 HTTP errors surface the status code and a redacted response body.
- HTTP 403 → :class:`GiteaAuthzError` (scope or denied) 502/503/504 upstream errors get an explicit "Gitea upstream unavailable"
- 502/503/504 → :class:`GiteaHttpError` (``upstream_unavailable``) message.
- Other non-429 HTTP → :class:`GiteaHttpError` (``http_error``) - Timeouts and network/DNS failures (``URLError`` / ``TimeoutError``) surface
- Timeouts / DNS / ``URLError`` → :class:`GiteaNetworkError` a generic "network error contacting Gitea" message.
- Malformed success JSON → plain ``RuntimeError`` (programming/protocol; - A malformed (non-JSON) success body surfaces a "malformed JSON response"
not reclassified as authentication) message rather than a raw decode error.
The ``*_func`` parameters and ``timeout`` are injection points for The ``*_func`` parameters and ``timeout`` are injection points for
deterministic testing. deterministic testing.
@@ -556,23 +409,22 @@ def api_request(method, url, auth_header, payload=None, *,
error_body = e.read().decode("utf-8", errors="replace") error_body = e.read().decode("utf-8", errors="replace")
except Exception: except Exception:
error_body = "" error_body = ""
# Classify from status (+ local body hint). Body is not embedded. detail = _redact(error_body).strip()
try: if e.code in (502, 503, 504):
raise_for_http_status(e.code, error_body) msg = f"HTTP {e.code}: Gitea upstream unavailable"
except GiteaClientError: raise RuntimeError(f"{msg}: {detail}" if detail else msg) from e
raise raise RuntimeError(f"HTTP {e.code}: {detail}") from e
# 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: except (urllib.error.URLError, TimeoutError) as e:
# Fixed message only — do not embed URLError reason (may leak paths). reason = getattr(e, "reason", e)
raise GiteaNetworkError(reason_code="network_error") from e raise RuntimeError(
f"network error contacting Gitea: {_redact(reason)}"
) from e
if not body: if not body:
return None return None
try: try:
return json.loads(body) return json.loads(body)
except ValueError as e: except ValueError as e:
# Programming/protocol failure — not authentication.
raise RuntimeError("malformed JSON response from Gitea") from e raise RuntimeError("malformed JSON response from Gitea") from e
@@ -758,6 +610,10 @@ def get_profile():
"profile_name": name, "profile_name": name,
"allowed_operations": ops, "allowed_operations": ops,
"forbidden_operations": forbidden, "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, "audit_label": audit_label,
"token_source_name": token_source, "token_source_name": token_source,
"auth_source_type": auth_type, "auth_source_type": auth_type,
+28
View File
@@ -475,6 +475,33 @@ def _require_enabled(kind, name, obj):
return enabled 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): def _reject_inline_secrets(kind, name, obj):
for key in _INLINE_SECRET_KEYS: for key in _INLINE_SECRET_KEYS:
if key in obj: if key in obj:
@@ -549,6 +576,7 @@ def _load_v2_contexts(data, path):
forbidden = raw.get("forbidden_operations") or [] forbidden = raw.get("forbidden_operations") or []
if not isinstance(allowed, list) or not isinstance(forbidden, list): if not isinstance(allowed, list) or not isinstance(forbidden, list):
raise ConfigError(f"profile '{name}' operation fields must be lists") 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} allowed_n = {_normalize_op("gitea", op, name) for op in allowed}
forbidden_n = {_normalize_op("gitea", op, name) for op in forbidden} forbidden_n = {_normalize_op("gitea", op, name) for op in forbidden}
# Reviewer-identity deadlock rule (#100/#103) applies here unchanged. # Reviewer-identity deadlock rule (#100/#103) applies here unchanged.
+774 -2318
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -196
View File
@@ -36,24 +36,7 @@ KIND_REVIEW_DRAFT = "review_draft"
# other session proofs; a contaminated session fails closed on gated mutations # other session proofs; a contaminated session fails closed on gated mutations
# until a reconciler audits and clears it. # until a reconciler audits and clears it.
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination" 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).
RECOVERY_CRITICAL_KINDS = frozenset(
{
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._+-]+") _SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
@@ -287,11 +270,7 @@ def identity_match_reasons(
reasons.append("session state missing recorded_at timestamp (fail closed)") reasons.append("session state missing recorded_at timestamp (fail closed)")
else: else:
age = _now_utc() - recorded_at age = _now_utc() - recorded_at
kind = (record.get("kind") or "").strip() if age > timedelta(hours=ttl_hours()):
ttl_exempt = kind in RECOVERY_CRITICAL_KINDS or bool(
record.get("recovery_critical")
)
if age > timedelta(hours=ttl_hours()) and not ttl_exempt:
reasons.append( reasons.append(
f"session state expired after {ttl_hours():g}h (fail closed)" f"session state expired after {ttl_hours():g}h (fail closed)"
) )
@@ -468,177 +447,3 @@ def clear_state(
profile_identity=profile_identity, profile_identity=profile_identity,
state_dir=state_dir, 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
-451
View File
@@ -1,451 +0,0 @@
"""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))
+7 -126
View File
@@ -20,114 +20,12 @@ if PROJECT_ROOT not in sys.path:
import gitea_config import gitea_config
# Defaults emit *canonical* operation names only. Shorthand that is not in AUTHOR_DEFAULT_ALLOWED = ["read", "branch", "commit", "push", "open_pr", "comment"]
# gitea_config.GITEA_OPERATION_ALIASES (e.g. ``pr.close``, ``issue.close``) AUTHOR_DEFAULT_FORBIDDEN = ["approve", "request_changes", "merge"]
# 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",
]
AUTHOR_DEFAULT_FORBIDDEN = [
"gitea.pr.approve",
"gitea.pr.request_changes",
"gitea.pr.merge",
]
REVIEWER_DEFAULT_ALLOWED = [ REVIEWER_DEFAULT_ALLOWED = [
"gitea.read", "read", "review", "comment", "approve", "request_changes", "merge"
"gitea.pr.review",
"gitea.pr.comment",
"gitea.pr.approve",
"gitea.pr.request_changes",
"gitea.pr.merge",
] ]
REVIEWER_DEFAULT_FORBIDDEN = [ REVIEWER_DEFAULT_FORBIDDEN = ["branch", "commit", "push", "open_pr"]
"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): def infer_role(name, execution_profile):
@@ -192,11 +90,9 @@ def migrate_v1_to_v2(v1_data):
ident_name = "reviewer" ident_name = "reviewer"
elif role == "author": elif role == "author":
ident_name = "author" ident_name = "author"
elif role == "reconciler":
ident_name = "reconciler"
else: else:
role = prof.get("role") role = prof.get("role")
if role not in (None, "author", "reviewer", "reconciler"): if role not in (None, "author", "reviewer"):
raise ValueError( raise ValueError(
f"Profile '{name}' has unsupported role {role!r}" f"Profile '{name}' has unsupported role {role!r}"
) )
@@ -228,35 +124,20 @@ def migrate_v1_to_v2(v1_data):
raise ValueError( raise ValueError(
f"Profile '{name}' operation fields must be lists" f"Profile '{name}' operation fields must be lists"
) )
try: identity_data["allowed_operations"] = list(allowed)
identity_data["allowed_operations"] = canonicalize_operations( identity_data["forbidden_operations"] = list(forbidden)
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": elif role == "author":
identity_data["allowed_operations"] = list(AUTHOR_DEFAULT_ALLOWED) identity_data["allowed_operations"] = list(AUTHOR_DEFAULT_ALLOWED)
identity_data["forbidden_operations"] = list(AUTHOR_DEFAULT_FORBIDDEN) identity_data["forbidden_operations"] = list(AUTHOR_DEFAULT_FORBIDDEN)
elif role == "reviewer": elif role == "reviewer":
identity_data["allowed_operations"] = list(REVIEWER_DEFAULT_ALLOWED) identity_data["allowed_operations"] = list(REVIEWER_DEFAULT_ALLOWED)
identity_data["forbidden_operations"] = list(REVIEWER_DEFAULT_FORBIDDEN) 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: else:
raise ValueError( raise ValueError(
f"Profile '{name}' has no explicit operation lists and no " f"Profile '{name}' has no explicit operation lists and no "
"unambiguous author/reviewer role marker (fail closed)" "unambiguous author/reviewer role marker (fail closed)"
) )
if role == "reconciler":
_assert_reconciler_required_survive(
identity_data["allowed_operations"], name
)
# Nest inside environments/services structure # Nest inside environments/services structure
env = environments.setdefault(env_name, {}) env = environments.setdefault(env_name, {})
services = env.setdefault("services", {}) services = env.setdefault("services", {})
-5
View File
@@ -18,11 +18,6 @@ RECONCILER_RECOMMENDED_OPERATIONS = (
"gitea.pr.comment", "gitea.pr.comment",
"gitea.issue.comment", "gitea.issue.comment",
"gitea.issue.close", "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 = ( RECONCILER_FORBIDDEN_OPERATIONS = (
+595
View File
@@ -0,0 +1,595 @@
"""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),
}
-249
View File
@@ -438,252 +438,3 @@ def format_cleanup_audit_comment(audit: dict[str, Any]) -> str:
"This path only clears a lock when the referenced PR is merged/closed.", "This path only clears a lock when the referenced PR is merged/closed.",
] ]
return "\n".join(lines) 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)
-26
View File
@@ -127,32 +127,6 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.pr.review", "permission": "gitea.pr.review",
"role": "reviewer", "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": { "delete_branch": {
"permission": "gitea.branch.delete", "permission": "gitea.branch.delete",
"role": "author", "role": "author",
+33 -1
View File
@@ -26,6 +26,14 @@ def _reset_mutation_authority(monkeypatch):
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir 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. 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 [ for env_key in [
"GITEA_SESSION_PROFILE_LOCK", "GITEA_SESSION_PROFILE_LOCK",
"GITEA_ACTIVE_WORKTREE", "GITEA_ACTIVE_WORKTREE",
@@ -80,9 +88,15 @@ def _reset_mutation_authority(monkeypatch):
try: try:
import mcp_server import mcp_server
except Exception: except Exception:
_state_tmp.cleanup() try:
yield yield
finally:
session_ctx._reset_session_context_for_testing()
_state_tmp.cleanup()
return return
import gitea_config
monkeypatch.setattr(gitea_config, "_active_profile_override", None)
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None) monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {}) monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None) monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
@@ -113,7 +127,9 @@ def _reset_mutation_authority(monkeypatch):
capability_stop_terminal.clear() capability_stop_terminal.clear()
except Exception: except Exception:
pass pass
try:
yield yield
finally:
try: try:
import capability_stop_terminal import capability_stop_terminal
capability_stop_terminal.clear() capability_stop_terminal.clear()
@@ -128,4 +144,20 @@ def _reset_mutation_authority(monkeypatch):
mcp_server._REVIEW_DECISION_LOCK = None mcp_server._REVIEW_DECISION_LOCK = None
except Exception: except Exception:
pass pass
gitea_config._active_profile_override = None
session_ctx._reset_session_context_for_testing()
_state_tmp.cleanup() _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()
except Exception:
pass
yield
+483
View File
@@ -0,0 +1,483 @@
"""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
+7 -5
View File
@@ -1,3 +1,7 @@
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).""" """Tests for agent temp artifact detection and preflight warnings (#261)."""
import json import json
import os import os
@@ -65,11 +69,9 @@ class TestPreflightWarnings(unittest.TestCase):
# Issue-write tools are profile-gated (#69); gitea_lock_issue requires # Issue-write tools are profile-gated (#69); gitea_lock_issue requires
# gitea.issue.comment (see task_capability_map), so the gate must be # gitea.issue.comment (see task_capability_map), so the gate must be
# seeded exactly like tests/test_mcp_server.py::TestIssueLocking (#359). # seeded exactly like tests/test_mcp_server.py::TestIssueLocking (#359).
ISSUE_WRITE_ENV = { ISSUE_WRITE_ENV = shared_mutation_env(
"GITEA_ALLOWED_OPERATIONS": ( "test-author-prgs",
"gitea.issue.create,gitea.issue.close,gitea.issue.comment" )
),
}
class TestIssueLockArtifactWarning(unittest.TestCase): class TestIssueLockArtifactWarning(unittest.TestCase):
+16 -22
View File
@@ -79,46 +79,41 @@ class TestApiRequestFailures(unittest.TestCase):
@patch("gitea_auth.urllib.request.urlopen") @patch("gitea_auth.urllib.request.urlopen")
def test_timeout_converted_to_runtimeerror(self, mock_open): def test_timeout_converted_to_runtimeerror(self, mock_open):
mock_open.side_effect = TimeoutError("timed out") mock_open.side_effect = TimeoutError("timed out")
with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx: with self.assertRaises(RuntimeError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH) gitea_auth.api_request("GET", URL, FAKE_AUTH)
# Fixed message only (#699) — still a RuntimeError subclass. self.assertIn("network error contacting Gitea", str(ctx.exception))
self.assertIsInstance(ctx.exception, RuntimeError)
self.assertEqual(str(ctx.exception), "Network error contacting Gitea")
@patch("gitea_auth.urllib.request.urlopen") @patch("gitea_auth.urllib.request.urlopen")
def test_dns_network_failure_converted(self, mock_open): def test_dns_network_failure_converted(self, mock_open):
mock_open.side_effect = urllib.error.URLError("Name or service not known") mock_open.side_effect = urllib.error.URLError("Name or service not known")
with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx: with self.assertRaises(RuntimeError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH) gitea_auth.api_request("GET", URL, FAKE_AUTH)
self.assertEqual(str(ctx.exception), "Network error contacting Gitea") self.assertIn("network error contacting Gitea", str(ctx.exception))
@patch("gitea_auth.urllib.request.urlopen") @patch("gitea_auth.urllib.request.urlopen")
def test_502_upstream_message(self, mock_open): def test_502_upstream_message(self, mock_open):
mock_open.side_effect = http_error(502, "bad gateway") mock_open.side_effect = http_error(502, "bad gateway")
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx: with self.assertRaises(RuntimeError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH) gitea_auth.api_request("GET", URL, FAKE_AUTH)
msg = str(ctx.exception) msg = str(ctx.exception)
self.assertEqual(msg, "Gitea upstream unavailable") self.assertIn("HTTP 502", msg)
self.assertEqual(ctx.exception.reason_code, "upstream_unavailable") self.assertIn("upstream unavailable", msg)
self.assertEqual(ctx.exception.http_status, 502)
@patch("gitea_auth.urllib.request.urlopen") @patch("gitea_auth.urllib.request.urlopen")
def test_503_upstream_message(self, mock_open): def test_503_upstream_message(self, mock_open):
mock_open.side_effect = http_error(503, "") mock_open.side_effect = http_error(503, "")
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx: with self.assertRaises(RuntimeError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH) gitea_auth.api_request("GET", URL, FAKE_AUTH)
self.assertEqual(str(ctx.exception), "Gitea upstream unavailable") self.assertIn("HTTP 503", str(ctx.exception))
self.assertEqual(ctx.exception.http_status, 503) self.assertIn("upstream unavailable", str(ctx.exception))
@patch("gitea_auth.urllib.request.urlopen") @patch("gitea_auth.urllib.request.urlopen")
def test_malformed_error_payload_does_not_crash(self, mock_open): def test_malformed_error_payload_does_not_crash(self, mock_open):
# Non-JSON garbage error body must still yield a clean typed error # Non-JSON garbage error body must still yield a clean RuntimeError.
# with a fixed message (no body echo — #699).
mock_open.side_effect = http_error(500, "<html>garbage</html>") mock_open.side_effect = http_error(500, "<html>garbage</html>")
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx: with self.assertRaises(RuntimeError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH) gitea_auth.api_request("GET", URL, FAKE_AUTH)
self.assertEqual(str(ctx.exception), "Gitea HTTP request failed") self.assertIn("HTTP 500", str(ctx.exception))
self.assertNotIn("<html>", str(ctx.exception))
@patch("gitea_auth.urllib.request.urlopen") @patch("gitea_auth.urllib.request.urlopen")
def test_malformed_success_json_raises_clean_error(self, mock_open): def test_malformed_success_json_raises_clean_error(self, mock_open):
@@ -131,17 +126,16 @@ class TestApiRequestFailures(unittest.TestCase):
def test_no_secret_leak_in_error_body(self, mock_open): def test_no_secret_leak_in_error_body(self, mock_open):
mock_open.side_effect = http_error( mock_open.side_effect = http_error(
400, "failed: token supersecret123 rejected") 400, "failed: token supersecret123 rejected")
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx: with self.assertRaises(RuntimeError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH) gitea_auth.api_request("GET", URL, FAKE_AUTH)
msg = str(ctx.exception) msg = str(ctx.exception)
self.assertNotIn("supersecret123", msg) self.assertNotIn("supersecret123", msg)
# Fixed message — body never appears (stronger than redaction). self.assertIn(gitea_audit.REDACTED, msg)
self.assertEqual(msg, "Gitea HTTP request failed")
@patch("gitea_auth.urllib.request.urlopen") @patch("gitea_auth.urllib.request.urlopen")
def test_auth_header_never_in_error(self, mock_open): def test_auth_header_never_in_error(self, mock_open):
mock_open.side_effect = http_error(400, "bad request") mock_open.side_effect = http_error(400, "bad request")
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx: with self.assertRaises(RuntimeError) as ctx:
gitea_auth.api_request("GET", URL, FAKE_AUTH) gitea_auth.api_request("GET", URL, FAKE_AUTH)
self.assertNotIn(FAKE_AUTH, str(ctx.exception)) self.assertNotIn(FAKE_AUTH, str(ctx.exception))
@@ -1,3 +1,7 @@
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).""" """Regression tests for gitea_assess_conflict_fix_push structured failures (#519)."""
import os import os
+46 -16
View File
@@ -1,3 +1,7 @@
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). """Tests for Gitea MCP mutating-action audit logging (issue #18).
Covers the pure audit module (redaction, event building, sink writes) and the Covers the pure audit module (redaction, event building, sink writes) and the
@@ -161,11 +165,23 @@ class _AuditWiringBase(unittest.TestCase):
self._dir.cleanup() self._dir.cleanup()
def _env(self, **extra): def _env(self, **extra):
env = {"GITEA_AUDIT_LOG": self.audit_path, # Default: prgs-aligned author with create/close (remote=prgs tests).
"GITEA_PROFILE_NAME": "gitea-author", # Callers override GITEA_MCP_PROFILE for merger/reviewer paths.
"GITEA_ALLOWED_OPERATIONS": ( profile = extra.pop("GITEA_MCP_PROFILE", None) or extra.pop(
"read,merge,gitea.issue.create,gitea.issue.close")} "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.update(extra) env.update(extra)
env["GITEA_MCP_PROFILE"] = profile
return env return env
def _records(self): def _records(self):
@@ -196,7 +212,7 @@ class TestSimpleToolAudit(_AuditWiringBase):
rec = recs[0] rec = recs[0]
self.assertEqual(rec["action"], "create_issue") self.assertEqual(rec["action"], "create_issue")
self.assertEqual(rec["result"], "succeeded") self.assertEqual(rec["result"], "succeeded")
self.assertEqual(rec["profile_name"], "gitea-author") self.assertIn(rec["profile_name"], ("gitea-author", "test-author-prgs", "prgs-author"))
self.assertEqual(rec["authenticated_username"], "author-bot") self.assertEqual(rec["authenticated_username"], "author-bot")
self.assertEqual(rec["issue_number"], 11) self.assertEqual(rec["issue_number"], 11)
self.assertEqual(rec["request_metadata"]["title"], "Add thing") self.assertEqual(rec["request_metadata"]["title"], "Add thing")
@@ -239,10 +255,11 @@ class TestSimpleToolAudit(_AuditWiringBase):
def test_disabled_writes_nothing_and_no_extra_call(self, _auth, _get_all, mock_api, _role): 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. # 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"} mock_api.return_value = {"number": 1, "html_url": "http://x/1"}
with patch.dict(os.environ, { with patch.dict(
"GITEA_PROFILE_NAME": "gitea-author", os.environ,
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create", shared_mutation_env("test-author-prgs"),
}, clear=True): clear=True,
):
gitea_create_issue(title="x", remote="prgs") gitea_create_issue(title="x", remote="prgs")
issue_posts = [ issue_posts = [
c for c in mock_api.call_args_list if c.args[0] == "POST" c for c in mock_api.call_args_list if c.args[0] == "POST"
@@ -342,8 +359,7 @@ class TestGatedToolAudit(_AuditWiringBase):
self._pr("author-bot"), approval, # gate 7 feedback self._pr("author-bot"), approval, # gate 7 feedback
{}, {"merged_commit_sha": "c1"}, {}, {"merged_commit_sha": "c1"},
] ]
env = self._env(GITEA_PROFILE_NAME="gitea-merger", env = self._env(GITEA_MCP_PROFILE="test-merger-prgs")
GITEA_ALLOWED_OPERATIONS="read,merge")
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=True):
mcp_server.gitea_load_review_workflow() mcp_server.gitea_load_review_workflow()
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8",
@@ -362,8 +378,7 @@ class TestGatedToolAudit(_AuditWiringBase):
def test_merge_blocked_audited(self, _auth, mock_api): def test_merge_blocked_audited(self, _auth, mock_api):
# Self-author merge is blocked; must still be recorded as blocked. # Self-author merge is blocked; must still be recorded as blocked.
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")] mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
env = self._env(GITEA_PROFILE_NAME="gitea-merger", env = self._env(GITEA_MCP_PROFILE="test-merger-prgs")
GITEA_ALLOWED_OPERATIONS="read,merge")
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=True):
mcp_server.gitea_load_review_workflow() mcp_server.gitea_load_review_workflow()
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", remote="prgs") r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", remote="prgs")
@@ -385,11 +400,23 @@ class TestGatedToolAudit(_AuditWiringBase):
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED", [{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}], "submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
] ]
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer", env = self._env(GITEA_MCP_PROFILE="test-reviewer-prgs")
GITEA_ALLOWED_OPERATIONS="read,review,approve")
with patch.dict(os.environ, env, clear=True): 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 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( gitea_mark_final_review_decision(
8, "approve", expected_head_sha="abc123", remote="prgs", 8, "approve", expected_head_sha="abc123", remote="prgs",
) )
@@ -398,7 +425,10 @@ class TestGatedToolAudit(_AuditWiringBase):
body="LGTM", remote="prgs", body="LGTM", remote="prgs",
final_review_decision_ready=True, final_review_decision_ready=True,
) )
self.assertTrue(r["performed"]) self.assertTrue(
r["performed"],
msg=f"submit_pr_review blocked: {r}",
)
recs = self._records() recs = self._records()
self.assertEqual(len(recs), 1) self.assertEqual(len(recs), 1)
self.assertEqual(recs[0]["action"], "submit_pr_review") self.assertEqual(recs[0]["action"], "submit_pr_review")
+1 -7
View File
@@ -27,13 +27,7 @@ from task_capability_map import required_permission, required_role
DELETE_PROFILE = { DELETE_PROFILE = {
"profile_name": "prgs-author-delete", "profile_name": "prgs-author-delete",
"role": "author", "allowed_operations": ["gitea.read", "gitea.branch.delete"],
"allowed_operations": [
"gitea.read",
"gitea.pr.create",
"gitea.branch.push",
"gitea.branch.delete",
],
"forbidden_operations": [], "forbidden_operations": [],
"audit_label": "prgs-author-delete", "audit_label": "prgs-author-delete",
} }
File diff suppressed because it is too large Load Diff
+3
View File
@@ -29,6 +29,7 @@ CONFIG = {
"allowed_operations": ["gitea.read", "gitea.repo.commit"], "allowed_operations": ["gitea.read", "gitea.repo.commit"],
"forbidden_operations": ["gitea.pr.create"], "forbidden_operations": ["gitea.pr.create"],
"execution_profile": "commit-author", "execution_profile": "commit-author",
"allowed_repositories": ["Example-Org/Example-Repo"],
}, },
"pr-only-author": { "pr-only-author": {
"enabled": True, "enabled": True,
@@ -39,6 +40,7 @@ CONFIG = {
"allowed_operations": ["gitea.read", "gitea.pr.create"], "allowed_operations": ["gitea.read", "gitea.pr.create"],
"forbidden_operations": ["gitea.repo.commit"], "forbidden_operations": ["gitea.repo.commit"],
"execution_profile": "pr-only-author", "execution_profile": "pr-only-author",
"allowed_repositories": ["Example-Org/Example-Repo"],
}, },
"reviewer-profile": { "reviewer-profile": {
"enabled": True, "enabled": True,
@@ -51,6 +53,7 @@ CONFIG = {
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push", "gitea.repo.commit", "gitea.pr.create", "gitea.branch.push",
], ],
"execution_profile": "reviewer-profile", "execution_profile": "reviewer-profile",
"allowed_repositories": ["Example-Org/Example-Repo"],
}, },
}, },
"rules": {"allow_runtime_switching": False}, "rules": {"allow_runtime_switching": False},
+2
View File
@@ -35,6 +35,7 @@ CONFIG = {
], ],
"forbidden_operations": [], "forbidden_operations": [],
"execution_profile": "full-author", "execution_profile": "full-author",
"allowed_repositories": ["Example-Org/Example-Repo"],
}, },
"reviewer-no-commit": { "reviewer-no-commit": {
"enabled": True, "enabled": True,
@@ -49,6 +50,7 @@ CONFIG = {
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push" "gitea.repo.commit", "gitea.pr.create", "gitea.branch.push"
], ],
"execution_profile": "reviewer-no-commit", "execution_profile": "reviewer-no-commit",
"allowed_repositories": ["Example-Org/Example-Repo"],
}, },
}, },
"rules": {"allow_runtime_switching": False}, "rules": {"allow_runtime_switching": False},
+4
View File
@@ -35,6 +35,7 @@ CONFIG = {
"gitea.pr.review", "gitea.pr.review",
], ],
"execution_profile": "full-author", "execution_profile": "full-author",
"allowed_repositories": ["Example-Org/Example-Repo"],
}, },
}, },
} }
@@ -227,6 +228,7 @@ class TestCommitPayloads(unittest.TestCase):
@patch("mcp_server.api_request") @patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass") @patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_traversal_blocked(self, _auth, mock_api): 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 # Remove active lock file to ensure it fails on traversal/invalid locks
os.remove(self.lock_file_path) os.remove(self.lock_file_path)
@@ -248,6 +250,7 @@ class TestCommitPayloads(unittest.TestCase):
@patch("mcp_server.api_request") @patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass") @patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_outside_scope_blocked(self, _auth, mock_api): 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 patch.dict(os.environ, self._env("full-author"), clear=True):
with self.assertRaises(ValueError) as ctx: with self.assertRaises(ValueError) as ctx:
mcp_server.gitea_commit_files( mcp_server.gitea_commit_files(
@@ -266,6 +269,7 @@ class TestCommitPayloads(unittest.TestCase):
@patch("mcp_server.api_request") @patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass") @patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_multiple_sources_blocked(self, _auth, mock_api): 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 patch.dict(os.environ, self._env("full-author"), clear=True):
with self.assertRaises(ValueError) as ctx: with self.assertRaises(ValueError) as ctx:
mcp_server.gitea_commit_files( mcp_server.gitea_commit_files(
+4
View File
@@ -1,3 +1,7 @@
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 """Tests for canonical JSON runtime-profile configuration (gitea_config) and
its integration into gitea_auth.get_profile / get_auth_header. its integration into gitea_auth.get_profile / get_auth_header.
+23 -2
View File
@@ -1,3 +1,4 @@
import os
"""Tests for create_issue.py. """Tests for create_issue.py.
Every test mocks auth functions so no real network calls or keychain Every test mocks auth functions so no real network calls or keychain
@@ -12,7 +13,7 @@ import sys
import tempfile import tempfile
import unittest import unittest
import contextlib import contextlib
from unittest.mock import MagicMock, patch from unittest.mock import patch, MagicMock, patch
# The module under test lives in the repo root, not a package. # The module under test lives in the repo root, not a package.
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
@@ -92,6 +93,26 @@ class TestRemoteResolution(unittest.TestCase):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@unittest.skipIf(_SKIP, _REASON) @unittest.skipIf(_SKIP, _REASON)
class TestAPIPayload(unittest.TestCase): 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.""" """Ensure the JSON payload sent to Gitea is correct."""
@patch("create_issue.get_credentials", return_value=FAKE_CREDS) @patch("create_issue.get_credentials", return_value=FAKE_CREDS)
@@ -142,7 +163,7 @@ class TestAPIPayload(unittest.TestCase):
url = mock_api.call_args[0][1] url = mock_api.call_args[0][1]
self.assertEqual( self.assertEqual(
url, url,
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/issues", "https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools/issues",
) )
+23 -2
View File
@@ -1,3 +1,4 @@
import os
"""Tests for create_pr.py. """Tests for create_pr.py.
Every test mocks `get_credentials` and `urllib.request.urlopen` so no real Every test mocks `get_credentials` and `urllib.request.urlopen` so no real
@@ -8,7 +9,7 @@ import json
import sys import sys
import unittest import unittest
import contextlib import contextlib
from unittest.mock import MagicMock, patch from unittest.mock import patch, MagicMock, patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import create_pr # noqa: E402 import create_pr # noqa: E402
@@ -74,6 +75,26 @@ class TestRemoteResolution(unittest.TestCase):
# API payload # API payload
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestAPIPayload(unittest.TestCase): 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) @patch("create_pr.get_credentials", return_value=FAKE_CREDS)
def test_payload_fields(self, _cred): def test_payload_fields(self, _cred):
@@ -113,7 +134,7 @@ class TestAPIPayload(unittest.TestCase):
url = MockReq.call_args[0][0] url = MockReq.call_args[0][0]
self.assertEqual( self.assertEqual(
url, url,
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/pulls", "https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools/pulls",
) )
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,509 @@
"""#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()
@@ -0,0 +1,608 @@
"""#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()
@@ -1,3 +1,7 @@
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 os
import sys import sys
import unittest import unittest
+8 -3
View File
@@ -1,3 +1,7 @@
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).""" """Tests for the pre-create issue content gate (Issue #582)."""
import sys import sys
import unittest import unittest
@@ -15,9 +19,10 @@ from issue_content_gate import ( # noqa: E402
from mcp_server import gitea_create_issue # noqa: E402 from mcp_server import gitea_create_issue # noqa: E402
FAKE_AUTH = "Basic dGVzdDp0ZXN0" FAKE_AUTH = "Basic dGVzdDp0ZXN0"
ISSUE_WRITE_ENV = { ISSUE_WRITE_ENV = shared_mutation_env(
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create", "test-author-prgs",
} GITEA_ALLOWED_OPERATIONS="gitea.issue.create",
)
class TestNormalizeAndPointers(unittest.TestCase): class TestNormalizeAndPointers(unittest.TestCase):
+9 -4
View File
@@ -1,3 +1,7 @@
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).""" """Tests for pre-create issue duplicate gate (Issue #207)."""
import sys import sys
import unittest import unittest
@@ -19,9 +23,10 @@ from mcp_server import gitea_create_issue # noqa: E402
from review_proofs import assess_duplicate_search_proof as proof_assess # noqa: E402 from review_proofs import assess_duplicate_search_proof as proof_assess # noqa: E402
FAKE_AUTH = "Basic dGVzdDp0ZXN0" FAKE_AUTH = "Basic dGVzdDp0ZXN0"
ISSUE_WRITE_ENV = { ISSUE_WRITE_ENV = shared_mutation_env(
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create", "test-author-prgs",
} GITEA_ALLOWED_OPERATIONS="gitea.issue.create",
)
CANONICAL_TITLE = ( CANONICAL_TITLE = (
"Add hard queue-target resolution wall before PR inventory " "Add hard queue-target resolution wall before PR inventory "
"or empty-queue claims" "or empty-queue claims"
@@ -162,7 +167,7 @@ class TestCreateIssueMCPGate(unittest.TestCase):
mock_role_check.return_value = (True, []) mock_role_check.return_value = (True, [])
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"} 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): with patch.dict(__import__("os").environ, ISSUE_WRITE_ENV, clear=True):
result = gitea_create_issue(title="Unique new issue", body="body text") result = gitea_create_issue(title="Unique new issue", body="body text", remote="prgs")
self.assertEqual(result["number"], 1) self.assertEqual(result["number"], 1)
+24 -5
View File
@@ -1,3 +1,7 @@
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).""" """Tests for early duplicate-work detection (#400)."""
import os import os
import sys import sys
@@ -144,10 +148,12 @@ class TestInjectableDuplicateFetcher(unittest.TestCase):
}, },
): ):
with tempfile.TemporaryDirectory() as lock_dir: with tempfile.TemporaryDirectory() as lock_dir:
with patch.dict(os.environ, { env = shared_mutation_env(
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment", "test-author-prgs",
"GITEA_ISSUE_LOCK_DIR": lock_dir, include_example_repo=True,
}, clear=True): GITEA_ISSUE_LOCK_DIR=lock_dir,
)
with patch.dict(os.environ, env, clear=True):
mcp_server.gitea_lock_issue( mcp_server.gitea_lock_issue(
issue_number=400, issue_number=400,
branch_name="feat/issue-400-duplicate-work-preflight", branch_name="feat/issue-400-duplicate-work-preflight",
@@ -161,7 +167,11 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
self._dir = tempfile.TemporaryDirectory() self._dir = tempfile.TemporaryDirectory()
self._env_patch = patch.dict( self._env_patch = patch.dict(
os.environ, os.environ,
{"GITEA_ISSUE_LOCK_DIR": self._dir.name}, shared_mutation_env(
"test-author-prgs",
include_example_repo=True,
GITEA_ISSUE_LOCK_DIR=self._dir.name,
),
clear=False, clear=False,
) )
self._env_patch.start() self._env_patch.start()
@@ -171,6 +181,11 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
}) })
self._remotes.start() self._remotes.start()
mcp_server._IDENTITY_CACHE.clear() mcp_server._IDENTITY_CACHE.clear()
try:
import session_context_binding as _sc
_sc._reset_session_context_for_testing()
except Exception:
pass
def tearDown(self): def tearDown(self):
patch.stopall() patch.stopall()
@@ -205,6 +220,8 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
"allowed_operations": ["gitea.read", "gitea.repo.commit"], "allowed_operations": ["gitea.read", "gitea.repo.commit"],
"forbidden_operations": [], "forbidden_operations": [],
"audit_label": "test-author", "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") @patch("mcp_server.get_auth_header", return_value="token x")
def test_commit_files_blocked_on_recheck(self, _auth, _profile, mock_gate): def test_commit_files_blocked_on_recheck(self, _auth, _profile, mock_gate):
@@ -239,6 +256,8 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
"allowed_operations": ["gitea.read", "gitea.pr.create"], "allowed_operations": ["gitea.read", "gitea.pr.create"],
"forbidden_operations": [], "forbidden_operations": [],
"audit_label": "test-author", "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") @patch("mcp_server.get_auth_header", return_value="token x")
def test_create_pr_returns_handoff_on_duplicate(self, _auth, _profile, mock_gate): def test_create_pr_returns_handoff_on_duplicate(self, _auth, _profile, mock_gate):
+6
View File
@@ -1,3 +1,8 @@
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).""" """Regression tests: issue-write tool gates match gitea_resolve_task_capability (#69)."""
import json import json
import os import os
@@ -41,6 +46,7 @@ CONFIG = {
"gitea.read", "gitea.issue.create", "gitea.issue.close", "gitea.read", "gitea.issue.create", "gitea.issue.close",
"gitea.issue.comment"], "gitea.issue.comment"],
"forbidden_operations": [], "forbidden_operations": [],
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "Example-Org/Example-Repo", "913443/eAgenda"],
"execution_profile": "full-author", "execution_profile": "full-author",
}, },
}, },
+4
View File
@@ -1,3 +1,7 @@
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). """Negative tests for LLM-Agent-SHA attribution (#86, Phase 0).
``LLM-Agent-SHA`` (docs/llm-agent-sha.md) is attribution metadata ONLY. These ``LLM-Agent-SHA`` (docs/llm-agent-sha.md) is attribution metadata ONLY. These
+14 -12
View File
@@ -1,7 +1,11 @@
"""Regression tests for gitea_lock_issue MCP tool registration (#521)."""
from __future__ import annotations 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 os
import tempfile import tempfile
import unittest import unittest
@@ -13,22 +17,20 @@ from mcp_server import gitea_create_pr, gitea_lock_issue
FAKE_AUTH = "Basic dGVzdDp0ZXN0" FAKE_AUTH = "Basic dGVzdDp0ZXN0"
ISSUE_WRITE_ENV = { ISSUE_WRITE_ENV = shared_mutation_env(
"GITEA_ALLOWED_OPERATIONS": ( "test-author-prgs",
"gitea.issue.create,gitea.issue.close,gitea.issue.comment" )
),
}
CREATE_PR_ENV = { CREATE_PR_ENV = shared_mutation_env(
"GITEA_PROFILE_NAME": "author-test", "test-author-prgs",
"GITEA_ALLOWED_OPERATIONS": ( GITEA_ALLOWED_OPERATIONS=(
"gitea.read,gitea.pr.create,gitea.branch.push," "gitea.read,gitea.pr.create,gitea.branch.push,"
"gitea.issue.create,gitea.issue.close,gitea.issue.comment" "gitea.issue.create,gitea.issue.close,gitea.issue.comment"
), ),
"GITEA_FORBIDDEN_OPERATIONS": ( GITEA_FORBIDDEN_OPERATIONS=(
"gitea.pr.approve,gitea.pr.merge,gitea.pr.review" "gitea.pr.approve,gitea.pr.merge,gitea.pr.review"
), ),
} )
def _clean_master_git_state_for_lock(): def _clean_master_git_state_for_lock():
+152 -112
View File
@@ -1,3 +1,11 @@
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. """Tests for the MCP server tool functions.
Each tool is tested by calling the underlying function directly (not through Each tool is tested by calling the underlying function directly (not through
@@ -47,6 +55,7 @@ from gitea_auth import get_profile # noqa: E402
import gitea_config # noqa: E402 import gitea_config # noqa: E402
import mcp_server import mcp_server
install_deterministic_remote_urls()
import issue_lock_store import issue_lock_store
import gitea_auth import gitea_auth
@@ -221,22 +230,26 @@ def _seed_ready_review_decision(
# Issue-write tools are profile-gated (#69). # Issue-write tools are profile-gated (#69).
ISSUE_WRITE_ENV = { # Default remote is dadeschools — use the host-aligned config profile so
"GITEA_ALLOWED_OPERATIONS": ( # cross-host session binding does not fail closed (#714).
"gitea.issue.create,gitea.issue.close,gitea.issue.comment" 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). # create_pr is gated on author profile + namespace (#69, #209).
CREATE_PR_ENV = { # Default remote is dadeschools; use matching config-backed author profile.
"GITEA_PROFILE_NAME": "author-test", CREATE_PR_ENV = shared_mutation_env(
"GITEA_ALLOWED_OPERATIONS": ( "test-author-dadeschools",
"gitea.read,gitea.pr.create,gitea.branch.push" 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",
"gitea.pr.approve,gitea.pr.merge,gitea.pr.review" )
),
}
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json" ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
@@ -287,6 +300,7 @@ def _bind_test_lock(**overrides) -> str:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Create Issue # Create Issue
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestCreateIssue(unittest.TestCase): class TestCreateIssue(unittest.TestCase):
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
@@ -296,7 +310,7 @@ class TestCreateIssue(unittest.TestCase):
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_creates_issue(self, _auth, _get_all, mock_api, _role): 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"} mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True): with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
result = gitea_create_issue(title="Test issue", body="body text") result = gitea_create_issue(title="Test issue", body="body text")
self.assertEqual(result["number"], 1) self.assertEqual(result["number"], 1)
self.assertNotIn("url", result) self.assertNotIn("url", result)
@@ -314,7 +328,7 @@ class TestCreateIssue(unittest.TestCase):
def test_create_issue_reveal_opt_in_includes_url(self, _auth, _get_all, mock_api, _role): 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"} mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
env = {**ISSUE_WRITE_ENV, "GITEA_MCP_REVEAL_ENDPOINTS": "1"} env = {**ISSUE_WRITE_ENV, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
result = gitea_create_issue(title="Test issue", body="body text") result = gitea_create_issue(title="Test issue", body="body text")
self.assertIn("issues/1", result["url"]) self.assertIn("issues/1", result["url"])
@@ -325,7 +339,18 @@ class TestCreateIssue(unittest.TestCase):
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_creates_on_prgs(self, _auth, _get_all, mock_api, _role): 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"} mock_api.return_value = {"number": 5, "html_url": "https://gitea.prgs.cc/issues/5"}
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True): 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
result = gitea_create_issue(title="Test", remote="prgs") result = gitea_create_issue(title="Test", remote="prgs")
self.assertEqual(result["number"], 5) self.assertEqual(result["number"], 5)
url = mock_api.call_args[0][1] url = mock_api.call_args[0][1]
@@ -338,7 +363,7 @@ class TestCreateIssue(unittest.TestCase):
@patch("mcp_server.api_get_all", return_value=[]) @patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @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): def test_create_issue_required_workflow_labels_blocks(self, _auth, _get_all, mock_api, _role):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True): with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
result = gitea_create_issue( result = gitea_create_issue(
title="Test issue", title="Test issue",
require_workflow_labels=True, require_workflow_labels=True,
@@ -378,7 +403,7 @@ class TestCreatePR(unittest.TestCase):
mock_api.side_effect = api_side_effect mock_api.side_effect = api_side_effect
with tempfile.TemporaryDirectory() as lock_dir: with tempfile.TemporaryDirectory() as lock_dir:
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir} env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree) _bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
result = gitea_create_pr( result = gitea_create_pr(
title="feat: X Closes #123", title="feat: X Closes #123",
@@ -421,7 +446,7 @@ class TestCreatePR(unittest.TestCase):
mock_api.side_effect = api_side_effect mock_api.side_effect = api_side_effect
with tempfile.TemporaryDirectory() as lock_dir: with tempfile.TemporaryDirectory() as lock_dir:
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"} env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree) _bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
result = gitea_create_pr( result = gitea_create_pr(
title="feat: X Closes #123", title="feat: X Closes #123",
@@ -447,7 +472,7 @@ class TestCreatePR(unittest.TestCase):
mock_api.return_value = _issue_with_labels("type:feature", "status:in-progress") mock_api.return_value = _issue_with_labels("type:feature", "status:in-progress")
with tempfile.TemporaryDirectory() as lock_dir: with tempfile.TemporaryDirectory() as lock_dir:
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir} env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree) _bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
result = gitea_create_pr( result = gitea_create_pr(
title="feat: X Closes #123", title="feat: X Closes #123",
@@ -470,7 +495,7 @@ class TestCreatePR(unittest.TestCase):
worktree = os.path.realpath(os.getcwd()) worktree = os.path.realpath(os.getcwd())
with tempfile.TemporaryDirectory() as lock_dir: with tempfile.TemporaryDirectory() as lock_dir:
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir} env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
_bind_test_lock( _bind_test_lock(
issue_number=123, issue_number=123,
branch_name="feat/x", branch_name="feat/x",
@@ -495,7 +520,7 @@ class TestCloseIssue(unittest.TestCase):
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_closes_issue(self, _auth, mock_api): def test_closes_issue(self, _auth, mock_api):
mock_api.return_value = {"state": "closed"} mock_api.return_value = {"state": "closed"}
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True): with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
result = gitea_close_issue(issue_number=42) result = gitea_close_issue(issue_number=42)
self.assertTrue(result["success"]) self.assertTrue(result["success"])
self.assertIn("42", result["message"]) self.assertIn("42", result["message"])
@@ -601,7 +626,7 @@ class TestMarkIssue(unittest.TestCase):
return {} return {}
mock_api.side_effect = api_side_effect mock_api.side_effect = api_side_effect
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True): with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
result = gitea_mark_issue(issue_number=5, action="start") result = gitea_mark_issue(issue_number=5, action="start")
self.assertTrue(result["success"]) self.assertTrue(result["success"])
self.assertIn("claimed", result["message"]) self.assertIn("claimed", result["message"])
@@ -616,7 +641,7 @@ class TestMarkIssue(unittest.TestCase):
mock_api.side_effect = [ mock_api.side_effect = [
None, None,
] ]
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True): with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
result = gitea_mark_issue(issue_number=5, action="done") result = gitea_mark_issue(issue_number=5, action="done")
self.assertTrue(result["success"]) self.assertTrue(result["success"])
self.assertIn("released", result["message"]) self.assertIn("released", result["message"])
@@ -629,7 +654,7 @@ class TestMarkIssue(unittest.TestCase):
@patch("mcp_server.api_request") @patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_missing_label_raises(self, _auth, mock_api, _labels): def test_missing_label_raises(self, _auth, mock_api, _labels):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True): with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
gitea_mark_issue(issue_number=5, action="start") gitea_mark_issue(issue_number=5, action="start")
@@ -641,12 +666,12 @@ class TestAuthErrors(unittest.TestCase):
@patch("mcp_server.get_auth_header", return_value=None) @patch("mcp_server.get_auth_header", return_value=None)
def test_no_credentials_raises(self, _auth): def test_no_credentials_raises(self, _auth):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True): with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
gitea_create_issue(title="test") gitea_create_issue(title="test")
def test_unknown_remote_raises(self): def test_unknown_remote_raises(self):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True): with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
gitea_create_issue(title="test", remote="nonexistent") gitea_create_issue(title="test", remote="nonexistent")
@@ -860,7 +885,7 @@ class TestMergePR(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr( r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), pr_number=8, confirmation=self._confirm(8),
expected_head_sha="abc123", do="squash", expected_head_sha="abc123", do="squash",
@@ -893,7 +918,7 @@ class TestMergePR(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr( r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), pr_number=8, confirmation=self._confirm(8),
expected_changed_files=["b.py", "a.py"], expected_changed_files=["b.py", "a.py"],
@@ -914,7 +939,7 @@ class TestMergePR(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr( r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), pr_number=8, confirmation=self._confirm(8),
expected_head_sha="abc123", remote="prgs", expected_head_sha="abc123", remote="prgs",
@@ -945,7 +970,7 @@ class TestMergePR(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr( r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), pr_number=8, confirmation=self._confirm(8),
expected_head_sha="abc123", remote="prgs", expected_head_sha="abc123", remote="prgs",
@@ -962,7 +987,7 @@ class TestMergePR(unittest.TestCase):
def test_missing_confirmation_blocks_with_no_api_call(self, _auth, mock_api): def test_missing_confirmation_blocks_with_no_api_call(self, _auth, mock_api):
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(pr_number=8, confirmation="", expected_head_sha="abc123", remote="prgs") r = gitea_merge_pr(pr_number=8, confirmation="", expected_head_sha="abc123", remote="prgs")
self.assertFalse(r["performed"]) self.assertFalse(r["performed"])
self.assertTrue(any("explicit confirmation required" in x for x in r["reasons"])) self.assertTrue(any("explicit confirmation required" in x for x in r["reasons"]))
@@ -973,7 +998,7 @@ class TestMergePR(unittest.TestCase):
def test_wrong_confirmation_blocks(self, _auth, mock_api): def test_wrong_confirmation_blocks(self, _auth, mock_api):
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 9", expected_head_sha="abc123", remote="prgs") r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 9", expected_head_sha="abc123", remote="prgs")
self.assertFalse(r["performed"]) self.assertFalse(r["performed"])
mock_api.assert_not_called() mock_api.assert_not_called()
@@ -985,7 +1010,7 @@ class TestMergePR(unittest.TestCase):
def test_reviewer_profile_cannot_merge(self, _auth, mock_api): def test_reviewer_profile_cannot_merge(self, _auth, mock_api):
env = {"GITEA_PROFILE_NAME": "prgs-reviewer", env = {"GITEA_PROFILE_NAME": "prgs-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,approve"} "GITEA_ALLOWED_OPERATIONS": "read,approve"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
with self.assertRaises(RuntimeError) as ctx: with self.assertRaises(RuntimeError) as ctx:
gitea_merge_pr( gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs" pr_number=8, confirmation=self._confirm(8), remote="prgs"
@@ -999,7 +1024,7 @@ class TestMergePR(unittest.TestCase):
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")] mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr( r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs") pr_number=8, confirmation=self._confirm(8), remote="prgs")
self.assertFalse(r["performed"]) self.assertFalse(r["performed"])
@@ -1010,7 +1035,7 @@ class TestMergePR(unittest.TestCase):
def test_unknown_identity_blocks(self, _auth): def test_unknown_identity_blocks(self, _auth):
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr( r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs") pr_number=8, confirmation=self._confirm(8), remote="prgs")
self.assertFalse(r["performed"]) self.assertFalse(r["performed"])
@@ -1022,7 +1047,7 @@ class TestMergePR(unittest.TestCase):
def test_unknown_profile_blocks(self, _auth, mock_api): def test_unknown_profile_blocks(self, _auth, mock_api):
mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")] mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")]
env = {} # no GITEA_ALLOWED_OPERATIONS → empty allowed ops env = {} # no GITEA_ALLOWED_OPERATIONS → empty allowed ops
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr( r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs") pr_number=8, confirmation=self._confirm(8), remote="prgs")
self.assertFalse(r["performed"]) self.assertFalse(r["performed"])
@@ -1094,7 +1119,7 @@ class TestMergePR(unittest.TestCase):
def test_profile_without_merge_permission_blocks(self, _auth, mock_api): def test_profile_without_merge_permission_blocks(self, _auth, mock_api):
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"} "GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
with self.assertRaises(RuntimeError) as ctx: with self.assertRaises(RuntimeError) as ctx:
gitea_merge_pr( gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs") pr_number=8, confirmation=self._confirm(8), remote="prgs")
@@ -1110,7 +1135,7 @@ class TestMergePR(unittest.TestCase):
{"login": "merger-bot"}, self._pr("author-bot", state="closed")] {"login": "merger-bot"}, self._pr("author-bot", state="closed")]
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr( r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs") pr_number=8, confirmation=self._confirm(8), remote="prgs")
self.assertFalse(r["performed"]) self.assertFalse(r["performed"])
@@ -1124,7 +1149,7 @@ class TestMergePR(unittest.TestCase):
{"login": "merger-bot"}, self._pr("author-bot", mergeable=False)] {"login": "merger-bot"}, self._pr("author-bot", mergeable=False)]
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr( r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs") pr_number=8, confirmation=self._confirm(8), remote="prgs")
self.assertFalse(r["performed"]) self.assertFalse(r["performed"])
@@ -1138,7 +1163,7 @@ class TestMergePR(unittest.TestCase):
{"login": "merger-bot"}, self._pr("author-bot", mergeable=None)] {"login": "merger-bot"}, self._pr("author-bot", mergeable=None)]
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr( r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs") pr_number=8, confirmation=self._confirm(8), remote="prgs")
self.assertFalse(r["performed"]) self.assertFalse(r["performed"])
@@ -1156,7 +1181,7 @@ class TestMergePR(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr( r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), pr_number=8, confirmation=self._confirm(8),
expected_head_sha="deadbeef", remote="prgs") expected_head_sha="deadbeef", remote="prgs")
@@ -1177,7 +1202,7 @@ class TestMergePR(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr( r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), pr_number=8, confirmation=self._confirm(8),
expected_changed_files=["a.py", "b.py"], expected_changed_files=["a.py", "b.py"],
@@ -1210,7 +1235,7 @@ class TestMergePR(unittest.TestCase):
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge", "GITEA_ALLOWED_OPERATIONS": "read,merge",
"GITEA_TOKEN": "super-secret-token"} "GITEA_TOKEN": "super-secret-token"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr( r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), pr_number=8, confirmation=self._confirm(8),
expected_head_sha="abc123", remote="prgs", expected_head_sha="abc123", remote="prgs",
@@ -1229,7 +1254,7 @@ class TestMergePR(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr( r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), pr_number=8, confirmation=self._confirm(8),
expected_head_sha="abc123", remote="prgs", expected_head_sha="abc123", remote="prgs",
@@ -1252,7 +1277,7 @@ class TestMergePR(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr( r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs", pr_number=8, confirmation=self._confirm(8), remote="prgs",
expected_head_sha=new_sha) expected_head_sha=new_sha)
@@ -1275,7 +1300,7 @@ class TestMergePR(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr( r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), pr_number=8, confirmation=self._confirm(8),
expected_head_sha="abc123", remote="prgs", expected_head_sha="abc123", remote="prgs",
@@ -1307,7 +1332,7 @@ class TestMergePR(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_merge_pr( r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), pr_number=8, confirmation=self._confirm(8),
expected_head_sha="abc123", remote="prgs", expected_head_sha="abc123", remote="prgs",
@@ -1446,16 +1471,10 @@ class TestReviewPR(unittest.TestCase):
class TestDeleteBranch(unittest.TestCase): class TestDeleteBranch(unittest.TestCase):
DELETE_PROFILE = { DELETE_PROFILE = {
"profile_name": "test-author-deleter", "profile_name": "test-deleter",
"role": "author", "allowed_operations": ["gitea.read", "gitea.branch.delete"],
"allowed_operations": [
"gitea.read",
"gitea.pr.create",
"gitea.branch.push",
"gitea.branch.delete",
],
"forbidden_operations": [], "forbidden_operations": [],
"audit_label": "test-author-deleter", "audit_label": "test-deleter",
} }
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE) @patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
@@ -1620,10 +1639,8 @@ class TestCommitFiles(unittest.TestCase):
files = [ files = [
{"operation": "create", "path": "test.txt", "content": "SGVsbG8="} {"operation": "create", "path": "test.txt", "content": "SGVsbG8="}
] ]
env = { env = shared_mutation_env("test-author-dadeschools")
"GITEA_ALLOWED_OPERATIONS": "gitea.repo.commit", with patch.dict(os.environ, env, clear=False):
}
with patch.dict(os.environ, env, clear=True):
result = gitea_commit_files( result = gitea_commit_files(
files=files, files=files,
message="Initial commit", message="Initial commit",
@@ -1738,7 +1755,7 @@ class TestRuntimeProfile(unittest.TestCase):
"GITEA_ALLOWED_OPERATIONS": "read, review , approve", "GITEA_ALLOWED_OPERATIONS": "read, review , approve",
"GITEA_BASE_URL": "https://gitea.example.invalid", "GITEA_BASE_URL": "https://gitea.example.invalid",
} }
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
p = get_profile() p = get_profile()
self.assertEqual(p["profile_name"], "gitea-reviewer") self.assertEqual(p["profile_name"], "gitea-reviewer")
self.assertEqual(p["allowed_operations"], ["read", "review", "approve"]) self.assertEqual(p["allowed_operations"], ["read", "review", "approve"])
@@ -1749,7 +1766,7 @@ class TestRuntimeProfile(unittest.TestCase):
"GITEA_PROFILE_NAME": "gitea-author", "GITEA_PROFILE_NAME": "gitea-author",
"GITEA_TOKEN": "super-secret-token", "GITEA_TOKEN": "super-secret-token",
} }
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
p = get_profile() p = get_profile()
blob = repr(p).lower() blob = repr(p).lower()
# The token VALUE must never appear. (The field name # The token VALUE must never appear. (The field name
@@ -1766,7 +1783,7 @@ class TestRuntimeProfile(unittest.TestCase):
"GITEA_ALLOWED_OPERATIONS": "read,review,approve", "GITEA_ALLOWED_OPERATIONS": "read,review,approve",
"GITEA_TOKEN": "super-secret-token", "GITEA_TOKEN": "super-secret-token",
} }
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
result = gitea_whoami(remote="prgs") result = gitea_whoami(remote="prgs")
self.assertEqual(result["profile"]["profile_name"], "gitea-reviewer") self.assertEqual(result["profile"]["profile_name"], "gitea-reviewer")
self.assertEqual( self.assertEqual(
@@ -1787,7 +1804,7 @@ class TestRuntimeProfile(unittest.TestCase):
"GITEA_AUDIT_LABEL": "reviewer-runtime", "GITEA_AUDIT_LABEL": "reviewer-runtime",
"GITEA_TOKEN_SOURCE": "keychain:prgs-reviewer-token", "GITEA_TOKEN_SOURCE": "keychain:prgs-reviewer-token",
} }
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
result = gitea_whoami(remote="prgs") result = gitea_whoami(remote="prgs")
profile = result["profile"] profile = result["profile"]
self.assertEqual(profile["environment"], None) self.assertEqual(profile["environment"], None)
@@ -1856,7 +1873,7 @@ class TestProfileDiscovery(unittest.TestCase):
"GITEA_AUDIT_LABEL": "reviewer-runtime", "GITEA_AUDIT_LABEL": "reviewer-runtime",
"GITEA_TOKEN_SOURCE": "GITEA_TOKEN", "GITEA_TOKEN_SOURCE": "GITEA_TOKEN",
} }
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
p = get_profile() p = get_profile()
self.assertEqual(p["forbidden_operations"], ["merge", "branch.push"]) self.assertEqual(p["forbidden_operations"], ["merge", "branch.push"])
self.assertEqual(p["audit_label"], "reviewer-runtime") self.assertEqual(p["audit_label"], "reviewer-runtime")
@@ -1872,7 +1889,7 @@ class TestProfileDiscovery(unittest.TestCase):
"GITEA_TOKEN_SOURCE": "GITEA_TOKEN", "GITEA_TOKEN_SOURCE": "GITEA_TOKEN",
"GITEA_TOKEN": "super-secret-token", "GITEA_TOKEN": "super-secret-token",
} }
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
result = gitea_get_profile(remote="prgs") result = gitea_get_profile(remote="prgs")
self.assertEqual(result["profile_name"], "gitea-reviewer") self.assertEqual(result["profile_name"], "gitea-reviewer")
self.assertEqual(result["allowed_operations"], ["read", "review", "approve"]) self.assertEqual(result["allowed_operations"], ["read", "review", "approve"])
@@ -1893,7 +1910,7 @@ class TestProfileDiscovery(unittest.TestCase):
def test_discovery_never_exposes_secrets(self, _auth, mock_api): def test_discovery_never_exposes_secrets(self, _auth, mock_api):
mock_api.return_value = {"id": 3, "login": "reviewer-bot"} mock_api.return_value = {"id": 3, "login": "reviewer-bot"}
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_TOKEN": "super-secret-token"} env = {"GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_TOKEN": "super-secret-token"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
result = gitea_get_profile(remote="prgs") result = gitea_get_profile(remote="prgs")
blob = repr(result).lower() blob = repr(result).lower()
for secret in ("super-secret-token", "token dgvzd", "authorization", "basic ", FAKE_AUTH.lower()): for secret in ("super-secret-token", "token dgvzd", "authorization", "basic ", FAKE_AUTH.lower()):
@@ -1975,7 +1992,7 @@ class TestPrEligibility(unittest.TestCase):
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")] mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"} "GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_check_pr_eligibility(pr_number=5, action="review", remote="prgs") r = gitea_check_pr_eligibility(pr_number=5, action="review", remote="prgs")
self.assertTrue(r["eligible"]) self.assertTrue(r["eligible"])
self.assertEqual(r["authenticated_user"], "reviewer-bot") self.assertEqual(r["authenticated_user"], "reviewer-bot")
@@ -1988,7 +2005,7 @@ class TestPrEligibility(unittest.TestCase):
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")] mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs") r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
self.assertFalse(r["eligible"]) self.assertFalse(r["eligible"])
self.assertIn("authenticated user is PR author", r["reasons"]) self.assertIn("authenticated user is PR author", r["reasons"])
@@ -1999,7 +2016,7 @@ class TestPrEligibility(unittest.TestCase):
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")] mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"} "GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_check_pr_eligibility(pr_number=8, action="approve", remote="prgs") r = gitea_check_pr_eligibility(pr_number=8, action="approve", remote="prgs")
self.assertFalse(r["eligible"]) self.assertFalse(r["eligible"])
self.assertIn("authenticated user is PR author", r["reasons"]) self.assertIn("authenticated user is PR author", r["reasons"])
@@ -2014,7 +2031,7 @@ class TestPrEligibility(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs") r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
self.assertFalse(r["eligible"]) self.assertFalse(r["eligible"])
self.assertIsNone(r["mergeable"]) self.assertIsNone(r["mergeable"])
@@ -2026,7 +2043,7 @@ class TestPrEligibility(unittest.TestCase):
mock_api.side_effect = [{"login": "author-bot"}, self._pr("someone-else")] mock_api.side_effect = [{"login": "author-bot"}, self._pr("someone-else")]
env = {"GITEA_PROFILE_NAME": "gitea-author", env = {"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_ALLOWED_OPERATIONS": "read,pr.create"} "GITEA_ALLOWED_OPERATIONS": "read,pr.create"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs") r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
self.assertFalse(r["eligible"]) self.assertFalse(r["eligible"])
self.assertIn("profile is not allowed to merge", r["reasons"]) self.assertIn("profile is not allowed to merge", r["reasons"])
@@ -2038,7 +2055,7 @@ class TestPrEligibility(unittest.TestCase):
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review", "GITEA_ALLOWED_OPERATIONS": "read,review",
"GITEA_FORBIDDEN_OPERATIONS": "merge"} "GITEA_FORBIDDEN_OPERATIONS": "merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs") r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
self.assertFalse(r["eligible"]) self.assertFalse(r["eligible"])
self.assertIn("profile forbids 'merge'", r["reasons"]) self.assertIn("profile forbids 'merge'", r["reasons"])
@@ -2049,7 +2066,7 @@ class TestPrEligibility(unittest.TestCase):
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot", state="closed")] mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot", state="closed")]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"} "GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_check_pr_eligibility(pr_number=8, action="approve", remote="prgs") r = gitea_check_pr_eligibility(pr_number=8, action="approve", remote="prgs")
self.assertFalse(r["eligible"]) self.assertFalse(r["eligible"])
self.assertIn("PR is not open (state=closed)", r["reasons"]) self.assertIn("PR is not open (state=closed)", r["reasons"])
@@ -2058,7 +2075,7 @@ class TestPrEligibility(unittest.TestCase):
def test_unknown_identity_fails_closed(self, _auth): def test_unknown_identity_fails_closed(self, _auth):
env = {"GITEA_PROFILE_NAME": "gitea-merger", env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"} "GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs") r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
self.assertFalse(r["eligible"]) self.assertFalse(r["eligible"])
self.assertIn("authenticated identity could not be determined", r["reasons"]) self.assertIn("authenticated identity could not be determined", r["reasons"])
@@ -2077,7 +2094,7 @@ class TestPrEligibility(unittest.TestCase):
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review", "GITEA_ALLOWED_OPERATIONS": "read,review",
"GITEA_TOKEN": "super-secret-token"} "GITEA_TOKEN": "super-secret-token"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_check_pr_eligibility(pr_number=5, action="review", remote="prgs") r = gitea_check_pr_eligibility(pr_number=5, action="review", remote="prgs")
blob = repr(r).lower() blob = repr(r).lower()
for secret in ("super-secret-token", "authorization", "basic ", FAKE_AUTH.lower()): for secret in ("super-secret-token", "authorization", "basic ", FAKE_AUTH.lower()):
@@ -2274,7 +2291,7 @@ class TestSubmitPrReview(unittest.TestCase):
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")] mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"} "GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review( r = gitea_submit_pr_review(
pr_number=8, action="approve", remote="prgs", pr_number=8, action="approve", remote="prgs",
final_review_decision_ready=True, final_review_decision_ready=True,
@@ -2293,7 +2310,7 @@ class TestSubmitPrReview(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"} "GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review( r = gitea_submit_pr_review(
pr_number=8, action="approve", body="LGTM", remote="prgs", pr_number=8, action="approve", body="LGTM", remote="prgs",
final_review_decision_ready=True, final_review_decision_ready=True,
@@ -2323,7 +2340,7 @@ class TestSubmitPrReview(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"} "GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review( r = gitea_submit_pr_review(
pr_number=8, action="approve", body="LGTM", remote="prgs", pr_number=8, action="approve", body="LGTM", remote="prgs",
final_review_decision_ready=True, final_review_decision_ready=True,
@@ -2343,7 +2360,7 @@ class TestSubmitPrReview(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"} "GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review( r = gitea_submit_pr_review(
pr_number=8, action="approve", body="LGTM", remote="prgs", pr_number=8, action="approve", body="LGTM", remote="prgs",
final_review_decision_ready=True, final_review_decision_ready=True,
@@ -2369,7 +2386,7 @@ class TestSubmitPrReview(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,request_changes"} "GITEA_ALLOWED_OPERATIONS": "read,review,request_changes"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review( r = gitea_submit_pr_review(
pr_number=8, action="request_changes", pr_number=8, action="request_changes",
body="needs work", remote="prgs", body="needs work", remote="prgs",
@@ -2390,7 +2407,7 @@ class TestSubmitPrReview(unittest.TestCase):
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")] mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review"} # no request_changes "GITEA_ALLOWED_OPERATIONS": "read,review"} # no request_changes
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review( r = gitea_submit_pr_review(
pr_number=8, action="request_changes", remote="prgs", pr_number=8, action="request_changes", remote="prgs",
final_review_decision_ready=True, final_review_decision_ready=True,
@@ -2411,7 +2428,7 @@ class TestSubmitPrReview(unittest.TestCase):
gitea_mark_final_review_decision(8, "comment", remote="prgs", expected_head_sha="abc123") gitea_mark_final_review_decision(8, "comment", remote="prgs", expected_head_sha="abc123")
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review"} "GITEA_ALLOWED_OPERATIONS": "read,review"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review( r = gitea_submit_pr_review(
pr_number=8, action="comment", body="finding", remote="prgs", pr_number=8, action="comment", body="finding", remote="prgs",
final_review_decision_ready=True, final_review_decision_ready=True,
@@ -2429,7 +2446,7 @@ class TestSubmitPrReview(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review"} "GITEA_ALLOWED_OPERATIONS": "read,review"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
gitea_mark_final_review_decision( gitea_mark_final_review_decision(
8, "comment", remote="prgs", expected_head_sha="abc123", 8, "comment", remote="prgs", expected_head_sha="abc123",
) )
@@ -2445,7 +2462,7 @@ class TestSubmitPrReview(unittest.TestCase):
def test_unknown_identity_blocks(self, _auth): def test_unknown_identity_blocks(self, _auth):
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"} "GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review( r = gitea_submit_pr_review(
pr_number=8, action="approve", remote="prgs", pr_number=8, action="approve", remote="prgs",
final_review_decision_ready=True, final_review_decision_ready=True,
@@ -2460,7 +2477,7 @@ class TestSubmitPrReview(unittest.TestCase):
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")] mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
env = {"GITEA_PROFILE_NAME": "gitea-author", env = {"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_ALLOWED_OPERATIONS": "read,pr.create"} "GITEA_ALLOWED_OPERATIONS": "read,pr.create"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review( r = gitea_submit_pr_review(
pr_number=8, action="approve", remote="prgs", pr_number=8, action="approve", remote="prgs",
final_review_decision_ready=True, final_review_decision_ready=True,
@@ -2479,7 +2496,7 @@ class TestSubmitPrReview(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"} "GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review( r = gitea_submit_pr_review(
pr_number=8, action="approve", pr_number=8, action="approve",
expected_head_sha="abc123", remote="prgs", expected_head_sha="abc123", remote="prgs",
@@ -2503,7 +2520,7 @@ class TestSubmitPrReview(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"} "GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review( r = gitea_submit_pr_review(
pr_number=8, action="approve", pr_number=8, action="approve",
expected_head_sha="abc123", remote="prgs", expected_head_sha="abc123", remote="prgs",
@@ -2532,7 +2549,7 @@ class TestSubmitPrReview(unittest.TestCase):
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve", "GITEA_ALLOWED_OPERATIONS": "read,review,approve",
"GITEA_TOKEN": "super-secret-token"} "GITEA_TOKEN": "super-secret-token"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
gitea_mark_final_review_decision(5, "approve", remote="prgs", expected_head_sha="abc123") gitea_mark_final_review_decision(5, "approve", remote="prgs", expected_head_sha="abc123")
r = gitea_submit_pr_review( r = gitea_submit_pr_review(
pr_number=5, action="approve", remote="prgs", pr_number=5, action="approve", remote="prgs",
@@ -2552,7 +2569,7 @@ class TestSubmitPrReview(unittest.TestCase):
] ]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"} "GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review( r = gitea_submit_pr_review(
pr_number=8, action="approve", remote="prgs", pr_number=8, action="approve", remote="prgs",
final_review_decision_ready=True, final_review_decision_ready=True,
@@ -2629,7 +2646,7 @@ class TestSubmitPrReview(unittest.TestCase):
try: try:
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"} "GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
r = gitea_submit_pr_review( r = gitea_submit_pr_review(
pr_number=8, action="approve", remote="prgs", pr_number=8, action="approve", remote="prgs",
final_review_decision_ready=True, final_review_decision_ready=True,
@@ -2662,7 +2679,7 @@ class TestSubmitPrReview(unittest.TestCase):
"GITEA_ALLOWED_OPERATIONS": "read,review,approve,request_changes"} "GITEA_ALLOWED_OPERATIONS": "read,review,approve,request_changes"}
with patch("mcp_server.gitea_get_pr_review_feedback", with patch("mcp_server.gitea_get_pr_review_feedback",
return_value=dict(_NO_BLOCKER_FEEDBACK)): return_value=dict(_NO_BLOCKER_FEEDBACK)):
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
first = gitea_submit_pr_review( first = gitea_submit_pr_review(
pr_number=8, action="approve", remote="prgs", pr_number=8, action="approve", remote="prgs",
final_review_decision_ready=True, final_review_decision_ready=True,
@@ -2687,7 +2704,7 @@ class TestSubmitPrReview(unittest.TestCase):
def test_remote_org_repo_validation_mismatch(self, _auth, mock_api): def test_remote_org_repo_validation_mismatch(self, _auth, mock_api):
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"} "GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
# Mark decision for specific remote, org, repo # Mark decision for specific remote, org, repo
gitea_mark_final_review_decision(8, "approve", remote="prgs", expected_head_sha="abc123", org="MyOrg", repo="MyRepo") gitea_mark_final_review_decision(8, "approve", remote="prgs", expected_head_sha="abc123", org="MyOrg", repo="MyRepo")
@@ -2725,13 +2742,29 @@ if __name__ == "__main__":
class TestTrackerHygieneCleanup(unittest.TestCase): class TestTrackerHygieneCleanup(unittest.TestCase):
def setUp(self): 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() mcp_server.gitea_load_review_workflow()
self.mock_api = patch("mcp_server.api_request").start() self.mock_api = patch("mcp_server.api_request").start()
self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start() self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
patch("gitea_audit.audit_enabled", return_value=True).start() patch("gitea_audit.audit_enabled", return_value=True).start()
self.mock_audit = patch("gitea_audit.write_event").start() self.mock_audit = patch("gitea_audit.write_event").start()
# gitea.pr.close: closing a PR via gitea_edit_pr is capability-gated (#216). # gitea.pr.close: closing a PR via gitea_edit_pr is capability-gated (#216).
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.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._list_pr_lease_comments", return_value=[]).start() patch("mcp_server._list_pr_lease_comments", return_value=[]).start()
patch( patch(
"mcp_server._pr_work_lease_reviewer_block", "mcp_server._pr_work_lease_reviewer_block",
@@ -2739,6 +2772,11 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
).start() ).start()
def tearDown(self): def tearDown(self):
try:
self._mut_env.stop()
except Exception:
pass
patch.stopall() patch.stopall()
def test_close_issue_removes_in_progress(self): def test_close_issue_removes_in_progress(self):
@@ -3144,7 +3182,7 @@ class TestEndpointRedaction(unittest.TestCase):
"GITEA_BASE_URL": "https://gitea.example.invalid", "GITEA_BASE_URL": "https://gitea.example.invalid",
"GITEA_TOKEN_SOURCE": "keychain:some-item-id", "GITEA_TOKEN_SOURCE": "keychain:some-item-id",
} }
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
result = gitea_get_profile(remote="prgs", result = gitea_get_profile(remote="prgs",
resolve_identity=False) resolve_identity=False)
blob = repr(result) blob = repr(result)
@@ -3166,7 +3204,7 @@ class TestEndpointRedaction(unittest.TestCase):
"GITEA_TOKEN_SOURCE": "keychain:some-item-id", "GITEA_TOKEN_SOURCE": "keychain:some-item-id",
"GITEA_MCP_REVEAL_ENDPOINTS": "1", "GITEA_MCP_REVEAL_ENDPOINTS": "1",
} }
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
result = gitea_get_profile(remote="prgs", result = gitea_get_profile(remote="prgs",
resolve_identity=False) resolve_identity=False)
self.assertEqual(result["server"], "https://gitea.prgs.cc") self.assertEqual(result["server"], "https://gitea.prgs.cc")
@@ -3178,7 +3216,7 @@ class TestEndpointRedaction(unittest.TestCase):
try: try:
env = {"GITEA_MCP_CONFIG": path, env = {"GITEA_MCP_CONFIG": path,
"GITEA_MCP_PROFILE": "prgs-author"} "GITEA_MCP_PROFILE": "prgs-author"}
with patch.dict(os.environ, env, clear=True), \ with patch.dict(os.environ, env, clear=False), \
patch("gitea_config._keychain_token", return_value="x"): patch("gitea_config._keychain_token", return_value="x"):
result = gitea_audit_config() result = gitea_audit_config()
finally: finally:
@@ -3266,7 +3304,7 @@ class TestIssueCommentTools(unittest.TestCase):
def test_list_reveal_opt_in_includes_url(self, _auth, mock_api): def test_list_reveal_opt_in_includes_url(self, _auth, mock_api):
mock_api.return_value = [self._comment()] mock_api.return_value = [self._comment()]
env = dict(self.AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1") env = dict(self.AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1")
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
result = gitea_list_issue_comments(issue_number=9, remote="prgs") result = gitea_list_issue_comments(issue_number=9, remote="prgs")
self.assertIn("issuecomment-101", result["comments"][0]["url"]) self.assertIn("issuecomment-101", result["comments"][0]["url"])
@@ -3275,7 +3313,7 @@ class TestIssueCommentTools(unittest.TestCase):
def test_list_blocked_without_read_permission(self, _auth, mock_api): def test_list_blocked_without_read_permission(self, _auth, mock_api):
env = {"GITEA_PROFILE_NAME": "gitea-writer-only", env = {"GITEA_PROFILE_NAME": "gitea-writer-only",
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment"} "GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
result = gitea_list_issue_comments(issue_number=9, remote="prgs") result = gitea_list_issue_comments(issue_number=9, remote="prgs")
self.assertFalse(result["success"]) self.assertFalse(result["success"])
self.assertTrue(result["reasons"]) self.assertTrue(result["reasons"])
@@ -3322,7 +3360,7 @@ class TestIssueCommentTools(unittest.TestCase):
def test_create_reveal_opt_in_includes_url(self, _auth, mock_api): def test_create_reveal_opt_in_includes_url(self, _auth, mock_api):
mock_api.return_value = self._comment(555) mock_api.return_value = self._comment(555)
env = dict(self.AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1") env = dict(self.AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1")
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
result = gitea_create_issue_comment( result = gitea_create_issue_comment(
issue_number=9, body="posted", remote="prgs") issue_number=9, body="posted", remote="prgs")
self.assertIn("issuecomment-555", result["url"]) self.assertIn("issuecomment-555", result["url"])
@@ -3334,7 +3372,7 @@ class TestIssueCommentTools(unittest.TestCase):
"GITEA_ALLOWED_OPERATIONS": "GITEA_ALLOWED_OPERATIONS":
"gitea.read,gitea.pr.review,gitea.pr.comment," "gitea.read,gitea.pr.review,gitea.pr.comment,"
"gitea.pr.approve,gitea.pr.merge"} "gitea.pr.approve,gitea.pr.merge"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
result = gitea_create_issue_comment( result = gitea_create_issue_comment(
issue_number=9, body="posted", remote="prgs") issue_number=9, body="posted", remote="prgs")
self.assertFalse(result["success"]) self.assertFalse(result["success"])
@@ -3348,7 +3386,7 @@ class TestIssueCommentTools(unittest.TestCase):
env = {"GITEA_PROFILE_NAME": "gitea-author", env = {"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment", "GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
"GITEA_FORBIDDEN_OPERATIONS": "gitea.issue.comment"} "GITEA_FORBIDDEN_OPERATIONS": "gitea.issue.comment"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
result = gitea_create_issue_comment( result = gitea_create_issue_comment(
issue_number=9, body="posted", remote="prgs") issue_number=9, body="posted", remote="prgs")
self.assertFalse(result["success"]) self.assertFalse(result["success"])
@@ -3484,7 +3522,7 @@ class TestIssueCommentPermissionSeparation(unittest.TestCase):
"GITEA_ALLOWED_OPERATIONS": "GITEA_ALLOWED_OPERATIONS":
"gitea.branch.create,gitea.branch.push,gitea.pr.comment," "gitea.branch.create,gitea.branch.push,gitea.pr.comment,"
"gitea.pr.create,gitea.read,gitea.repo.commit"} "gitea.pr.create,gitea.read,gitea.repo.commit"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
result = gitea_create_issue_comment( result = gitea_create_issue_comment(
issue_number=51, body="audit findings", remote="prgs") issue_number=51, body="audit findings", remote="prgs")
self.assertFalse(result["success"]) self.assertFalse(result["success"])
@@ -3669,11 +3707,12 @@ class TestIssueLocking(unittest.TestCase):
def setUp(self): def setUp(self):
self._lock_dir = tempfile.TemporaryDirectory() self._lock_dir = tempfile.TemporaryDirectory()
# Most locking tests target remote=prgs; host-align profile (#714).
env = { env = {
**ISSUE_WRITE_ENV, **shared_mutation_env("test-author-prgs"),
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name, "GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
} }
self._env_patcher = patch.dict(os.environ, env, clear=True) self._env_patcher = patch.dict(os.environ, env, clear=False)
self._env_patcher.start() self._env_patcher.start()
self._dup_fetcher_patcher = patch( self._dup_fetcher_patcher = patch(
"mcp_server.issue_duplicate_context_fetcher", "mcp_server.issue_duplicate_context_fetcher",
@@ -3687,8 +3726,9 @@ class TestIssueLocking(unittest.TestCase):
self._lock_dir.cleanup() self._lock_dir.cleanup()
def _create_pr_env(self) -> dict: def _create_pr_env(self) -> dict:
# PR-locking tests call create_pr with remote=prgs.
return { return {
**CREATE_PR_ENV, **shared_mutation_env("test-author-prgs"),
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name, "GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
} }
@@ -3707,7 +3747,7 @@ class TestIssueLocking(unittest.TestCase):
self.assertIn("created_at", res["work_lease"]) self.assertIn("created_at", res["work_lease"])
self.assertIn("expires_at", res["work_lease"]) self.assertIn("expires_at", res["work_lease"])
self.assertIn("last_heartbeat_at", res["work_lease"]) self.assertIn("last_heartbeat_at", res["work_lease"])
self.assertEqual(res["work_lease"]["claimant"]["profile"], "gitea-default") self.assertIn(res["work_lease"]["claimant"]["profile"], ("gitea-default", "test-author-prgs", "prgs-author", "gitea-author"))
self.assertIn("lock_file_path", res) self.assertIn("lock_file_path", res)
lock = issue_lock_store.read_lock_file(res["lock_file_path"]) lock = issue_lock_store.read_lock_file(res["lock_file_path"])
self.assertIn("worktree_path", lock) self.assertIn("worktree_path", lock)
@@ -3826,7 +3866,7 @@ class TestIssueLocking(unittest.TestCase):
@patch("mcp_server.api_get_all", return_value=[]) @patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_issue_blocks_active_same_operation_lease(self, _auth, _api, _git_state): def test_lock_issue_blocks_active_same_operation_lease(self, _auth, _api, _git_state):
prgs_repo = mcp_server.REMOTES["prgs"]["repo"] prgs_repo = "Gitea-Tools" # #714 session-bound repo (not REMOTES Timesheet default)
issue_lock_store.save_lock_file( issue_lock_store.save_lock_file(
issue_lock_store.lock_file_path( issue_lock_store.lock_file_path(
remote="prgs", remote="prgs",
@@ -3863,7 +3903,7 @@ class TestIssueLocking(unittest.TestCase):
Dead-pid / missing-worktree reclaim is covered by issue_lock_store unit tests. 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. This MCP path must remain fail-closed for sticky expired foreign ownership.
""" """
prgs_repo = mcp_server.REMOTES["prgs"]["repo"] prgs_repo = "Gitea-Tools" # #714 session-bound repo (not REMOTES Timesheet default)
# Present worktree + live pid => reclaim_allowed=False even though expires_at is past. # Present worktree + live pid => reclaim_allowed=False even though expires_at is past.
sticky_worktree = tempfile.mkdtemp(prefix="gitea-sticky-lease-") sticky_worktree = tempfile.mkdtemp(prefix="gitea-sticky-lease-")
self.addCleanup(lambda: shutil.rmtree(sticky_worktree, ignore_errors=True)) self.addCleanup(lambda: shutil.rmtree(sticky_worktree, ignore_errors=True))
@@ -4057,12 +4097,12 @@ class TestIssueLocking(unittest.TestCase):
worktree = os.path.realpath(os.getcwd()) worktree = os.path.realpath(os.getcwd())
with tempfile.TemporaryDirectory() as lock_dir: with tempfile.TemporaryDirectory() as lock_dir:
env = {**self._create_pr_env(), "GITEA_ISSUE_LOCK_DIR": lock_dir} env = {**self._create_pr_env(), "GITEA_ISSUE_LOCK_DIR": lock_dir}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=False):
issue_lock_store.save_lock_file( issue_lock_store.save_lock_file(
issue_lock_store.lock_file_path( issue_lock_store.lock_file_path(
remote="prgs", remote="prgs",
org="Scaled-Tech-Consulting", org="Scaled-Tech-Consulting",
repo=mcp_server.REMOTES["prgs"]["repo"], repo="Gitea-Tools", # #714 session-bound
issue_number=447, issue_number=447,
lock_dir=lock_dir, lock_dir=lock_dir,
), ),
@@ -4071,7 +4111,7 @@ class TestIssueLocking(unittest.TestCase):
branch_name="feat/issue-447-lock-provenance", branch_name="feat/issue-447-lock-provenance",
remote="prgs", remote="prgs",
org="Scaled-Tech-Consulting", org="Scaled-Tech-Consulting",
repo=mcp_server.REMOTES["prgs"]["repo"], repo="Gitea-Tools", # #714 session-bound
worktree_path=worktree, worktree_path=worktree,
lock_provenance=None, lock_provenance=None,
), ),
+4
View File
@@ -1,3 +1,7 @@
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).""" """Merger lease adoption / recovery (#536)."""
import os import os
+6 -176
View File
@@ -92,19 +92,14 @@ class TestMigrateProfiles(unittest.TestCase):
author = prgs_gitea["identities"]["author"] author = prgs_gitea["identities"]["author"]
self.assertEqual(author["username"], "jcwalker3") self.assertEqual(author["username"], "jcwalker3")
self.assertEqual(author["auth"]["id"], "redacted-author-ref") self.assertEqual(author["auth"]["id"], "redacted-author-ref")
self.assertEqual( self.assertEqual(author["allowed_operations"], ["read", "comment"])
author["allowed_operations"], ["gitea.read", "gitea.pr.comment"] self.assertEqual(author["forbidden_operations"], ["approve", "merge"])
)
self.assertEqual(
author["forbidden_operations"],
["gitea.pr.approve", "gitea.pr.merge"],
)
reviewer = prgs_gitea["identities"]["reviewer"] reviewer = prgs_gitea["identities"]["reviewer"]
self.assertEqual(reviewer["role"], "reviewer") self.assertEqual(reviewer["role"], "reviewer")
self.assertEqual(reviewer["username"], "sysadmin") self.assertEqual(reviewer["username"], "sysadmin")
self.assertEqual(reviewer["auth"]["id"], "redacted-reviewer-ref") self.assertEqual(reviewer["auth"]["id"], "redacted-reviewer-ref")
self.assertIn("gitea.pr.merge", reviewer["allowed_operations"]) self.assertIn("merge", reviewer["allowed_operations"])
def test_alias_generation(self): def test_alias_generation(self):
"""Test that aliases are correctly generated to support old profile names.""" """Test that aliases are correctly generated to support old profile names."""
@@ -193,7 +188,7 @@ class TestMigrateProfiles(unittest.TestCase):
self.assertNotIn("token", stdout_output.lower()) self.assertNotIn("token", stdout_output.lower())
def test_explicit_operations_are_preserved(self): def test_explicit_operations_are_preserved(self):
"""Explicit v1 permissions are canonicalized, not replaced by role defaults.""" """Explicit v1 permissions must not be replaced by role defaults."""
v1_data = json.loads(json.dumps(self.v1_content)) v1_data = json.loads(json.dumps(self.v1_content))
v1_data["profiles"]["prgs-reviewer"]["allowed_operations"] = ["read"] v1_data["profiles"]["prgs-reviewer"]["allowed_operations"] = ["read"]
v1_data["profiles"]["prgs-reviewer"]["forbidden_operations"] = ["merge"] v1_data["profiles"]["prgs-reviewer"]["forbidden_operations"] = ["merge"]
@@ -203,8 +198,8 @@ class TestMigrateProfiles(unittest.TestCase):
v2_data["environments"]["prgs"]["services"]["gitea"] v2_data["environments"]["prgs"]["services"]["gitea"]
["identities"]["reviewer"] ["identities"]["reviewer"]
) )
self.assertEqual(reviewer["allowed_operations"], ["gitea.read"]) self.assertEqual(reviewer["allowed_operations"], ["read"])
self.assertEqual(reviewer["forbidden_operations"], ["gitea.pr.merge"]) self.assertEqual(reviewer["forbidden_operations"], ["merge"])
def test_inferred_role_defaults_only_when_unambiguous(self): def test_inferred_role_defaults_only_when_unambiguous(self):
"""Role defaults are allowed only for clear author/reviewer profiles.""" """Role defaults are allowed only for clear author/reviewer profiles."""
@@ -311,171 +306,6 @@ class TestMigrateProfiles(unittest.TestCase):
migrate_profiles.main() migrate_profiles.main()
self.assertEqual(cm.exception.code, 1) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+4
View File
@@ -1,3 +1,7 @@
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. """Operation-name normalization table and enforcement tests — issue #106.
Covers the required matrix from #106: Covers the required matrix from #106:
+66 -32
View File
@@ -1,4 +1,9 @@
"""Tests for operation-scoped role selection and automatic dispatch switching (#228).""" """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.
"""
import os import os
import sys import sys
import json import json
@@ -15,6 +20,7 @@ from reviewer_worktree import assess_author_worktree_continuity
CONFIG_TEST = { CONFIG_TEST = {
"version": 2, "version": 2,
"allow_runtime_switching": True,
"contexts": { "contexts": {
"ctx": { "ctx": {
"enabled": True, "enabled": True,
@@ -30,9 +36,11 @@ CONFIG_TEST = {
"context": "ctx", "context": "ctx",
"role": "author", "role": "author",
"username": "author-user", "username": "author-user",
"base_url": "https://gitea.example.com",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"}, "auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read", "gitea.issue.create", "gitea.pr.create", "gitea.branch.push", "gitea.issue.comment"], "allowed_operations": ["gitea.read", "gitea.issue.create", "gitea.pr.create", "gitea.branch.push", "gitea.issue.comment"],
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"], "forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "913443/eAgenda"],
"execution_profile": "author-profile" "execution_profile": "author-profile"
}, },
"reviewer-profile": { "reviewer-profile": {
@@ -40,9 +48,11 @@ CONFIG_TEST = {
"context": "ctx", "context": "ctx",
"role": "reviewer", "role": "reviewer",
"username": "reviewer-user", "username": "reviewer-user",
"base_url": "https://gitea.example.com",
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"}, "auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.issue.comment"], "allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.issue.comment"],
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"], "forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "913443/eAgenda"],
"execution_profile": "reviewer-profile" "execution_profile": "reviewer-profile"
} }
} }
@@ -57,6 +67,15 @@ class TestOperationScopedRoles(unittest.TestCase):
}) })
self._remotes_patch.start() self._remotes_patch.start()
mcp_server._IDENTITY_CACHE.clear() 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 gitea_config._active_profile_override = None
mcp_server._MUTATION_AUTHORITY = None mcp_server._MUTATION_AUTHORITY = None
self._dir = tempfile.TemporaryDirectory() self._dir = tempfile.TemporaryDirectory()
@@ -64,6 +83,11 @@ class TestOperationScopedRoles(unittest.TestCase):
self._write_config(CONFIG_TEST) self._write_config(CONFIG_TEST)
def tearDown(self): def tearDown(self):
try:
self._url.stop()
except Exception:
pass
self._remotes_patch.stop() self._remotes_patch.stop()
mcp_server._IDENTITY_CACHE.clear() mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None gitea_config._active_profile_override = None
@@ -85,62 +109,71 @@ class TestOperationScopedRoles(unittest.TestCase):
return env return env
@patch("mcp_server.api_request") @patch("mcp_server.api_request")
def test_auto_switch_to_reviewer(self, mock_api): def test_no_auto_switch_to_reviewer(self, mock_api):
# mock identity resolution to return username matching profile # #714: capability resolution must not silently switch author → reviewer
mock_api.side_effect = lambda method, url, header: ( mock_api.side_effect = lambda method, url, header: (
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"} {"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
) )
with patch.dict(os.environ, self._env("author-profile")): 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(gitea_config.selected_profile_name(), "author-profile")
self.assertEqual(mcp_server.get_profile()["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") res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
# verify it automatically switched to reviewer-profile self.assertFalse(res["allowed_in_current_session"])
self.assertTrue(res["allowed_in_current_session"]) self.assertFalse(res["available_in_session"])
self.assertTrue(res["available_in_session"]) self.assertEqual(res["active_profile"], "author-profile")
self.assertEqual(res["active_profile"], "reviewer-profile") self.assertEqual(res["active_identity"], "author-user")
self.assertEqual(res["active_identity"], "reviewer-user") self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile") self.assertIn("reviewer-profile", res["matching_configured_profile"])
self.assertFalse(res.get("auto_profile_substitution", True))
@patch("mcp_server.api_request") @patch("mcp_server.api_request")
def test_auto_switch_to_author(self, mock_api): def test_no_auto_switch_to_author(self, mock_api):
mock_api.side_effect = lambda method, url, header: ( mock_api.side_effect = lambda method, url, header: (
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"} {"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
) )
with patch.dict(os.environ, self._env("reviewer-profile")): with patch.dict(os.environ, self._env("reviewer-profile")):
# initially we are reviewer-profile
self.assertEqual(gitea_config.selected_profile_name(), "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") res = mcp_server.gitea_resolve_task_capability(task="create_issue", remote="prgs")
# verify it automatically switched to author-profile self.assertFalse(res["allowed_in_current_session"])
self.assertTrue(res["allowed_in_current_session"]) self.assertFalse(res["available_in_session"])
self.assertTrue(res["available_in_session"]) self.assertEqual(res["active_profile"], "reviewer-profile")
self.assertEqual(res["active_profile"], "author-profile") self.assertEqual(res["active_identity"], "reviewer-user")
self.assertEqual(res["active_identity"], "author-user") self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
self.assertEqual(gitea_config.selected_profile_name(), "author-profile") self.assertIn("author-profile", res["matching_configured_profile"])
@patch("mcp_server.api_request") @patch("mcp_server.api_request")
def test_restart_required_when_unattached(self, mock_api): 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")
self.assertTrue(res["allowed_in_current_session"])
self.assertEqual(res["active_profile"], "reviewer-profile")
@patch("mcp_server.api_request")
def test_denied_when_matching_profile_token_missing(self, mock_api):
mock_api.side_effect = lambda method, url, header: {"login": "author-user"} mock_api.side_effect = lambda method, url, header: {"login": "author-user"}
# launch without reviewer token in env # launch without reviewer token in env
with patch.dict(os.environ, self._env("author-profile", with_reviewer_token=False)): with patch.dict(os.environ, self._env("author-profile", with_reviewer_token=False)):
self.assertEqual(gitea_config.selected_profile_name(), "author-profile") 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") 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["allowed_in_current_session"])
self.assertFalse(res["available_in_session"]) self.assertFalse(res["available_in_session"])
self.assertTrue(res["configured"]) self.assertTrue(res["configured"])
self.assertTrue(res["restart_required"])
self.assertTrue(res["stop_required"]) self.assertTrue(res["stop_required"])
self.assertIn("Reviewer profile exists but MCP server was added after session startup and is not attached", res["reason"]) self.assertEqual(res["active_profile"], "author-profile")
def test_author_continuity_dirty_worktree(self): def test_author_continuity_dirty_worktree(self):
# author is allowed to keep dirty worktree # author is allowed to keep dirty worktree
@@ -161,20 +194,21 @@ class TestOperationScopedRoles(unittest.TestCase):
self.assertFalse(res2["proven"]) self.assertFalse(res2["proven"])
@patch("mcp_server.api_request") @patch("mcp_server.api_request")
def test_mutating_actions_auto_switch(self, mock_api): def test_mutating_actions_do_not_auto_switch(self, mock_api):
mock_api.side_effect = lambda method, url, header: ( mock_api.side_effect = lambda method, url, header: (
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"} {"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
) )
with patch.dict(os.environ, self._env("author-profile")): with patch.dict(os.environ, self._env("author-profile")):
# verify we are author-profile
self.assertEqual(gitea_config.selected_profile_name(), "author-profile") self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
# call a reviewer mutation check helper (like _profile_permission_block with reviewer permission) blocked = mcp_server._profile_permission_block(
blocked = mcp_server._profile_permission_block("gitea.pr.merge", remote="prgs") "gitea.pr.merge", remote="prgs"
self.assertIsNone(blocked) # should switch to reviewer-profile and allow it (no permission block) )
self.assertIsNotNone(blocked)
self.assertFalse(blocked.get("success", True))
# verify we dynamically switched to reviewer-profile # profile must remain author — no silent substitution
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile") self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
if __name__ == "__main__": if __name__ == "__main__":
+4
View File
@@ -1,3 +1,7 @@
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). """Tests for the operator guide / project skills MCP tools (#128).
Read-only capability-discovery tools: mcp_get_control_plane_guide, Read-only capability-discovery tools: mcp_get_control_plane_guide,
+4
View File
@@ -1,3 +1,7 @@
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). """Post-merge moot reviewer-lease handling (#515).
Covers: Covers:
@@ -1,3 +1,7 @@
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).""" """Regression tests for non-list API payloads on PR/issue comment listing (#485)."""
import os import os
import sys import sys
-36
View File
@@ -82,42 +82,6 @@ class TestReconcilerProfileModel(unittest.TestCase):
"reconciler", "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__": if __name__ == "__main__":
unittest.main() unittest.main()
+9 -5
View File
@@ -1,3 +1,7 @@
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). """Regression coverage for the remote/repo mismatch guard (#530).
Bare ``remote=prgs`` historically resolved to ``Scaled-Tech-Consulting/Timesheet`` Bare ``remote=prgs`` historically resolved to ``Scaled-Tech-Consulting/Timesheet``
@@ -162,12 +166,12 @@ class TestResolveServerWiring(unittest.TestCase):
self.addCleanup(self.server.REMOTES.__setitem__, "prgs", original) self.addCleanup(self.server.REMOTES.__setitem__, "prgs", original)
self.server.REMOTES["prgs"] = {**original, "repo": repo} self.server.REMOTES["prgs"] = {**original, "repo": repo}
def test_resolve_blocks_on_default_repo_mismatch(self): def test_resolve_prefers_workspace_over_timesheet_default(self):
"""#714: bare remote=prgs must use workspace Gitea-Tools, not REMOTES Timesheet."""
self._set_prgs_default_repo("Timesheet") self._set_prgs_default_repo("Timesheet")
with self.assertRaises(RuntimeError) as ctx: host, org, repo = self.server._resolve("prgs", None, None, None)
self.server._resolve("prgs", None, None, None) self.assertEqual(org, "Scaled-Tech-Consulting")
self.assertIn("Timesheet", str(ctx.exception)) self.assertEqual(repo, "Gitea-Tools")
self.assertIn("Gitea-Tools", str(ctx.exception))
def test_resolve_allows_explicit_org_and_repo(self): def test_resolve_allows_explicit_org_and_repo(self):
self._set_prgs_default_repo("Timesheet") self._set_prgs_default_repo("Timesheet")
+2 -2
View File
@@ -188,8 +188,8 @@ class TestResolveTaskCapability(unittest.TestCase):
self.assertEqual(res["required_role_kind"], "author") self.assertEqual(res["required_role_kind"], "author")
self.assertTrue(res["allowed_in_current_session"]) self.assertTrue(res["allowed_in_current_session"])
@patch("mcp_server.api_request", return_value={"login": "author-user"}) @patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass") @patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_resolve_work_issue_reviewer_profile_blocked(self, _auth, _api): def test_resolve_work_issue_reviewer_profile_blocked(self, _auth, _api):
with patch.dict(os.environ, self._env("reviewer-profile")): with patch.dict(os.environ, self._env("reviewer-profile")):
res = mcp_server.gitea_resolve_task_capability( res = mcp_server.gitea_resolve_task_capability(
+4 -9
View File
@@ -122,11 +122,9 @@ class TestApiRequestRetry(unittest.TestCase):
sleep.assert_not_called() sleep.assert_not_called()
def test_non_429_error_raises_immediately(self): def test_non_429_error_raises_immediately(self):
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx: with self.assertRaises(RuntimeError) as ctx:
_call([_http_error(500, body=b"boom")]) _call([_http_error(500, body=b"boom")])
# Fixed message only (#699) — no response body echo. self.assertIn("HTTP 500", str(ctx.exception))
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): def test_non_429_error_does_not_sleep(self):
sleep = MagicMock() sleep = MagicMock()
@@ -173,12 +171,9 @@ class TestApiRequestRetry(unittest.TestCase):
# max_retries=3 -> 3 sleeps, then the 4th failure raises. # max_retries=3 -> 3 sleeps, then the 4th failure raises.
errors = [_http_error(429, retry_after="1") for _ in range(4)] errors = [_http_error(429, retry_after="1") for _ in range(4)]
sleep = MagicMock() sleep = MagicMock()
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx: with self.assertRaises(RuntimeError) as ctx:
_call(errors, sleep_func=sleep, max_retries=3) _call(errors, sleep_func=sleep, max_retries=3)
# After retries are exhausted, 429 is a typed HTTP error with fixed self.assertIn("HTTP 429", str(ctx.exception))
# 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) self.assertEqual(sleep.call_count, 3)
def test_no_infinite_loop_when_always_429(self): def test_no_infinite_loop_when_always_429(self):
+9 -2
View File
@@ -1,3 +1,7 @@
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).""" """Tests for reviewer/author namespace mismatch walls (#209)."""
import json import json
import os import os
@@ -36,6 +40,7 @@ CONFIG = {
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
], ],
"execution_profile": "prgs-author", "execution_profile": "prgs-author",
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
}, },
"prgs-reviewer": { "prgs-reviewer": {
"enabled": True, "enabled": True,
@@ -51,6 +56,7 @@ CONFIG = {
"gitea.pr.create", "gitea.branch.push", "gitea.issue.create", "gitea.pr.create", "gitea.branch.push", "gitea.issue.create",
], ],
"execution_profile": "prgs-reviewer", "execution_profile": "prgs-reviewer",
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
}, },
"prgs-reviewer-issue-writer": { "prgs-reviewer-issue-writer": {
"enabled": True, "enabled": True,
@@ -63,12 +69,13 @@ CONFIG = {
], ],
"forbidden_operations": ["gitea.pr.create"], "forbidden_operations": ["gitea.pr.create"],
"execution_profile": "prgs-reviewer-issue-writer", "execution_profile": "prgs-reviewer-issue-writer",
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
}, },
}, },
"rules": {"allow_runtime_switching": False}, "rules": {"allow_runtime_switching": False},
} }
ISSUE_WRITE_ENV = {"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create"} ISSUE_WRITE_ENV = {} # config-backed; operations come from profile allowlist (#714)
class NamespaceGateBase(unittest.TestCase): class NamespaceGateBase(unittest.TestCase):
@@ -199,7 +206,7 @@ class TestMutationAuditFields(NamespaceGateBase):
def test_successful_create_issue_audit_includes_namespace_fields( def test_successful_create_issue_audit_includes_namespace_fields(
self, _auth, mock_api, _get_all, _role self, _auth, mock_api, _get_all, _role
): ):
env = {**self._env("prgs-author", audit=True), **ISSUE_WRITE_ENV} env = self._env("prgs-author", audit=True)
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=True):
mcp_server.gitea_create_issue(title="audited", remote="prgs") mcp_server.gitea_create_issue(title="audited", remote="prgs")
with open(self.audit_path, encoding="utf-8") as fh: with open(self.audit_path, encoding="utf-8") as fh:
+4
View File
@@ -36,6 +36,7 @@ CONFIG = {
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
], ],
"execution_profile": "prgs-author", "execution_profile": "prgs-author",
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
}, },
"prgs-reviewer": { "prgs-reviewer": {
"enabled": True, "enabled": True,
@@ -51,6 +52,7 @@ CONFIG = {
"gitea.pr.create", "gitea.branch.push", "gitea.issue.create", "gitea.pr.create", "gitea.branch.push", "gitea.issue.create",
], ],
"execution_profile": "prgs-reviewer", "execution_profile": "prgs-reviewer",
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
}, },
"prgs-merger": { "prgs-merger": {
"enabled": True, "enabled": True,
@@ -65,6 +67,7 @@ CONFIG = {
"gitea.pr.create", "gitea.branch.push", "gitea.pr.approve", "gitea.pr.create", "gitea.branch.push", "gitea.pr.approve",
], ],
"execution_profile": "prgs-merger", "execution_profile": "prgs-merger",
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
}, },
"prgs-reconciler": { "prgs-reconciler": {
"enabled": True, "enabled": True,
@@ -81,6 +84,7 @@ CONFIG = {
"gitea.branch.push", "gitea.branch.push",
], ],
"execution_profile": "prgs-reconciler", "execution_profile": "prgs-reconciler",
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
}, },
}, },
"rules": {"allow_runtime_switching": False}, "rules": {"allow_runtime_switching": False},
+6 -3
View File
@@ -35,7 +35,8 @@ CONFIG_SWITCHING_DISABLED = {
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"}, "auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read", "gitea.pr.create", "gitea.branch.push"], "allowed_operations": ["gitea.read", "gitea.pr.create", "gitea.branch.push"],
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"], "forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
"execution_profile": "author-profile" "execution_profile": "author-profile",
"allowed_repositories": ["Example-Org/Example-Repo"],
}, },
"reviewer-profile": { "reviewer-profile": {
"enabled": True, "enabled": True,
@@ -45,7 +46,8 @@ CONFIG_SWITCHING_DISABLED = {
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"}, "auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve"], "allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve"],
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"], "forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
"execution_profile": "reviewer-profile" "execution_profile": "reviewer-profile",
"allowed_repositories": ["Example-Org/Example-Repo"],
}, },
"merger-profile": { "merger-profile": {
"enabled": True, "enabled": True,
@@ -55,7 +57,8 @@ CONFIG_SWITCHING_DISABLED = {
"auth": {"type": "env", "name": "GITEA_TOKEN_MERGER"}, "auth": {"type": "env", "name": "GITEA_TOKEN_MERGER"},
"allowed_operations": ["gitea.read", "gitea.pr.merge"], "allowed_operations": ["gitea.read", "gitea.pr.merge"],
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.approve"], "forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.approve"],
"execution_profile": "merger-profile" "execution_profile": "merger-profile",
"allowed_repositories": ["Example-Org/Example-Repo"],
} }
}, },
"rules": { "rules": {
@@ -1,15 +1,11 @@
"""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 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 os
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
-426
View File
@@ -1,426 +0,0 @@
"""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()
+6 -2
View File
@@ -1,7 +1,11 @@
"""Tests for canonical workflow skill naming and preflight (#551)."""
from __future__ import annotations 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 os
import tempfile import tempfile
import unittest import unittest