refactor!: remove legacy adapters, policy engine and API-key auth

Removes the FastAPI server, hand-rolled MCP protocol, tool registry and
tools, YAML policy, resource-type authz, API-key and OAuth proxy modules,
automation, observability, cache, the old Gitea client and stdio adapter,
the key-management scripts and their tests (ADR 0006).
This commit is contained in:
Latte committed 2026-10-14 15:40:37 +02:00
1 parent c0b22c531e
commit 4ac9626eb5
41 files changed
-13441

No files matched your search

-206
View File
@@ -1,206 +0,0 @@
"""Authentication module for MCP server API key validation."""
from __future__ import annotations
import hashlib
import hmac
import secrets
from datetime import datetime, timezone
from aegis_gitea_mcp.audit import get_audit_logger
from aegis_gitea_mcp.config import get_settings
class AuthenticationError(Exception):
"""Raised when authentication fails."""
class APIKeyValidator:
"""Validate API keys for MCP server access."""
def __init__(self) -> None:
"""Initialize API key validator state."""
self.settings = get_settings()
self.audit = get_audit_logger()
self._failed_attempts: dict[str, list[datetime]] = {}
def _constant_time_compare(self, candidate: str, expected: str) -> bool:
"""Compare API keys in constant time to mitigate timing attacks."""
return hmac.compare_digest(candidate, expected)
def _check_rate_limit(self, identifier: str) -> bool:
"""Check whether authentication failures exceed configured threshold."""
now = datetime.now(timezone.utc)
boundary = now.timestamp() - self.settings.auth_failure_window
if identifier in self._failed_attempts:
self._failed_attempts[identifier] = [
attempt
for attempt in self._failed_attempts[identifier]
if attempt.timestamp() > boundary
]
return len(self._failed_attempts.get(identifier, [])) < self.settings.max_auth_failures
def _record_failed_attempt(self, identifier: str) -> None:
"""Record a failed authentication attempt for rate limiting."""
attempt_time = datetime.now(timezone.utc)
self._failed_attempts.setdefault(identifier, []).append(attempt_time)
if len(self._failed_attempts[identifier]) >= self.settings.max_auth_failures:
self.audit.log_security_event(
event_type="auth_rate_limit_exceeded",
description="Authentication failure threshold exceeded",
severity="high",
metadata={
"identifier": identifier,
"failure_count": len(self._failed_attempts[identifier]),
"window_seconds": self.settings.auth_failure_window,
},
)
def validate_api_key(
self,
provided_key: str | None,
client_ip: str,
user_agent: str,
) -> tuple[bool, str | None]:
"""Validate an API key.
Args:
provided_key: API key provided by client.
client_ip: Request source IP address.
user_agent: Request user agent.
Returns:
Tuple of `(is_valid, error_message)`.
"""
if not self.settings.auth_enabled:
# Security note: auth-disabled mode is explicit and should be monitored.
self.audit.log_security_event(
event_type="auth_disabled",
description="Authentication disabled; request was allowed",
severity="critical",
metadata={"client_ip": client_ip},
)
return True, None
if not self._check_rate_limit(client_ip):
self.audit.log_access_denied(
tool_name="api_authentication",
reason="rate_limit_exceeded",
)
return False, "Too many failed authentication attempts. Please try again later."
if not provided_key:
self._record_failed_attempt(client_ip)
self.audit.log_access_denied(
tool_name="api_authentication",
reason="missing_api_key",
)
return False, "Authorization header missing. Required: Authorization: Bearer <api-key>"
if len(provided_key) < 32:
# Validation logic: reject short keys early to reduce brute force surface.
self._record_failed_attempt(client_ip)
self.audit.log_access_denied(
tool_name="api_authentication",
reason="invalid_key_format",
)
return False, "Invalid API key format"
valid_keys = self.settings.mcp_api_keys
if not valid_keys:
self.audit.log_security_event(
event_type="no_api_keys_configured",
description="No API keys configured while auth is enabled",
severity="critical",
metadata={"client_ip": client_ip},
)
return False, "Server configuration error: No API keys configured"
is_valid = any(
self._constant_time_compare(provided_key, valid_key) for valid_key in valid_keys
)
if is_valid:
key_fingerprint = hashlib.sha256(provided_key.encode("utf-8")).hexdigest()[:12]
self.audit.log_tool_invocation(
tool_name="api_authentication",
result_status="success",
params={
"client_ip": client_ip,
"user_agent": user_agent,
"key_fingerprint": key_fingerprint,
},
)
return True, None
self._record_failed_attempt(client_ip)
self.audit.log_access_denied(
tool_name="api_authentication",
reason="invalid_api_key",
)
self.audit.log_security_event(
event_type="invalid_api_key_attempt",
description="Invalid API key was presented",
severity="medium",
metadata={"client_ip": client_ip, "user_agent": user_agent},
)
return False, "Invalid API key"
def extract_bearer_token(self, authorization_header: str | None) -> str | None:
"""Extract API token from `Authorization: Bearer <token>` header.
Security note:
The scheme is case-sensitive by policy (`Bearer`) to prevent accepting
ambiguous client implementations and to align strict API contracts.
"""
if not authorization_header:
return None
parts = authorization_header.split(" ")
if len(parts) != 2:
return None
scheme, token = parts
if scheme != "Bearer":
return None
if not token.strip():
return None
return token.strip()
def generate_api_key(length: int = 64) -> str:
"""Generate a cryptographically secure API key.
Args:
length: Length of key in characters.
Returns:
Generated API key string.
"""
return secrets.token_hex(length // 2)
def hash_api_key(api_key: str) -> str:
"""Hash an API key for secure storage and comparison."""
return hashlib.sha256(api_key.encode("utf-8")).hexdigest()
_validator: APIKeyValidator | None = None
def get_validator() -> APIKeyValidator:
"""Get or create global API key validator instance."""
global _validator
if _validator is None:
_validator = APIKeyValidator()
return _validator
def reset_validator() -> None:
"""Reset global API key validator instance (primarily for testing)."""
global _validator
_validator = None
-342
View File
@@ -1,342 +0,0 @@
"""Resource-type-aware authorization (fail-closed).
The public HTTP server runs in *service-PAT mode*: a privileged bot token makes
the actual Gitea calls while a per-user OAuth identity decides what that user is
allowed to reach. For repository-scoped calls the server verifies the user's
collaborator permission on ``owner/repo``. This module closes the rest of the
gap — the admin/user/org/misc surface that ``gitea_request`` can now reach — by
classifying each call by *resource type* and enforcing a type-specific rule.
Every decision fails closed: if a call cannot be classified, or a required
permission cannot be positively verified against Gitea, it is denied and audited.
Rules (enforced only in service-PAT mode; in pure-OAuth mode the user's own
token already scopes every call at Gitea):
* ``repository`` — per-user collaborator permission (handled by the server's
existing repository check; not re-implemented here).
* ``org`` — the signed-in user must be a verified member of the target org.
* ``user_self`` — token-owner-scoped endpoints (``/user``, ``/notifications``).
Denied in service-PAT mode: the data belongs to the bot, not the caller.
* ``user_owned`` — a resource owned by a named user/org (``/users/{name}``,
``/packages/{owner}``). Allowed only when the owner is the caller, or the
caller is a verified member of the owning org.
* ``misc_global`` — instance-wide, read-only utility endpoints (markdown render,
version, gitignore templates …). Reads allowed; writes fall to policy.
* ``admin`` — default deny. Allowed only when the operator has opted in
(``RAW_API_ALLOW_SENSITIVE``) *and* the signed-in user is a verified Gitea
site administrator.
* ``unknown`` — denied.
"""
from __future__ import annotations
import urllib.parse
from dataclasses import dataclass
from enum import Enum
import httpx
from aegis_gitea_mcp.audit import get_audit_logger
from aegis_gitea_mcp.cache import BoundedTTLCache
from aegis_gitea_mcp.config import get_settings
from aegis_gitea_mcp.errors import ToolError
from aegis_gitea_mcp.tools.arguments import (
normalize_raw_endpoint,
parse_raw_repository,
raw_relative_segments,
raw_request_is_write,
)
class ResourceType(str, Enum):
"""Coarse resource classes used for authorization decisions."""
REPOSITORY = "repository"
ORG = "org"
USER_SELF = "user_self"
USER_OWNED = "user_owned"
MISC_GLOBAL = "misc_global"
ADMIN = "admin"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class ResourceClass:
"""Result of classifying a call by resource type."""
resource_type: ResourceType
is_write: bool
repository: str | None = None
org: str | None = None
owner: str | None = None
# Instance-wide, read-only utility prefixes: not owned by any user/org.
_MISC_GLOBAL_PREFIXES = frozenset(
{
"markdown",
"markup",
"version",
"gitignore",
"licenses",
"label",
"topics",
"nodeinfo",
"activitypub",
"miscellaneous",
"signing-key.gpg",
"settings",
}
)
# Token-owner-scoped prefixes ("me"/"my" endpoints).
_USER_SELF_PREFIXES = frozenset({"user", "notifications"})
def classify_raw_endpoint(method: str, endpoint: str) -> ResourceClass:
"""Classify a normalized raw ``/api/v1`` endpoint by resource type.
Args:
method: HTTP method (used only to set the read/write flag).
endpoint: A normalized ``/api/v1/...`` path.
Returns:
The resource classification; ``UNKNOWN`` when nothing matches (deny).
"""
is_write = raw_request_is_write(method, endpoint)
rel = raw_relative_segments(endpoint)
if not rel:
return ResourceClass(ResourceType.MISC_GLOBAL, is_write)
top = rel[0]
if top == "admin":
return ResourceClass(ResourceType.ADMIN, is_write)
if top in {"repos", "repositories"}:
repository = parse_raw_repository(endpoint)
# repository is None for cross-repo endpoints (search/issues) — those
# cannot be scoped to a single owner/repo and so fail closed downstream.
return ResourceClass(ResourceType.REPOSITORY, is_write, repository=repository)
if top in {"orgs", "org"}:
org = rel[1] if len(rel) >= 2 else None
return ResourceClass(ResourceType.ORG, is_write, org=org)
if top == "users":
owner = rel[1] if len(rel) >= 2 else None
return ResourceClass(ResourceType.USER_OWNED, is_write, owner=owner)
if top == "packages":
owner = rel[1] if len(rel) >= 2 else None
return ResourceClass(ResourceType.USER_OWNED, is_write, owner=owner)
if top in _USER_SELF_PREFIXES:
return ResourceClass(ResourceType.USER_SELF, is_write)
if top in _MISC_GLOBAL_PREFIXES:
return ResourceClass(ResourceType.MISC_GLOBAL, is_write)
return ResourceClass(ResourceType.UNKNOWN, is_write)
def classify_tool(tool_name: str, arguments: dict[str, object]) -> ResourceClass:
"""Classify a dispatched tool call (typed tool or ``gitea_request``).
Repository-scoped typed tools are handled by the server's repository check,
so this primarily classifies the non-repo surface that this module gates.
"""
if tool_name == "gitea_request":
method = str(arguments.get("method", "GET"))
path = str(arguments.get("path", ""))
try:
endpoint = normalize_raw_endpoint(path)
except ValueError:
return ResourceClass(ResourceType.UNKNOWN, is_write=True)
return classify_raw_endpoint(method, endpoint)
if tool_name == "list_org_repositories":
org = arguments.get("org")
return ResourceClass(
ResourceType.ORG, is_write=False, org=org if isinstance(org, str) else None
)
if tool_name == "list_organizations":
# Backed by /user/orgs: token-owner-scoped, not attributable to the caller
# in service-PAT mode.
return ResourceClass(ResourceType.USER_SELF, is_write=False)
# Any other non-repository tool is unrecognized for the purpose of this gate.
return ResourceClass(ResourceType.UNKNOWN, is_write=False)
# Bounded, short-TTL caches for positive verification results (fail-closed:
# only successful checks are cached).
_org_membership_cache: BoundedTTLCache[str, bool] | None = None
_site_admin_cache: BoundedTTLCache[str, bool] | None = None
def _get_org_membership_cache() -> BoundedTTLCache[str, bool]:
global _org_membership_cache
if _org_membership_cache is None:
ttl = get_settings().repo_authz_cache_ttl_seconds
_org_membership_cache = BoundedTTLCache(ttl_seconds=ttl, max_size=2048)
return _org_membership_cache
def _get_site_admin_cache() -> BoundedTTLCache[str, bool]:
global _site_admin_cache
if _site_admin_cache is None:
ttl = get_settings().repo_authz_cache_ttl_seconds
_site_admin_cache = BoundedTTLCache(ttl_seconds=ttl, max_size=2048)
return _site_admin_cache
def reset_authz_caches() -> None:
"""Reset authorization caches (primarily for tests)."""
global _org_membership_cache, _site_admin_cache
_org_membership_cache = None
_site_admin_cache = None
async def _service_get(path: str) -> httpx.Response | None:
"""GET ``path`` on Gitea with the service PAT; None on transport failure."""
settings = get_settings()
token = settings.gitea_token.strip()
if not token:
return None
url = f"{settings.gitea_base_url}{path}"
try:
async with httpx.AsyncClient(timeout=settings.request_timeout_seconds) as client:
return await client.get(
url,
headers={"Authorization": f"token {token}", "Accept": "application/json"},
)
except httpx.RequestError:
return None
async def verify_org_membership(*, org: str, user_login: str) -> bool:
"""Return True only if ``user_login`` is a verified member of ``org``.
Fails closed: any transport error, non-204 response, or missing identity
yields False.
"""
if not org or not user_login or user_login == "unknown":
return False
cache_key = f"{org.lower()}:{user_login.lower()}"
cache = _get_org_membership_cache()
if cache.get(cache_key) is True:
return True
encoded_org = urllib.parse.quote(org, safe="")
encoded_user = urllib.parse.quote(user_login, safe="")
response = await _service_get(f"/api/v1/orgs/{encoded_org}/members/{encoded_user}")
if response is not None and response.status_code == 204:
cache.set(cache_key, True)
return True
return False
async def verify_site_admin(*, user_login: str) -> bool:
"""Return True only if ``user_login`` is a verified Gitea site administrator.
Requires the service PAT to have admin visibility (so ``is_admin`` is
returned). Fails closed on any error or when the flag is not positively True.
"""
if not user_login or user_login == "unknown":
return False
cache_key = user_login.lower()
cache = _get_site_admin_cache()
if cache.get(cache_key) is True:
return True
encoded_user = urllib.parse.quote(user_login, safe="")
response = await _service_get(f"/api/v1/users/{encoded_user}")
if response is None or response.status_code != 200:
return False
try:
payload = response.json()
except ValueError:
return False
if isinstance(payload, dict) and payload.get("is_admin") is True:
cache.set(cache_key, True)
return True
return False
async def authorize_non_repository_access(
*,
classification: ResourceClass,
user_login: str,
tool_name: str,
correlation_id: str | None = None,
) -> None:
"""Enforce the resource-type rule for a non-repository call (service-PAT mode).
Raises:
ToolError: with status 403 when the call is denied. The repository type
is intentionally not handled here — the server's existing per-user
collaborator check owns it.
"""
audit = get_audit_logger()
settings = get_settings()
login = (user_login or "").strip()
def _deny(reason: str) -> ToolError:
audit.log_access_denied(
tool_name=tool_name,
repository=classification.repository,
reason=f"resource_authz:{classification.resource_type.value}:{reason}",
correlation_id=correlation_id,
)
return ToolError(
f"Access denied for {classification.resource_type.value} resource: {reason}",
status_code=403,
)
rtype = classification.resource_type
if rtype == ResourceType.REPOSITORY:
# Reached only when a repo-scoped path could not be parsed to owner/repo
# (e.g. cross-repo search). Cannot verify per-user permission -> deny.
raise _deny("repository could not be determined")
if rtype == ResourceType.ORG:
if not classification.org:
raise _deny("organization not specified")
if await verify_org_membership(org=classification.org, user_login=login):
return
raise _deny("user is not a verified member of the organization")
if rtype == ResourceType.USER_OWNED:
owner = (classification.owner or "").strip()
if not owner:
raise _deny("resource owner not specified")
if owner.lower() == login.lower() and login:
return
# The owner may be an organization the caller belongs to.
if await verify_org_membership(org=owner, user_login=login):
return
raise _deny("resource owner is neither the caller nor a member org")
if rtype == ResourceType.USER_SELF:
# Token-owner-scoped data; in service-PAT mode the token is the bot's, so
# the result cannot be attributed to the caller.
raise _deny("token-owner-scoped endpoint is not available in service-PAT mode")
if rtype == ResourceType.MISC_GLOBAL:
if not classification.is_write:
return
# Writes to global utility endpoints are not part of the safe surface.
raise _deny("write to a global endpoint is not permitted")
if rtype == ResourceType.ADMIN:
if not settings.raw_api_allow_sensitive:
raise _deny("admin surface is disabled (set RAW_API_ALLOW_SENSITIVE=true to opt in)")
if await verify_site_admin(user_login=login):
return
raise _deny("user is not a verified site administrator")
raise _deny("unclassified resource")
-229
View File
@@ -1,229 +0,0 @@
"""Automation workflows for webhooks and scheduled jobs."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from aegis_gitea_mcp.audit import get_audit_logger
from aegis_gitea_mcp.config import get_settings
from aegis_gitea_mcp.gitea_client import GiteaClient
from aegis_gitea_mcp.policy import get_policy_engine
class AutomationError(RuntimeError):
"""Raised when an automation action is denied or invalid."""
def _parse_timestamp(value: str) -> datetime | None:
"""Parse ISO8601 timestamp with best-effort normalization."""
normalized = value.replace("Z", "+00:00")
try:
return datetime.fromisoformat(normalized)
except ValueError:
return None
class AutomationManager:
"""Policy-controlled automation manager."""
def __init__(self) -> None:
"""Initialize automation manager with runtime services."""
self.settings = get_settings()
self.audit = get_audit_logger()
async def handle_webhook(
self,
event_type: str,
payload: dict[str, Any],
repository: str | None,
) -> dict[str, Any]:
"""Handle inbound webhook event.
Args:
event_type: Event type identifier.
payload: Event payload body.
repository: Optional target repository (`owner/repo`).
Returns:
Result summary for webhook processing.
"""
if not self.settings.automation_enabled:
raise AutomationError("automation is disabled")
decision = get_policy_engine().authorize(
tool_name="automation_webhook_ingest",
is_write=False,
repository=repository,
)
if not decision.allowed:
raise AutomationError(f"policy denied webhook: {decision.reason}")
self.audit.log_tool_invocation(
tool_name="automation_webhook_ingest",
repository=repository,
params={"event_type": event_type},
result_status="success",
)
# Safe default: treat webhook payload as data only.
return {
"status": "accepted",
"event_type": event_type,
"repository": repository,
"keys": sorted(payload.keys()),
}
async def run_job(
self,
job_name: str,
owner: str,
repo: str,
user_token: str | None = None,
finding_title: str | None = None,
finding_body: str | None = None,
) -> dict[str, Any]:
"""Run a named automation job for a repository.
Args:
job_name: Job identifier.
owner: Repository owner.
repo: Repository name.
Returns:
Job execution summary.
"""
if not self.settings.automation_enabled:
raise AutomationError("automation is disabled")
repository = f"{owner}/{repo}"
is_write = job_name == "auto_issue_creation"
decision = get_policy_engine().authorize(
tool_name=f"automation_{job_name}",
is_write=is_write,
repository=repository,
)
if not decision.allowed:
raise AutomationError(f"policy denied automation job: {decision.reason}")
if job_name == "dependency_hygiene_scan":
return await self._dependency_hygiene_scan(owner, repo)
if job_name == "stale_issue_detection":
return await self._stale_issue_detection(owner, repo, user_token=user_token)
if job_name == "auto_issue_creation":
return await self._auto_issue_creation(
owner,
repo,
user_token=user_token,
finding_title=finding_title,
finding_body=finding_body,
)
raise AutomationError(f"unsupported automation job: {job_name}")
async def _dependency_hygiene_scan(self, owner: str, repo: str) -> dict[str, Any]:
"""Run dependency hygiene scan placeholder workflow.
Security note:
This job intentionally performs read-only checks and does not mutate
repository state directly.
"""
repository = f"{owner}/{repo}"
self.audit.log_tool_invocation(
tool_name="automation_dependency_hygiene_scan",
repository=repository,
result_status="success",
)
# Placeholder output for policy-controlled automation scaffold.
return {
"job": "dependency_hygiene_scan",
"repository": repository,
"status": "completed",
"findings": [],
}
async def _stale_issue_detection(
self, owner: str, repo: str, user_token: str | None
) -> dict[str, Any]:
"""Detect stale issues using repository issue metadata."""
repository = f"{owner}/{repo}"
cutoff = datetime.now(timezone.utc) - timedelta(days=self.settings.automation_stale_days)
if not user_token:
raise AutomationError("missing authenticated user token")
stale_issue_numbers: list[int] = []
async with GiteaClient(token=user_token) as gitea:
issues = await gitea.list_issues(
owner,
repo,
state="open",
page=1,
limit=100,
labels=None,
)
for issue in issues:
updated_at = issue.get("updated_at")
if not isinstance(updated_at, str):
continue
parsed = _parse_timestamp(updated_at)
if parsed and parsed < cutoff:
number = issue.get("number")
if isinstance(number, int):
stale_issue_numbers.append(number)
self.audit.log_tool_invocation(
tool_name="automation_stale_issue_detection",
repository=repository,
params={"stale_count": len(stale_issue_numbers)},
result_status="success",
)
return {
"job": "stale_issue_detection",
"repository": repository,
"status": "completed",
"stale_issue_numbers": stale_issue_numbers,
"stale_count": len(stale_issue_numbers),
}
async def _auto_issue_creation(
self,
owner: str,
repo: str,
user_token: str | None,
finding_title: str | None,
finding_body: str | None,
) -> dict[str, Any]:
"""Create issue from automation finding payload."""
repository = f"{owner}/{repo}"
title = finding_title or "Automated security finding"
body = finding_body or "Automated finding created by Aegis automation workflow."
if not user_token:
raise AutomationError("missing authenticated user token")
async with GiteaClient(token=user_token) as gitea:
issue = await gitea.create_issue(
owner,
repo,
title=title,
body=body,
labels=["security", "automation"],
assignees=None,
)
issue_number = issue.get("number", 0) if isinstance(issue, dict) else 0
self.audit.log_tool_invocation(
tool_name="automation_auto_issue_creation",
repository=repository,
params={"issue_number": issue_number},
result_status="success",
)
return {
"job": "auto_issue_creation",
"repository": repository,
"status": "completed",
"issue_number": issue_number,
}
-71
View File
@@ -1,71 +0,0 @@
"""Bounded, TTL-based in-memory caches with size eviction.
Provides a small dependency-free cache used by the auth middleware and the
per-user authorization layer. Entries expire after a TTL and the cache is
bounded by a maximum size to prevent unbounded memory growth from untrusted
key cardinality (e.g. one entry per distinct token or per (user, repo) pair).
"""
from __future__ import annotations
import time
from collections import OrderedDict
from typing import Generic, TypeVar
K = TypeVar("K")
V = TypeVar("V")
class BoundedTTLCache(Generic[K, V]):
"""A size-bounded cache whose entries expire after a fixed TTL.
Eviction is least-recently-inserted (FIFO) once ``max_size`` is reached.
Expired entries are removed lazily on access and proactively when the
cache is full, so the cache never exceeds ``max_size`` live entries.
"""
def __init__(self, *, ttl_seconds: float, max_size: int = 1024) -> None:
"""Initialize the cache with a TTL and maximum entry count."""
if ttl_seconds <= 0:
raise ValueError("ttl_seconds must be positive")
if max_size <= 0:
raise ValueError("max_size must be positive")
self._ttl = float(ttl_seconds)
self._max_size = int(max_size)
self._store: OrderedDict[K, tuple[V, float]] = OrderedDict()
def get(self, key: K) -> V | None:
"""Return the cached value for ``key`` or ``None`` if absent/expired."""
entry = self._store.get(key)
if entry is None:
return None
value, expiry = entry
if time.monotonic() >= expiry:
# Lazily evict expired entry.
self._store.pop(key, None)
return None
return value
def set(self, key: K, value: V) -> None:
"""Store ``value`` under ``key`` with the configured TTL."""
now = time.monotonic()
# Drop the existing entry so reinsertion refreshes ordering.
self._store.pop(key, None)
self._store[key] = (value, now + self._ttl)
self._evict(now)
def _evict(self, now: float) -> None:
"""Remove expired entries, then enforce the size bound (FIFO)."""
expired = [key for key, (_, expiry) in self._store.items() if now >= expiry]
for key in expired:
self._store.pop(key, None)
while len(self._store) > self._max_size:
self._store.popitem(last=False)
def clear(self) -> None:
"""Remove all entries (primarily for tests)."""
self._store.clear()
def __len__(self) -> int:
"""Return the number of stored (not necessarily live) entries."""
return len(self._store)
File diff suppressed because it is too large. Load diff
-761
View File
@@ -1,761 +0,0 @@
"""MCP protocol models and tool registry."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class MCPTool(BaseModel):
"""MCP tool definition."""
name: str = Field(..., description="Unique tool identifier")
description: str = Field(..., description="Human-readable tool description")
input_schema: dict[str, Any] = Field(
...,
alias="inputSchema",
serialization_alias="inputSchema",
description="JSON schema describing input arguments",
)
write_operation: bool = Field(default=False, description="Whether tool mutates data")
model_config = ConfigDict(populate_by_name=True)
class MCPToolCallRequest(BaseModel):
"""Request to invoke an MCP tool."""
tool: str = Field(..., description="Name of the tool to invoke")
arguments: dict[str, Any] = Field(default_factory=dict, description="Tool argument payload")
correlation_id: str | None = Field(default=None, description="Request correlation ID")
model_config = ConfigDict(extra="forbid")
class MCPToolCallResponse(BaseModel):
"""Response returned from MCP tool invocation."""
success: bool = Field(..., description="Whether invocation succeeded")
result: Any | None = Field(default=None, description="Tool result payload")
error: str | None = Field(default=None, description="Error message for failed request")
correlation_id: str = Field(..., description="Correlation ID for request tracing")
class MCPListToolsResponse(BaseModel):
"""Response listing available tools."""
tools: list[MCPTool] = Field(..., description="Available tool definitions")
def _tool(
name: str, description: str, schema: dict[str, Any], write_operation: bool = False
) -> MCPTool:
"""Construct tool metadata entry."""
return MCPTool(
name=name,
description=description,
inputSchema=schema,
write_operation=write_operation,
)
AVAILABLE_TOOLS: list[MCPTool] = [
_tool(
"list_repositories",
"List repositories visible to the authenticated Gitea API token.",
{"type": "object", "properties": {}, "required": []},
),
_tool(
"get_repository_info",
"Get metadata for a repository.",
{
"type": "object",
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}},
"required": ["owner", "repo"],
"additionalProperties": False,
},
),
_tool(
"get_file_tree",
"Get repository tree at a selected ref.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"ref": {"type": "string", "default": "main"},
"recursive": {"type": "boolean", "default": False},
},
"required": ["owner", "repo"],
"additionalProperties": False,
},
),
_tool(
"get_file_contents",
"Read a repository file with size-limited content.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"filepath": {"type": "string"},
"ref": {"type": "string", "default": "main"},
},
"required": ["owner", "repo", "filepath"],
"additionalProperties": False,
},
),
_tool(
"search_code",
"Search code in a repository.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"query": {"type": "string"},
"ref": {"type": "string", "default": "main"},
"page": {"type": "integer", "minimum": 1, "default": 1},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25},
},
"required": ["owner", "repo", "query"],
"additionalProperties": False,
},
),
_tool(
"list_commits",
"List commits for a repository ref.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"ref": {"type": "string", "default": "main"},
"page": {"type": "integer", "minimum": 1, "default": 1},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25},
},
"required": ["owner", "repo"],
"additionalProperties": False,
},
),
_tool(
"get_commit_diff",
"Get commit metadata and file diffs.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"sha": {"type": "string"},
},
"required": ["owner", "repo", "sha"],
"additionalProperties": False,
},
),
_tool(
"compare_refs",
"Compare two repository refs.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"base": {"type": "string"},
"head": {"type": "string"},
},
"required": ["owner", "repo", "base", "head"],
"additionalProperties": False,
},
),
_tool(
"list_issues",
"List repository issues.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"state": {"type": "string", "enum": ["open", "closed", "all"], "default": "open"},
"page": {"type": "integer", "minimum": 1, "default": 1},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25},
"labels": {"type": "array", "items": {"type": "string"}, "default": []},
},
"required": ["owner", "repo"],
"additionalProperties": False,
},
),
_tool(
"get_issue",
"Get repository issue details.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"issue_number": {"type": "integer", "minimum": 1},
},
"required": ["owner", "repo", "issue_number"],
"additionalProperties": False,
},
),
_tool(
"list_pull_requests",
"List repository pull requests.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"state": {"type": "string", "enum": ["open", "closed", "all"], "default": "open"},
"page": {"type": "integer", "minimum": 1, "default": 1},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25},
},
"required": ["owner", "repo"],
"additionalProperties": False,
},
),
_tool(
"get_pull_request",
"Get pull request details.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"pull_number": {"type": "integer", "minimum": 1},
},
"required": ["owner", "repo", "pull_number"],
"additionalProperties": False,
},
),
_tool(
"list_labels",
"List labels defined on a repository.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"page": {"type": "integer", "minimum": 1, "default": 1},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
},
"required": ["owner", "repo"],
"additionalProperties": False,
},
),
_tool(
"list_tags",
"List repository tags.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"page": {"type": "integer", "minimum": 1, "default": 1},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
},
"required": ["owner", "repo"],
"additionalProperties": False,
},
),
_tool(
"list_releases",
"List repository releases.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"page": {"type": "integer", "minimum": 1, "default": 1},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25},
},
"required": ["owner", "repo"],
"additionalProperties": False,
},
),
_tool(
"list_pull_request_files",
"List files changed in a pull request.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"pull_number": {"type": "integer", "minimum": 1},
"page": {"type": "integer", "minimum": 1, "default": 1},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
},
"required": ["owner", "repo", "pull_number"],
"additionalProperties": False,
},
),
_tool(
"list_pull_request_commits",
"List commits in a pull request.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"pull_number": {"type": "integer", "minimum": 1},
"page": {"type": "integer", "minimum": 1, "default": 1},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
},
"required": ["owner", "repo", "pull_number"],
"additionalProperties": False,
},
),
_tool(
"list_issue_comments",
"List comments on an issue or pull request.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"issue_number": {"type": "integer", "minimum": 1},
"page": {"type": "integer", "minimum": 1, "default": 1},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
},
"required": ["owner", "repo", "issue_number"],
"additionalProperties": False,
},
),
_tool(
"list_branches",
"List repository branches.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"page": {"type": "integer", "minimum": 1, "default": 1},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
},
"required": ["owner", "repo"],
"additionalProperties": False,
},
),
_tool(
"get_branch",
"Get a single branch.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"branch": {"type": "string"},
},
"required": ["owner", "repo", "branch"],
"additionalProperties": False,
},
),
_tool(
"get_release",
"Get a release by id.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"release_id": {"type": "integer", "minimum": 1},
},
"required": ["owner", "repo", "release_id"],
"additionalProperties": False,
},
),
_tool(
"get_latest_release",
"Get the latest published release.",
{
"type": "object",
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}},
"required": ["owner", "repo"],
"additionalProperties": False,
},
),
_tool(
"list_milestones",
"List repository milestones.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"state": {"type": "string", "enum": ["open", "closed", "all"], "default": "open"},
"page": {"type": "integer", "minimum": 1, "default": 1},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
},
"required": ["owner", "repo"],
"additionalProperties": False,
},
),
_tool(
"get_commit_status",
"Get the combined commit status for a ref or sha.",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"sha": {"type": "string"},
},
"required": ["owner", "repo", "sha"],
"additionalProperties": False,
},
),
_tool(
"list_org_repositories",
"List repositories belonging to an organization.",
{
"type": "object",
"properties": {
"org": {"type": "string"},
"page": {"type": "integer", "minimum": 1, "default": 1},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
},
"required": ["org"],
"additionalProperties": False,
},
),
_tool(
"list_organizations",
"List organizations the authenticated user belongs to.",
{
"type": "object",
"properties": {
"page": {"type": "integer", "minimum": 1, "default": 1},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
},
"required": [],
"additionalProperties": False,
},
),
_tool(
"get_repo_languages",
"Get the language breakdown for a repository.",
{
"type": "object",
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}},
"required": ["owner", "repo"],
"additionalProperties": False,
},
),
_tool(
"list_repo_topics",
"List the topics assigned to a repository.",
{
"type": "object",
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}},
"required": ["owner", "repo"],
"additionalProperties": False,
},
),
_tool(
"create_issue",
"Create a repository issue (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"title": {"type": "string"},
"body": {"type": "string", "default": ""},
"labels": {"type": "array", "items": {"type": "string"}, "default": []},
"assignees": {"type": "array", "items": {"type": "string"}, "default": []},
"milestone": {
"type": ["integer", "string"],
"description": "Milestone id or title to assign the issue to",
},
},
"required": ["owner", "repo", "title"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"update_issue",
"Update issue title/body/state/milestone (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"issue_number": {"type": "integer", "minimum": 1},
"title": {"type": "string"},
"body": {"type": "string"},
"state": {"type": "string", "enum": ["open", "closed"]},
"milestone": {
"type": ["integer", "string"],
"description": "Milestone id or title to assign; 0 clears the milestone",
},
},
"required": ["owner", "repo", "issue_number"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"create_issue_comment",
"Create issue comment (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"issue_number": {"type": "integer", "minimum": 1},
"body": {"type": "string"},
},
"required": ["owner", "repo", "issue_number", "body"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"create_pr_comment",
"Create pull request comment (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"pull_number": {"type": "integer", "minimum": 1},
"body": {"type": "string"},
},
"required": ["owner", "repo", "pull_number", "body"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"add_labels",
"Add labels to an issue or PR (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"issue_number": {"type": "integer", "minimum": 1},
"labels": {"type": "array", "items": {"type": "string"}, "minItems": 1},
},
"required": ["owner", "repo", "issue_number", "labels"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"assign_issue",
"Assign users to issue or PR (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"issue_number": {"type": "integer", "minimum": 1},
"assignees": {"type": "array", "items": {"type": "string"}, "minItems": 1},
},
"required": ["owner", "repo", "issue_number", "assignees"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"create_label",
"Create a repository label (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"name": {"type": "string"},
"color": {"type": "string", "description": "Hex color, e.g. #00aabb"},
"description": {"type": "string", "default": ""},
"exclusive": {"type": "boolean", "default": False},
},
"required": ["owner", "repo", "name", "color"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"update_label",
"Update an existing repository label, located by its current name (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"name": {"type": "string", "description": "Current label name"},
"new_name": {"type": "string"},
"color": {"type": "string", "description": "Hex color, e.g. #00aabb"},
"description": {"type": "string"},
},
"required": ["owner", "repo", "name"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"remove_labels",
"Remove labels (by name) from an issue or pull request (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"issue_number": {"type": "integer", "minimum": 1},
"labels": {"type": "array", "items": {"type": "string"}, "minItems": 1},
},
"required": ["owner", "repo", "issue_number", "labels"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"create_pull_request",
"Open a pull request from head into base (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"title": {"type": "string"},
"head": {"type": "string", "description": "Source branch"},
"base": {"type": "string", "description": "Target branch"},
"body": {"type": "string", "default": ""},
},
"required": ["owner", "repo", "title", "head", "base"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"create_release",
"Create a release for a tag (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"tag_name": {"type": "string"},
"name": {"type": "string", "default": ""},
"body": {"type": "string", "default": ""},
"draft": {"type": "boolean", "default": False},
"prerelease": {"type": "boolean", "default": False},
"target": {"type": "string", "description": "Target commitish/branch"},
},
"required": ["owner", "repo", "tag_name"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"edit_release",
"Edit an existing release (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"release_id": {"type": "integer", "minimum": 1},
"name": {"type": "string"},
"body": {"type": "string"},
"draft": {"type": "boolean"},
"prerelease": {"type": "boolean"},
},
"required": ["owner", "repo", "release_id"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"create_branch",
"Create a branch, optionally from an existing branch (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"new_branch_name": {"type": "string"},
"old_branch_name": {"type": "string", "description": "Source branch (optional)"},
},
"required": ["owner", "repo", "new_branch_name"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"create_milestone",
"Create a repository milestone (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"title": {"type": "string"},
"description": {"type": "string", "default": ""},
"due_on": {"type": "string", "description": "ISO8601 due date (optional)"},
},
"required": ["owner", "repo", "title"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"edit_issue_comment",
"Edit an existing issue or PR comment (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"comment_id": {"type": "integer", "minimum": 1},
"body": {"type": "string"},
},
"required": ["owner", "repo", "comment_id", "body"],
"additionalProperties": False,
},
write_operation=True,
),
_tool(
"gitea_request",
(
"Generic escape hatch that calls an arbitrary Gitea REST endpoint "
"(method + path). Prefer the dedicated tools; use this only for "
"endpoints they do not cover. Subject to policy, write-mode and the "
"sensitive-path denylist. Methods other than GET/HEAD are writes and "
"require write-mode plus a whitelisted repository."
),
{
"type": "object",
"properties": {
"method": {
"type": "string",
"enum": ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"],
},
"path": {
"type": "string",
"description": "Gitea REST path, e.g. /repos/{owner}/{repo}/pulls/1/merge",
},
"query": {"type": "object", "description": "Optional query-string parameters"},
"body": {"type": "object", "description": "Optional JSON request body"},
},
"required": ["method", "path"],
"additionalProperties": False,
},
# write_operation is intentionally False: a static flag cannot describe a
# tool that is read OR write depending on the method. Setting it True
# would force the central write-mode gate on GETs and break reads. The
# handler is authoritative via its own per-method authorize() call.
write_operation=False,
),
]
def get_tool_by_name(tool_name: str) -> MCPTool | None:
"""Get tool definition by name."""
for tool in AVAILABLE_TOOLS:
if tool.name == tool_name:
return tool
return None
-386
View File
@@ -1,386 +0,0 @@
"""OAuth2/OIDC token validation for per-user Gitea authentication."""
from __future__ import annotations
import json
import time
from datetime import datetime, timezone
from typing import Any, cast
import httpx
import jwt
from jwt import InvalidTokenError
from jwt.algorithms import RSAAlgorithm
from aegis_gitea_mcp.audit import get_audit_logger
from aegis_gitea_mcp.config import get_settings
class OAuthTokenValidationError(RuntimeError):
"""Raised when a provided OAuth token cannot be trusted."""
def __init__(self, public_message: str, reason: str) -> None:
"""Initialize validation error details."""
super().__init__(public_message)
self.public_message = public_message
self.reason = reason
class GiteaOAuthValidator:
"""Validate per-user OAuth access tokens issued by Gitea."""
def __init__(self) -> None:
"""Initialize OAuth validator state and caches."""
self.settings = get_settings()
self.audit = get_audit_logger()
self._failed_attempts: dict[str, list[datetime]] = {}
self._discovery_cache: tuple[dict[str, Any], float] | None = None
self._jwks_cache: dict[str, tuple[dict[str, Any], float]] = {}
@staticmethod
def extract_bearer_token(authorization_header: str | None) -> str | None:
"""Extract token from `Authorization: Bearer <token>` header."""
if not authorization_header:
return None
scheme, separator, token = authorization_header.partition(" ")
if separator != " " or scheme != "Bearer":
return None
stripped = token.strip()
if not stripped or " " in stripped:
return None
return stripped
def _check_rate_limit(self, identifier: str) -> bool:
"""Check whether authentication failures exceed configured threshold."""
now = datetime.now(timezone.utc)
boundary = now.timestamp() - self.settings.auth_failure_window
if identifier in self._failed_attempts:
self._failed_attempts[identifier] = [
attempt
for attempt in self._failed_attempts[identifier]
if attempt.timestamp() > boundary
]
return len(self._failed_attempts.get(identifier, [])) < self.settings.max_auth_failures
def _record_failed_attempt(self, identifier: str) -> None:
"""Record a failed authentication attempt for rate limiting."""
attempt_time = datetime.now(timezone.utc)
self._failed_attempts.setdefault(identifier, []).append(attempt_time)
if len(self._failed_attempts[identifier]) >= self.settings.max_auth_failures:
self.audit.log_security_event(
event_type="oauth_rate_limit_exceeded",
description="OAuth authentication failure threshold exceeded",
severity="high",
metadata={
"identifier": identifier,
"failure_count": len(self._failed_attempts[identifier]),
"window_seconds": self.settings.auth_failure_window,
},
)
@staticmethod
def _looks_like_jwt(token: str) -> bool:
"""Return True when token has JWT segment structure."""
return token.count(".") == 2
@staticmethod
def _normalize_scopes(raw: Any) -> set[str]:
"""Normalize scope claim variations to a set."""
normalized: set[str] = set()
if isinstance(raw, str):
normalized.update(scope for scope in raw.split(" ") if scope)
elif isinstance(raw, list):
normalized.update(str(scope).strip() for scope in raw if str(scope).strip())
return normalized
def _extract_scopes(self, payload: dict[str, Any]) -> set[str]:
"""Extract scopes from JWT or userinfo payload."""
scopes = set()
scopes.update(self._normalize_scopes(payload.get("scope")))
scopes.update(self._normalize_scopes(payload.get("scopes")))
scopes.update(self._normalize_scopes(payload.get("scp")))
return scopes
async def _fetch_json_document(self, url: str) -> dict[str, Any]:
"""Fetch a JSON document from a trusted OAuth endpoint."""
try:
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
response = await client.get(url, headers={"Accept": "application/json"})
except httpx.RequestError as exc:
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_network_error",
) from exc
if response.status_code != 200:
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_metadata_unavailable",
)
try:
data = response.json()
except ValueError as exc:
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_metadata_invalid_json",
) from exc
if not isinstance(data, dict):
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_metadata_invalid_type",
)
return data
async def _get_discovery_document(self) -> dict[str, Any]:
"""Get cached OIDC discovery metadata."""
now = time.monotonic()
if self._discovery_cache and now < self._discovery_cache[1]:
return self._discovery_cache[0]
discovery_url = f"{self.settings.gitea_base_url}/.well-known/openid-configuration"
discovery = await self._fetch_json_document(discovery_url)
issuer = discovery.get("issuer")
jwks_uri = discovery.get("jwks_uri")
if not isinstance(issuer, str) or not issuer.strip():
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_discovery_missing_issuer",
)
if not isinstance(jwks_uri, str) or not jwks_uri.strip():
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_discovery_missing_jwks_uri",
)
self._discovery_cache = (discovery, now + self.settings.oauth_cache_ttl_seconds)
return discovery
async def _get_jwks(self, jwks_uri: str) -> dict[str, Any]:
"""Get cached JWKS document."""
now = time.monotonic()
cached = self._jwks_cache.get(jwks_uri)
if cached and now < cached[1]:
return cached[0]
jwks = await self._fetch_json_document(jwks_uri)
keys = jwks.get("keys")
if not isinstance(keys, list) or not keys:
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_jwks_missing_keys",
)
self._jwks_cache[jwks_uri] = (jwks, now + self.settings.oauth_cache_ttl_seconds)
return jwks
def _acceptable_audiences(self) -> list[str]:
"""Return the set of OIDC audiences this MCP server will accept.
Per the MCP authorization spec (RFC 8707 / RFC 9728) tokens are bound to
the MCP server's canonical resource URL, so the configured public base is
the primary accepted audience. The upstream Gitea OAuth client id is also
accepted because Gitea — the actual token issuer behind this proxy —
stamps ``aud`` with the client id rather than the MCP resource URL. An
operator may add a further required audience via OAUTH_EXPECTED_AUDIENCE.
"""
audiences: list[str] = []
canonical_resource = self.settings.public_base
if canonical_resource:
audiences.append(canonical_resource)
gitea_client_id = self.settings.gitea_oauth_client_id.strip()
if gitea_client_id:
audiences.append(gitea_client_id)
configured = self.settings.oauth_expected_audience.strip()
if configured:
audiences.append(configured)
# Preserve order while removing duplicates.
return list(dict.fromkeys(audiences))
async def _validate_jwt(self, token: str) -> dict[str, Any]:
"""Validate JWT access token using OIDC discovery and JWKS."""
discovery = await self._get_discovery_document()
issuer = str(discovery["issuer"]).rstrip("/")
jwks_uri = str(discovery["jwks_uri"])
jwks = await self._get_jwks(jwks_uri)
try:
header = jwt.get_unverified_header(token)
except InvalidTokenError as exc:
raise OAuthTokenValidationError(
"Invalid or expired OAuth token.", "oauth_jwt_header"
) from exc
algorithm = header.get("alg")
key_id = header.get("kid")
if algorithm != "RS256":
raise OAuthTokenValidationError("Invalid or expired OAuth token.", "oauth_jwt_alg")
if not isinstance(key_id, str) or not key_id.strip():
raise OAuthTokenValidationError("Invalid or expired OAuth token.", "oauth_jwt_kid")
matching_key = None
for key in jwks.get("keys", []):
if isinstance(key, dict) and key.get("kid") == key_id:
matching_key = key
break
if matching_key is None:
raise OAuthTokenValidationError(
"Invalid or expired OAuth token.", "oauth_jwt_key_not_found"
)
try:
public_key = RSAAlgorithm.from_jwk(json.dumps(matching_key))
except Exception as exc:
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_jwt_invalid_jwk",
) from exc
accepted_audiences = self._acceptable_audiences()
decode_options = cast(Any, {"verify_aud": bool(accepted_audiences)})
try:
claims = jwt.decode(
token,
key=cast(Any, public_key),
algorithms=["RS256"],
issuer=issuer,
audience=accepted_audiences or None,
options=decode_options,
)
except InvalidTokenError as exc:
raise OAuthTokenValidationError(
"Invalid or expired OAuth token.", "oauth_jwt_invalid"
) from exc
if not isinstance(claims, dict):
raise OAuthTokenValidationError("Invalid or expired OAuth token.", "oauth_jwt_claims")
scopes = self._extract_scopes(claims)
login = (
str(claims.get("preferred_username", "")).strip()
or str(claims.get("name", "")).strip()
or str(claims.get("sub", "unknown")).strip()
)
subject = str(claims.get("sub", login)).strip() or "unknown"
return {
"login": login,
"subject": subject,
"scopes": sorted(scopes),
}
async def _validate_userinfo(self, token: str) -> dict[str, Any]:
"""Validate token via Gitea userinfo endpoint (opaque token fallback)."""
userinfo_url = f"{self.settings.gitea_base_url}/login/oauth/userinfo"
try:
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
response = await client.get(
userinfo_url,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
},
)
except httpx.RequestError as exc:
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_userinfo_network",
) from exc
if response.status_code in {401, 403}:
raise OAuthTokenValidationError(
"Invalid or expired OAuth token.", "oauth_userinfo_denied"
)
if response.status_code != 200:
raise OAuthTokenValidationError(
"Unable to validate OAuth token at this time.",
"oauth_userinfo_unavailable",
)
try:
payload = response.json()
except ValueError as exc:
raise OAuthTokenValidationError(
"Invalid or expired OAuth token.", "oauth_userinfo_json"
) from exc
if not isinstance(payload, dict):
raise OAuthTokenValidationError(
"Invalid or expired OAuth token.", "oauth_userinfo_type"
)
scopes = self._extract_scopes(payload)
login = (
str(payload.get("preferred_username", "")).strip()
or str(payload.get("login", "")).strip()
or str(payload.get("name", "")).strip()
or str(payload.get("sub", "unknown")).strip()
)
subject = str(payload.get("sub", login)).strip() or "unknown"
return {
"login": login,
"subject": subject,
"scopes": sorted(scopes),
}
async def validate_oauth_token(
self,
token: str | None,
client_ip: str,
user_agent: str,
) -> tuple[bool, str | None, dict[str, Any] | None]:
"""Validate an incoming OAuth token and return principal context."""
if not self._check_rate_limit(client_ip):
return False, "Too many failed authentication attempts. Try again later.", None
if not token:
self._record_failed_attempt(client_ip)
return False, "Authorization header missing or empty.", None
try:
if self._looks_like_jwt(token):
try:
principal = await self._validate_jwt(token)
except OAuthTokenValidationError:
# Some providers issue opaque access tokens; verify those via userinfo.
principal = await self._validate_userinfo(token)
else:
principal = await self._validate_userinfo(token)
except OAuthTokenValidationError as exc:
self._record_failed_attempt(client_ip)
self.audit.log_access_denied(
tool_name="oauth_authentication",
reason=exc.reason,
)
return False, exc.public_message, None
self.audit.log_tool_invocation(
tool_name="oauth_authentication",
result_status="success",
params={
"client_ip": client_ip,
"user_agent": user_agent,
"gitea_user": principal.get("login", "unknown"),
},
)
return True, None, principal
_oauth_validator: GiteaOAuthValidator | None = None
def get_oauth_validator() -> GiteaOAuthValidator:
"""Get or create the global OAuth validator instance."""
global _oauth_validator
if _oauth_validator is None:
_oauth_validator = GiteaOAuthValidator()
return _oauth_validator
def reset_oauth_validator() -> None:
"""Reset the global OAuth validator instance (primarily for testing)."""
global _oauth_validator
_oauth_validator = None
-401
View File
@@ -1,401 +0,0 @@
"""OAuth proxy helpers for signed state, redirect validation, and DCR storage."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import secrets
import time
from fnmatch import fnmatchcase
from pathlib import Path
from typing import Any
from urllib.parse import ParseResult, urlparse, urlunparse
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
_CLAUDE_CALLBACK_URIS = {
"https://claude.ai/api/mcp/auth_callback",
"https://claude.com/api/mcp/auth_callback",
}
_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
_SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS = {"none", "client_secret_post"}
_SUPPORTED_GRANT_TYPES = {"authorization_code", "refresh_token"}
_SUPPORTED_RESPONSE_TYPES = {"code"}
class OAuthRegistrationRequest(BaseModel):
"""Incoming RFC 7591 client registration request."""
client_name: str | None = Field(default=None, max_length=200)
redirect_uris: list[str] = Field(..., min_length=1)
grant_types: list[str] = Field(default_factory=lambda: ["authorization_code", "refresh_token"])
response_types: list[str] = Field(default_factory=lambda: ["code"])
token_endpoint_auth_method: str = Field(default="none", max_length=64)
scope: str | None = Field(default=None, max_length=512)
model_config = ConfigDict(extra="forbid")
@field_validator("redirect_uris")
@classmethod
def validate_redirect_uris(cls, value: list[str]) -> list[str]:
"""Normalize and validate redirect URIs."""
uris = [uri.strip() for uri in value if isinstance(uri, str) and uri.strip()]
if not uris:
raise ValueError("redirect_uris must contain at least one non-empty URI")
return uris
@field_validator("grant_types")
@classmethod
def validate_grant_types(cls, value: list[str]) -> list[str]:
"""Restrict supported grant types to authorization code and refresh token."""
normalized = [item.strip() for item in value if item.strip()]
if not normalized:
raise ValueError("grant_types must not be empty")
if any(item not in _SUPPORTED_GRANT_TYPES for item in normalized):
raise ValueError("Unsupported grant_types requested")
return normalized
@field_validator("response_types")
@classmethod
def validate_response_types(cls, value: list[str]) -> list[str]:
"""Restrict supported response types to authorization code."""
normalized = [item.strip() for item in value if item.strip()]
if not normalized:
raise ValueError("response_types must not be empty")
if any(item not in _SUPPORTED_RESPONSE_TYPES for item in normalized):
raise ValueError("Unsupported response_types requested")
return normalized
@field_validator("token_endpoint_auth_method")
@classmethod
def validate_token_endpoint_auth_method(cls, value: str) -> str:
"""Restrict token endpoint auth methods to the supported subset."""
normalized = value.strip().lower()
if normalized not in _SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS:
raise ValueError("Unsupported token_endpoint_auth_method requested")
return normalized
@model_validator(mode="after")
def validate_pkce_ready(self) -> OAuthRegistrationRequest:
"""Ensure the request is usable for PKCE-based authorization code flow."""
if "authorization_code" not in self.grant_types:
raise ValueError("authorization_code grant is required")
if "code" not in self.response_types:
raise ValueError("code response type is required")
return self
class OAuthClientRecord(BaseModel):
"""Persisted OAuth client registration record."""
client_id: str
client_name: str | None = None
redirect_uris: list[str]
grant_types: list[str]
response_types: list[str]
token_endpoint_auth_method: str
client_id_issued_at: int
client_secret_expires_at: int = 0
client_secret_hash: str | None = None
scope: str | None = None
model_config = ConfigDict(extra="forbid")
def _canonicalize_url(value: str) -> str:
"""Normalize a URL for comparison."""
parsed = urlparse(value.strip())
if not parsed.scheme or not parsed.netloc:
return ""
normalized = ParseResult(
scheme=parsed.scheme.lower(),
netloc=parsed.netloc.lower(),
path=parsed.path or "/",
params=parsed.params,
query=parsed.query,
fragment="",
)
return urlunparse(normalized).rstrip("/")
def is_loopback_redirect_uri(redirect_uri: str) -> bool:
"""Return whether a redirect URI uses a loopback host."""
parsed = urlparse(redirect_uri.strip())
if parsed.scheme != "http":
return False
host = (parsed.hostname or "").lower()
return host in _LOOPBACK_HOSTS
def is_claude_redirect_uri(redirect_uri: str) -> bool:
"""Return whether a redirect URI is a built-in Claude callback URL."""
return _canonicalize_url(redirect_uri) in _CLAUDE_CALLBACK_URIS
def is_redirect_uri_allowed(redirect_uri: str, allowlist: list[str]) -> bool:
"""Return whether a redirect URI is allowed by policy."""
normalized = _canonicalize_url(redirect_uri)
if not normalized:
return False
if is_loopback_redirect_uri(redirect_uri) or is_claude_redirect_uri(redirect_uri):
return True
for pattern in allowlist:
candidate = pattern.strip()
if not candidate:
continue
if fnmatchcase(normalized, _canonicalize_url(candidate) or candidate):
return True
if fnmatchcase(redirect_uri.strip(), candidate):
return True
return False
def is_origin_allowed(origin: str, request_base: str, public_base: str | None) -> bool:
"""Return whether a browser Origin is allowed for MCP transport requests."""
normalized_origin = _canonicalize_url(origin)
if not normalized_origin:
return False
expected_bases = [request_base.rstrip("/")]
if public_base:
expected_bases.append(public_base.rstrip("/"))
return normalized_origin in expected_bases
def encode_proxy_state(
secret: str,
redirect_uri: str,
original_state: str,
*,
ttl_seconds: int = 600,
) -> str:
"""Create a signed OAuth state wrapper for the proxy callback round-trip."""
payload = {
"redirect_uri": redirect_uri,
"state": original_state,
"issued_at": int(time.time()),
"nonce": secrets.token_urlsafe(16),
"ttl_seconds": ttl_seconds,
}
canonical_payload = json.dumps(payload, sort_keys=True, separators=(",", ":"))
signature = hmac.new(secret.encode("utf-8"), canonical_payload.encode("utf-8"), hashlib.sha256)
envelope = {
"payload": payload,
"signature": signature.hexdigest(),
}
return base64.urlsafe_b64encode(
json.dumps(envelope, sort_keys=True, separators=(",", ":")).encode("utf-8")
).decode("ascii")
def decode_proxy_state(secret: str, encoded_state: str) -> dict[str, str]:
"""Verify and unpack a signed OAuth state wrapper."""
try:
raw = base64.urlsafe_b64decode(encoded_state.encode("ascii"))
envelope = json.loads(raw)
except Exception as exc: # pragma: no cover - guarded by tests
raise ValueError("Invalid or missing state parameter") from exc
if not isinstance(envelope, dict):
raise ValueError("Invalid or missing state parameter")
payload = envelope.get("payload")
signature = envelope.get("signature")
if not isinstance(payload, dict) or not isinstance(signature, str):
raise ValueError("Invalid or missing state parameter")
canonical_payload = json.dumps(payload, sort_keys=True, separators=(",", ":"))
expected_signature = hmac.new(
secret.encode("utf-8"), canonical_payload.encode("utf-8"), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected_signature):
raise ValueError("Invalid or missing state parameter")
issued_at = payload.get("issued_at")
ttl_seconds = payload.get("ttl_seconds")
now = int(time.time())
if not isinstance(issued_at, int) or not isinstance(ttl_seconds, int):
raise ValueError("Invalid or missing state parameter")
if issued_at > now or now - issued_at > max(ttl_seconds, 1):
raise ValueError("Invalid or missing state parameter")
redirect_uri = payload.get("redirect_uri")
if not isinstance(redirect_uri, str) or not redirect_uri.strip():
raise ValueError("Invalid or missing state parameter")
original_state = payload.get("state")
if not isinstance(original_state, str):
raise ValueError("Invalid or missing state parameter")
return {"redirect_uri": redirect_uri, "state": original_state}
class OAuthClientRegistry:
"""Persisted OAuth client registry for dynamic client registration."""
def __init__(self, storage_path: Path) -> None:
"""Initialize registry storage.
The storage directory is created lazily at write time (see ``_persist``)
rather than here, so that read-only operations — the OAuth ``authorize``
and ``token`` proxies only ever *read* the registry — never require a
writable filesystem. This keeps those endpoints working on hardened,
read-only container roots where only an explicit data volume is writable.
"""
self.storage_path = storage_path
self._clients: dict[str, OAuthClientRecord] = {}
self._loaded = False
@staticmethod
def _hash_secret(secret: str) -> str:
"""Hash client secrets before persistence."""
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
def _load(self) -> None:
"""Load persisted registrations from disk once."""
if self._loaded:
return
self._loaded = True
if not self.storage_path.exists():
self._clients = {}
return
raw = json.loads(self.storage_path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise ValueError("Persisted DCR storage must be a JSON object")
clients: dict[str, OAuthClientRecord] = {}
for client_id, payload in raw.items():
if not isinstance(client_id, str):
raise ValueError("Persisted client id must be a string")
if not isinstance(payload, dict):
raise ValueError(f"Persisted client record for {client_id} must be a mapping")
record = OAuthClientRecord.model_validate({"client_id": client_id, **payload})
clients[client_id] = record
self._clients = clients
def _persist(self) -> None:
"""Write registrations atomically.
Dynamic client registration is the only operation that writes to disk, so
the storage directory is created here (not at construction time). This
requires the configured ``DCR_STORAGE_PATH`` to live on a writable,
persistent volume — see the deployment notes in the README.
"""
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
payload = {
client_id: record.model_dump(mode="json", exclude={"client_id"})
for client_id, record in self._clients.items()
}
tmp_path = self.storage_path.with_suffix(self.storage_path.suffix + ".tmp")
tmp_path.write_text(json.dumps(payload, sort_keys=True, indent=2), encoding="utf-8")
# Registration records hold client-secret hashes and metadata; restrict to the
# owning user before the atomic replace so the file is never briefly world-readable.
# chmod is a no-op on platforms without POSIX permission bits (e.g. Windows).
try:
os.chmod(tmp_path, 0o600)
except (OSError, NotImplementedError):
pass
tmp_path.replace(self.storage_path)
def get(self, client_id: str) -> OAuthClientRecord | None:
"""Look up a registered client by identifier."""
self._load()
return self._clients.get(client_id)
def is_known_client(
self,
client_id: str,
*,
fallback_client_id: str = "",
fallback_client_secret: str = "",
) -> bool:
"""Return whether a client is recognized by the registry or environment."""
if not client_id.strip():
return False
if fallback_client_id.strip() and client_id == fallback_client_id.strip():
return True
return self.get(client_id) is not None
def validate_client_secret(
self,
client_id: str,
client_secret: str | None,
*,
fallback_client_id: str = "",
fallback_client_secret: str = "",
) -> bool:
"""Validate a client identifier and optional secret."""
if fallback_client_id.strip() and client_id == fallback_client_id.strip():
if not fallback_client_secret.strip():
return True
if not client_secret:
return False
return hmac.compare_digest(
self._hash_secret(client_secret), self._hash_secret(fallback_client_secret.strip())
)
record = self.get(client_id)
if record is None:
return False
if record.client_secret_hash is None:
return True
if not client_secret:
return False
return hmac.compare_digest(self._hash_secret(client_secret), record.client_secret_hash)
def register(self, request: OAuthRegistrationRequest) -> dict[str, Any]:
"""Persist a new OAuth client registration and return its public metadata."""
self._load()
client_id = secrets.token_urlsafe(24)
client_secret: str | None = None
client_secret_hash: str | None = None
if request.token_endpoint_auth_method != "none":
client_secret = secrets.token_urlsafe(32)
client_secret_hash = self._hash_secret(client_secret)
record = OAuthClientRecord(
client_id=client_id,
client_name=request.client_name,
redirect_uris=list(request.redirect_uris),
grant_types=list(request.grant_types),
response_types=list(request.response_types),
token_endpoint_auth_method=request.token_endpoint_auth_method,
client_id_issued_at=int(time.time()),
client_secret_hash=client_secret_hash,
scope=request.scope,
)
self._clients[client_id] = record
self._persist()
response: dict[str, Any] = record.model_dump(exclude={"client_secret_hash"})
if client_secret is not None:
response["client_secret"] = client_secret
response["client_secret_expires_at"] = 0
return response
_oauth_client_registry: OAuthClientRegistry | None = None
def get_oauth_client_registry(storage_path: Path) -> OAuthClientRegistry:
"""Get or create the global OAuth client registry."""
global _oauth_client_registry
if _oauth_client_registry is None or _oauth_client_registry.storage_path != storage_path:
_oauth_client_registry = OAuthClientRegistry(storage_path)
return _oauth_client_registry
def reset_oauth_client_registry() -> None:
"""Reset the global OAuth client registry (primarily for tests)."""
global _oauth_client_registry
_oauth_client_registry = None
-98
View File
@@ -1,98 +0,0 @@
"""Observability primitives: metrics and lightweight instrumentation."""
from __future__ import annotations
import time
from collections import defaultdict
from dataclasses import dataclass
from threading import Lock
@dataclass(frozen=True)
class ToolTiming:
"""Aggregated tool timing stats."""
count: int
total_seconds: float
class MetricsRegistry:
"""In-process Prometheus-compatible metrics storage."""
def __init__(self) -> None:
"""Initialize empty metrics state."""
self._lock = Lock()
self._http_requests_total: defaultdict[tuple[str, str, str], int] = defaultdict(int)
self._tool_calls_total: defaultdict[tuple[str, str], int] = defaultdict(int)
self._tool_duration_seconds: defaultdict[str, float] = defaultdict(float)
self._tool_duration_count: defaultdict[str, int] = defaultdict(int)
def record_http_request(self, method: str, path: str, status_code: int) -> None:
"""Record completed HTTP request metric."""
with self._lock:
self._http_requests_total[(method, path, str(status_code))] += 1
def record_tool_call(self, tool_name: str, status: str, duration_seconds: float) -> None:
"""Record tool invocation counters and duration aggregates."""
with self._lock:
self._tool_calls_total[(tool_name, status)] += 1
self._tool_duration_seconds[tool_name] += max(duration_seconds, 0.0)
self._tool_duration_count[tool_name] += 1
def render_prometheus(self) -> str:
"""Render metrics in Prometheus exposition format."""
lines: list[str] = []
lines.append("# HELP aegis_http_requests_total Total HTTP requests")
lines.append("# TYPE aegis_http_requests_total counter")
with self._lock:
for (method, path, status), count in sorted(self._http_requests_total.items()):
lines.append(
"aegis_http_requests_total"
f'{{method="{method}",path="{path}",status="{status}"}} {count}'
)
lines.append("# HELP aegis_tool_calls_total Total MCP tool calls")
lines.append("# TYPE aegis_tool_calls_total counter")
for (tool_name, status), count in sorted(self._tool_calls_total.items()):
lines.append(
f'aegis_tool_calls_total{{tool="{tool_name}",status="{status}"}} {count}'
)
lines.append(
"# HELP aegis_tool_duration_seconds_sum Sum of MCP tool call duration seconds"
)
lines.append("# TYPE aegis_tool_duration_seconds_sum counter")
for tool_name, total in sorted(self._tool_duration_seconds.items()):
lines.append(f'aegis_tool_duration_seconds_sum{{tool="{tool_name}"}} {total:.6f}')
lines.append(
"# HELP aegis_tool_duration_seconds_count MCP tool call duration sample count"
)
lines.append("# TYPE aegis_tool_duration_seconds_count counter")
for tool_name, count in sorted(self._tool_duration_count.items()):
lines.append(f'aegis_tool_duration_seconds_count{{tool="{tool_name}"}} {count}')
return "\n".join(lines) + "\n"
_metrics_registry: MetricsRegistry | None = None
def get_metrics_registry() -> MetricsRegistry:
"""Get global metrics registry."""
global _metrics_registry
if _metrics_registry is None:
_metrics_registry = MetricsRegistry()
return _metrics_registry
def reset_metrics_registry() -> None:
"""Reset global metrics registry for tests."""
global _metrics_registry
_metrics_registry = None
def monotonic_seconds() -> float:
"""Expose monotonic timer for deterministic instrumentation."""
return time.monotonic()
-265
View File
@@ -1,265 +0,0 @@
"""Policy engine for tool authorization decisions."""
from __future__ import annotations
from dataclasses import dataclass, field
from fnmatch import fnmatch
from pathlib import Path
from typing import Any
import yaml # type: ignore[import-untyped]
from aegis_gitea_mcp.config import get_settings
class PolicyError(Exception):
"""Raised when policy loading or validation fails."""
@dataclass(frozen=True)
class PolicyDecision:
"""Authorization result for a policy check."""
allowed: bool
reason: str
@dataclass(frozen=True)
class RuleSet:
"""Allow/Deny rules for tools."""
allow: set[str] = field(default_factory=set)
deny: set[str] = field(default_factory=set)
@dataclass(frozen=True)
class PathRules:
"""Allow/Deny rules for target file paths."""
allow: tuple[str, ...] = ()
deny: tuple[str, ...] = ()
@dataclass(frozen=True)
class RepositoryPolicy:
"""Repository-scoped policy rules."""
tools: RuleSet = field(default_factory=RuleSet)
paths: PathRules = field(default_factory=PathRules)
@dataclass(frozen=True)
class PolicyConfig:
"""Parsed policy configuration."""
default_read: str = "allow"
default_write: str = "deny"
tools: RuleSet = field(default_factory=RuleSet)
repositories: dict[str, RepositoryPolicy] = field(default_factory=dict)
class PolicyEngine:
"""Evaluates authorization decisions for MCP tools."""
def __init__(self, config: PolicyConfig) -> None:
"""Initialize policy engine with prevalidated config."""
self.config = config
self.settings = get_settings()
@classmethod
def from_yaml_file(cls, policy_path: Path) -> PolicyEngine:
"""Build a policy engine from YAML policy file.
Args:
policy_path: Path to policy YAML file.
Returns:
Initialized policy engine.
Raises:
PolicyError: If file is malformed or violates policy schema.
"""
if not policy_path.exists():
# Secure default for writes, backwards-compatible allow for reads.
return cls(PolicyConfig())
try:
raw = yaml.safe_load(policy_path.read_text(encoding="utf-8"))
except Exception as exc:
raise PolicyError(f"Failed to parse policy YAML: {exc}") from exc
if raw is None:
return cls(PolicyConfig())
if not isinstance(raw, dict):
raise PolicyError("Policy root must be a mapping")
defaults = raw.get("defaults", {})
if defaults and not isinstance(defaults, dict):
raise PolicyError("defaults must be a mapping")
default_read = str(defaults.get("read", "allow")).lower()
default_write = str(defaults.get("write", "deny")).lower()
if default_read not in {"allow", "deny"}:
raise PolicyError("defaults.read must be 'allow' or 'deny'")
if default_write not in {"allow", "deny"}:
raise PolicyError("defaults.write must be 'allow' or 'deny'")
global_tools = cls._parse_tool_rules(raw.get("tools", {}), "tools")
repositories_raw = raw.get("repositories", {})
if repositories_raw is None:
repositories_raw = {}
if not isinstance(repositories_raw, dict):
raise PolicyError("repositories must be a mapping")
repositories: dict[str, RepositoryPolicy] = {}
for repo_name, repo_payload in repositories_raw.items():
if not isinstance(repo_name, str) or "/" not in repo_name:
raise PolicyError("Repository keys must be in 'owner/repo' format")
if not isinstance(repo_payload, dict):
raise PolicyError(f"Repository policy for {repo_name} must be a mapping")
tool_rules = cls._parse_tool_rules(
repo_payload.get("tools", {}),
f"repositories.{repo_name}.tools",
)
path_payload = repo_payload.get("paths", {})
if path_payload and not isinstance(path_payload, dict):
raise PolicyError(f"repositories.{repo_name}.paths must be a mapping")
allow_paths = cls._parse_path_list(path_payload.get("allow", []), "allow")
deny_paths = cls._parse_path_list(path_payload.get("deny", []), "deny")
repositories[repo_name] = RepositoryPolicy(
tools=tool_rules,
paths=PathRules(allow=allow_paths, deny=deny_paths),
)
return cls(
PolicyConfig(
default_read=default_read,
default_write=default_write,
tools=global_tools,
repositories=repositories,
)
)
@staticmethod
def _parse_tool_rules(raw_rules: Any, location: str) -> RuleSet:
"""Parse tool allow/deny mapping from raw payload."""
if not raw_rules:
return RuleSet()
if not isinstance(raw_rules, dict):
raise PolicyError(f"{location} must be a mapping")
allow = raw_rules.get("allow", [])
deny = raw_rules.get("deny", [])
if not isinstance(allow, list) or not all(isinstance(item, str) for item in allow):
raise PolicyError(f"{location}.allow must be a list of strings")
if not isinstance(deny, list) or not all(isinstance(item, str) for item in deny):
raise PolicyError(f"{location}.deny must be a list of strings")
return RuleSet(allow=set(allow), deny=set(deny))
@staticmethod
def _parse_path_list(raw_paths: Any, label: str) -> tuple[str, ...]:
"""Parse path allow/deny list."""
if raw_paths is None:
return ()
if not isinstance(raw_paths, list) or not all(isinstance(item, str) for item in raw_paths):
raise PolicyError(f"paths.{label} must be a list of strings")
return tuple(raw_paths)
@staticmethod
def _normalize_target_path(path: str) -> str:
"""Normalize path before policy matching.
Security note:
Path normalization blocks traversal attempts before fnmatch
comparisons are executed.
"""
normalized = path.replace("\\", "/").lstrip("/")
parts = [part for part in normalized.split("/") if part and part != "."]
if any(part == ".." for part in parts):
raise PolicyError("Target path contains traversal sequence '..'")
return "/".join(parts)
def authorize(
self,
tool_name: str,
is_write: bool,
repository: str | None = None,
target_path: str | None = None,
) -> PolicyDecision:
"""Evaluate whether a tool call is authorized by policy.
Args:
tool_name: Invoked MCP tool name.
is_write: Whether the tool mutates data.
repository: Optional `owner/repo` target repository.
target_path: Optional file path target.
Returns:
Policy decision indicating allow/deny and reason.
"""
if tool_name in self.config.tools.deny:
return PolicyDecision(False, "tool denied by global policy")
if self.config.tools.allow and tool_name not in self.config.tools.allow:
return PolicyDecision(False, "tool not allowed by global policy")
if is_write:
if not self.settings.write_mode:
return PolicyDecision(False, "write mode is disabled")
if not repository:
return PolicyDecision(False, "write operation requires a repository target")
if (
not self.settings.write_allow_all_token_repos
and repository not in self.settings.write_repository_whitelist
):
return PolicyDecision(False, "repository is not in write-mode whitelist")
repo_policy = self.config.repositories.get(repository) if repository else None
if repo_policy:
if tool_name in repo_policy.tools.deny:
return PolicyDecision(False, "tool denied for repository")
if repo_policy.tools.allow and tool_name not in repo_policy.tools.allow:
return PolicyDecision(False, "tool not allowed for repository")
if target_path:
normalized_path = self._normalize_target_path(target_path)
if repo_policy.paths.deny and any(
fnmatch(normalized_path, pattern) for pattern in repo_policy.paths.deny
):
return PolicyDecision(False, "path denied by repository policy")
if repo_policy.paths.allow and not any(
fnmatch(normalized_path, pattern) for pattern in repo_policy.paths.allow
):
return PolicyDecision(False, "path not allowed by repository policy")
default_behavior = self.config.default_write if is_write else self.config.default_read
return PolicyDecision(default_behavior == "allow", "default policy decision")
_policy_engine: PolicyEngine | None = None
def get_policy_engine() -> PolicyEngine:
"""Get or create global policy engine instance."""
global _policy_engine
if _policy_engine is None:
settings = get_settings()
_policy_engine = PolicyEngine.from_yaml_file(settings.policy_file_path)
return _policy_engine
def reset_policy_engine() -> None:
"""Reset global policy engine (mainly for tests)."""
global _policy_engine
_policy_engine = None
-151
View File
@@ -1,151 +0,0 @@
"""Shared, transport-agnostic tool registry.
This module is the single source of truth that maps each MCP tool name to its
async handler. Both transport adapters consume it:
* the HTTP/OAuth server (``server.py``), and
* the local stdio adapter (``stdio_app.py``).
Tool *definitions* (name, description, JSON schema, read/write flag) live in
``mcp_protocol.AVAILABLE_TOOLS``; this module binds those names to callables and
exposes lookup helpers so neither adapter duplicates the tool list. It imports
only core modules and never the web stack, keeping the core importable without
FastAPI installed.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from aegis_gitea_mcp.gitea_client import GiteaClient
from aegis_gitea_mcp.mcp_protocol import (
AVAILABLE_TOOLS,
MCPTool,
get_tool_by_name,
)
from aegis_gitea_mcp.tools.raw_tools import raw_api_request_tool
from aegis_gitea_mcp.tools.read_tools import (
compare_refs_tool,
get_branch_tool,
get_commit_diff_tool,
get_commit_status_tool,
get_issue_tool,
get_latest_release_tool,
get_pull_request_tool,
get_release_tool,
get_repo_languages_tool,
list_branches_tool,
list_commits_tool,
list_issue_comments_tool,
list_issues_tool,
list_labels_tool,
list_milestones_tool,
list_org_repositories_tool,
list_organizations_tool,
list_pull_request_commits_tool,
list_pull_request_files_tool,
list_pull_requests_tool,
list_releases_tool,
list_repo_topics_tool,
list_tags_tool,
search_code_tool,
)
from aegis_gitea_mcp.tools.repository import (
get_file_contents_tool,
get_file_tree_tool,
get_repository_info_tool,
list_repositories_tool,
)
from aegis_gitea_mcp.tools.write_tools import (
add_labels_tool,
assign_issue_tool,
create_branch_tool,
create_issue_comment_tool,
create_issue_tool,
create_label_tool,
create_milestone_tool,
create_pr_comment_tool,
create_pull_request_tool,
create_release_tool,
edit_issue_comment_tool,
edit_release_tool,
remove_labels_tool,
update_issue_tool,
update_label_tool,
)
ToolHandler = Callable[[GiteaClient, dict[str, Any]], Awaitable[dict[str, Any]]]
TOOL_HANDLERS: dict[str, ToolHandler] = {
# Baseline read tools
"list_repositories": list_repositories_tool,
"get_repository_info": get_repository_info_tool,
"get_file_tree": get_file_tree_tool,
"get_file_contents": get_file_contents_tool,
# Expanded read tools
"search_code": search_code_tool,
"list_commits": list_commits_tool,
"get_commit_diff": get_commit_diff_tool,
"compare_refs": compare_refs_tool,
"list_issues": list_issues_tool,
"get_issue": get_issue_tool,
"list_pull_requests": list_pull_requests_tool,
"get_pull_request": get_pull_request_tool,
"list_labels": list_labels_tool,
"list_tags": list_tags_tool,
"list_releases": list_releases_tool,
"list_pull_request_files": list_pull_request_files_tool,
"list_pull_request_commits": list_pull_request_commits_tool,
"list_issue_comments": list_issue_comments_tool,
"list_branches": list_branches_tool,
"get_branch": get_branch_tool,
"get_release": get_release_tool,
"get_latest_release": get_latest_release_tool,
"list_milestones": list_milestones_tool,
"get_commit_status": get_commit_status_tool,
"list_org_repositories": list_org_repositories_tool,
"list_organizations": list_organizations_tool,
"get_repo_languages": get_repo_languages_tool,
"list_repo_topics": list_repo_topics_tool,
# Write-mode tools
"create_issue": create_issue_tool,
"update_issue": update_issue_tool,
"create_issue_comment": create_issue_comment_tool,
"create_pr_comment": create_pr_comment_tool,
"add_labels": add_labels_tool,
"assign_issue": assign_issue_tool,
"create_label": create_label_tool,
"update_label": update_label_tool,
"remove_labels": remove_labels_tool,
"create_pull_request": create_pull_request_tool,
"create_release": create_release_tool,
"edit_release": edit_release_tool,
"create_branch": create_branch_tool,
"create_milestone": create_milestone_tool,
"edit_issue_comment": edit_issue_comment_tool,
# Generic raw API dispatch (escape hatch). Registered as a read tool so GETs
# work without write-mode; the handler authorizes writes per-method itself.
"gitea_request": raw_api_request_tool,
}
def get_tool_handler(tool_name: str) -> ToolHandler | None:
"""Return the async handler bound to a tool name, or None if unknown."""
return TOOL_HANDLERS.get(tool_name)
def list_tool_definitions() -> list[MCPTool]:
"""Return all registered tool definitions (name, schema, read/write flag)."""
return list(AVAILABLE_TOOLS)
__all__ = [
"AVAILABLE_TOOLS",
"MCPTool",
"ToolHandler",
"TOOL_HANDLERS",
"get_tool_by_name",
"get_tool_handler",
"list_tool_definitions",
]
File diff suppressed because it is too large. Load diff
-34
View File
@@ -1,34 +0,0 @@
"""Guarded console-script entry point for the HTTP/OAuth server.
The HTTP server (``aegis_gitea_mcp.server``) imports FastAPI/uvicorn at module
load. Those live in the optional ``[server]`` extra, so a default (local-only)
install would crash with a bare ``ModuleNotFoundError`` traceback if the
``aegis-gitea-mcp-server`` script were invoked. This thin wrapper imports nothing
from the web stack at module scope and degrades to an actionable message.
"""
from __future__ import annotations
import sys
def main() -> None:
"""Run the HTTP server, or explain how to install the web stack."""
try:
import fastapi # noqa: F401
import uvicorn # noqa: F401
except ModuleNotFoundError as exc:
print(
"aegis-gitea-mcp-server requires the web stack, which is not installed.\n"
"Install it with: pip install 'aegis-gitea-mcp[server]'",
file=sys.stderr,
)
raise SystemExit(1) from exc
from aegis_gitea_mcp.server import main as server_main
server_main()
if __name__ == "__main__":
main()
-267
View File
@@ -1,267 +0,0 @@
"""Local stdio transport adapter (``aegis-gitea-mcp``).
This is the second transport for the shared core: a single-user, local MCP
server spoken over stdio using the official ``mcp`` SDK. It is meant to be run
like ``uvx aegis-gitea-mcp`` and wired into Claude Desktop / Claude Code, mirror-
ing the ergonomics of other local MCP servers.
Trust model
-----------
The local operator owns the Gitea Personal Access Token supplied via
``GITEA_TOKEN``; there is no per-user OAuth. At startup the adapter resolves the
PAT owner (``GET /user``) and pins the request context to that single login.
Because the caller *is* the token owner, the per-user repository-permission
probe used by the public HTTP server is intentionally skipped — but the policy
engine, ``WRITE_MODE`` gate, secret sanitization and the tamper-evident audit
log all run exactly as they do on the server. The same tools (including
``gitea_request``) are served from the shared :mod:`aegis_gitea_mcp.registry`.
"""
from __future__ import annotations
import asyncio
import logging
import os
import sys
from pathlib import Path
from typing import Any
from aegis_gitea_mcp.errors import ToolError
class StdioConfigError(RuntimeError):
"""Raised when the local environment is missing required configuration."""
def _default_audit_log_path() -> Path:
"""Return a writable per-user audit-log path for local runs.
The server's container default (``/var/log/aegis-mcp/audit.log``) is not
writable on a typical workstation, so fall back to an OS-appropriate user
state directory.
"""
if sys.platform == "win32":
base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
return Path(base) / "aegis-gitea-mcp" / "audit.log"
xdg_state = os.environ.get("XDG_STATE_HOME")
base_dir = Path(xdg_state) if xdg_state else (Path.home() / ".local" / "state")
return base_dir / "aegis-gitea-mcp" / "audit.log"
def _bootstrap_env() -> None:
"""Apply local-mode defaults to the environment before settings load.
Local mode has no OAuth and no API-key gate (the operator is the trusted PAT
owner), and writes its audit log to a per-user path when one is not set. User
overrides via real env vars or ``.env`` always win for everything else.
"""
# python-dotenv: load a local .env so GITEA_URL/GITEA_TOKEN can live there.
try:
from dotenv import load_dotenv
load_dotenv()
except Exception: # pragma: no cover - dotenv is a core dep, defensive only
pass
# Local mode is single-user PAT auth: force OAuth off and disable the API-key
# requirement so the server's API-key/OAuth config validation does not apply.
os.environ["OAUTH_MODE"] = "false"
os.environ.setdefault("AUTH_ENABLED", "false")
os.environ.setdefault("STARTUP_VALIDATE_GITEA", "false")
if not os.environ.get("AUDIT_LOG_PATH", "").strip():
os.environ["AUDIT_LOG_PATH"] = str(_default_audit_log_path())
def _check_required_env() -> None:
"""Fail with an actionable message when required env vars are missing."""
missing = [
name for name in ("GITEA_URL", "GITEA_TOKEN") if not os.environ.get(name, "").strip()
]
if missing:
raise StdioConfigError(
"Missing required environment variable(s): "
+ ", ".join(missing)
+ ".\nSet them in your environment or a local .env file, e.g.:\n"
" GITEA_URL=https://gitea.example.com\n"
" GITEA_TOKEN=<a Gitea personal access token>\n"
)
# The PAT owner login, resolved once at startup and pinned onto every dispatch.
_owner_login: str | None = None
async def _resolve_owner_login() -> str:
"""Resolve and cache the Gitea login that owns the configured PAT."""
from aegis_gitea_mcp.config import get_settings
from aegis_gitea_mcp.gitea_client import GiteaClient
settings = get_settings()
async with GiteaClient(token=settings.gitea_token) as gitea:
user = await gitea.get_current_user()
login = str(user.get("login", "")).strip()
if not login:
raise StdioConfigError(
"Could not resolve the Gitea user for the supplied GITEA_TOKEN. "
"Verify the token is valid and has API access."
)
return login
async def _dispatch(tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
"""Execute a tool with the same policy/audit/sanitize guarantees as the server.
The per-user repository-permission probe is intentionally omitted: the local
operator is the PAT owner. Everything else — policy engine, ``WRITE_MODE``,
the ``gitea_request`` per-method authorization, secret sanitization and audit
logging — runs identically to the HTTP adapter.
"""
from aegis_gitea_mcp.audit import get_audit_logger
from aegis_gitea_mcp.config import get_settings
from aegis_gitea_mcp.gitea_client import GiteaClient
from aegis_gitea_mcp.policy import get_policy_engine
from aegis_gitea_mcp.registry import get_tool_by_name, get_tool_handler
from aegis_gitea_mcp.request_context import set_gitea_user_login
from aegis_gitea_mcp.security import sanitize_data
from aegis_gitea_mcp.tools.arguments import extract_repository, extract_target_path
# Pin identity to the trusted PAT owner for every call (e.g. list_repositories
# scopes its results to this login in service-PAT mode).
if _owner_login:
set_gitea_user_login(_owner_login)
settings = get_settings()
audit = get_audit_logger()
tool_def = get_tool_by_name(tool_name)
if tool_def is None:
raise ToolError(f"Tool '{tool_name}' not found", status_code=404)
handler = get_tool_handler(tool_name)
if handler is None:
raise ToolError(f"Tool '{tool_name}' has no handler implementation", status_code=500)
repository = extract_repository(arguments)
target_path = extract_target_path(arguments)
decision = get_policy_engine().authorize(
tool_name=tool_name,
is_write=tool_def.write_operation,
repository=repository,
target_path=target_path,
)
if not decision.allowed:
audit.log_access_denied(tool_name=tool_name, repository=repository, reason=decision.reason)
raise ToolError(f"Policy denied request: {decision.reason}", status_code=403)
correlation_id = audit.log_tool_invocation(tool_name=tool_name, params=arguments)
async with GiteaClient(token=settings.gitea_token) as gitea:
result = await handler(gitea, arguments)
if settings.secret_detection_mode != "off":
result = sanitize_data(result, mode=settings.secret_detection_mode)
audit.log_tool_invocation(
tool_name=tool_name, correlation_id=correlation_id, result_status="success"
)
return result
def _configure_stderr_logging() -> None:
"""Pin all logging to stderr so the stdout JSON-RPC channel stays clean.
The stdio MCP transport speaks JSON-RPC over stdout; a single stray log line
on stdout corrupts the stream and breaks the client. ``configure_logging``
already targets stderr, but we additionally rewrite any handler that points
at stdout (e.g. a library that called ``basicConfig``) so nothing can leak.
"""
from aegis_gitea_mcp.config import get_settings
from aegis_gitea_mcp.logging_utils import configure_logging
configure_logging(get_settings().log_level)
root = logging.getLogger()
for handler in root.handlers:
if isinstance(handler, logging.StreamHandler) and handler.stream is sys.stdout:
handler.setStream(sys.stderr)
def build_server() -> Any:
"""Build (but do not run) the stdio MCP ``Server`` from the shared registry.
Kept separate from :func:`_serve` so it can be driven in-process by tests
over an in-memory transport without opening real stdio streams.
"""
import mcp.types as mcp_types
from mcp.server import Server
from aegis_gitea_mcp.registry import list_tool_definitions
server: Any = Server("aegis-gitea-mcp")
@server.list_tools()
async def list_tools() -> list[mcp_types.Tool]:
return [
mcp_types.Tool(
name=tool.name,
description=tool.description,
inputSchema=tool.input_schema,
)
for tool in list_tool_definitions()
]
@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
# Returning a dict yields structured content plus a JSON text block.
return await _dispatch(name, arguments)
return server
async def _serve() -> None:
"""Resolve identity and serve the stdio MCP server over real stdin/stdout."""
from mcp.server.stdio import stdio_server
from aegis_gitea_mcp.config import get_settings
from aegis_gitea_mcp.policy import get_policy_engine
# Fail fast on bad settings/policy before opening the transport.
get_settings()
get_policy_engine()
global _owner_login
_owner_login = await _resolve_owner_login()
server = build_server()
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())
def main() -> None:
"""Console-script entry point for the local stdio MCP server."""
_bootstrap_env()
try:
_check_required_env()
except StdioConfigError as exc:
print(f"aegis-gitea-mcp: {exc}", file=sys.stderr)
raise SystemExit(2) from exc
try:
from aegis_gitea_mcp.config import get_settings
get_settings()
except Exception as exc: # pydantic ValidationError or PolicyError
print(f"aegis-gitea-mcp: invalid configuration: {exc}", file=sys.stderr)
raise SystemExit(2) from exc
# Keep stdout reserved for the JSON-RPC stream; all logs go to stderr.
_configure_stderr_logging()
try:
asyncio.run(_serve())
except StdioConfigError as exc:
print(f"aegis-gitea-mcp: {exc}", file=sys.stderr)
raise SystemExit(2) from exc
except KeyboardInterrupt: # pragma: no cover - interactive shutdown
pass
__all__ = ["main", "StdioConfigError"]
-53
View File
@@ -1,53 +0,0 @@
"""MCP tool implementation exports."""
from aegis_gitea_mcp.tools.read_tools import (
compare_refs_tool,
get_commit_diff_tool,
get_issue_tool,
get_pull_request_tool,
list_commits_tool,
list_issues_tool,
list_labels_tool,
list_pull_requests_tool,
list_releases_tool,
list_tags_tool,
search_code_tool,
)
from aegis_gitea_mcp.tools.repository import (
get_file_contents_tool,
get_file_tree_tool,
get_repository_info_tool,
list_repositories_tool,
)
from aegis_gitea_mcp.tools.write_tools import (
add_labels_tool,
assign_issue_tool,
create_issue_comment_tool,
create_issue_tool,
create_pr_comment_tool,
update_issue_tool,
)
__all__ = [
"list_repositories_tool",
"get_repository_info_tool",
"get_file_tree_tool",
"get_file_contents_tool",
"search_code_tool",
"list_commits_tool",
"get_commit_diff_tool",
"compare_refs_tool",
"list_issues_tool",
"get_issue_tool",
"list_pull_requests_tool",
"get_pull_request_tool",
"list_labels_tool",
"list_tags_tool",
"list_releases_tool",
"create_issue_tool",
"update_issue_tool",
"create_issue_comment_tool",
"create_pr_comment_tool",
"add_labels_tool",
"assign_issue_tool",
]
-689
View File
@@ -1,689 +0,0 @@
"""Pydantic argument models for MCP tools."""
from __future__ import annotations
import re
from typing import Annotated, Any, Literal
from urllib.parse import urlsplit
from pydantic import (
AfterValidator,
BaseModel,
BeforeValidator,
ConfigDict,
Field,
field_validator,
model_validator,
)
_REPO_PART_PATTERN = r"^[A-Za-z0-9._-]{1,100}$"
def _validate_git_ref(value: str) -> str:
"""Validate a git ref-like value (ref/sha/base/head) against traversal.
Refs that legitimately contain ``/`` (e.g. ``feature/foo``, ``release/1.0``)
are preserved; only traversal and unsafe URL-path characters are rejected.
Args:
value: Candidate ref, sha, base, or head value.
Returns:
The unchanged value when it is safe.
Raises:
ValueError: When the value could escape the intended repository path.
"""
# Security decision: block path traversal and absolute references.
if ".." in value.split("/"):
raise ValueError("ref must not contain '..' path segments")
if value.startswith("/"):
raise ValueError("ref must not start with '/'")
if "\\" in value:
raise ValueError("ref must not contain backslashes")
if "\x00" in value:
raise ValueError("ref must not contain null bytes")
if any(ord(char) < 0x20 or ord(char) == 0x7F for char in value):
raise ValueError("ref must not contain control characters")
if any(char.isspace() for char in value):
raise ValueError("ref must not contain whitespace")
if "?" in value or "#" in value:
raise ValueError("ref must not contain '?' or '#'")
return value
GitRef = Annotated[str, AfterValidator(_validate_git_ref)]
def _validate_milestone(value: object) -> int | str:
"""Validate a milestone reference supplied as a numeric id or a title.
An integer is treated as a milestone id (``0`` clears the milestone on
update); a string is treated as a milestone title to resolve. Runs as a
``BeforeValidator`` so ``bool`` (a subclass of ``int`` that Pydantic would
otherwise coerce to ``1``/``0``) is rejected on the raw input.
"""
if isinstance(value, bool):
raise ValueError("milestone must be a milestone id or title")
if isinstance(value, int):
if value < 0:
raise ValueError("milestone id must be >= 0")
return value
if isinstance(value, str):
title = value.strip()
if not title:
raise ValueError("milestone title must not be empty")
if len(title) > 256:
raise ValueError("milestone title must not exceed 256 characters")
return title
raise ValueError("milestone must be a milestone id or title")
MilestoneRef = Annotated[int | str, BeforeValidator(_validate_milestone)]
class StrictBaseModel(BaseModel):
"""Strict model base that rejects unexpected fields."""
model_config = ConfigDict(extra="forbid")
class ListRepositoriesArgs(StrictBaseModel):
"""Arguments for list_repositories tool."""
class RepositoryArgs(StrictBaseModel):
"""Common repository locator arguments."""
owner: str = Field(..., pattern=_REPO_PART_PATTERN)
repo: str = Field(..., pattern=_REPO_PART_PATTERN)
class FileTreeArgs(RepositoryArgs):
"""Arguments for get_file_tree."""
ref: GitRef = Field(default="main", min_length=1, max_length=200)
recursive: bool = Field(default=False)
class FileContentsArgs(RepositoryArgs):
"""Arguments for get_file_contents."""
filepath: str = Field(..., min_length=1, max_length=1024)
ref: GitRef = Field(default="main", min_length=1, max_length=200)
@model_validator(mode="after")
def validate_filepath(self) -> FileContentsArgs:
"""Validate path safety constraints."""
normalized = self.filepath.replace("\\", "/")
# Security decision: block traversal and absolute paths.
if normalized.startswith("/") or ".." in normalized.split("/"):
raise ValueError("filepath must be a relative path without traversal")
if "\x00" in normalized:
raise ValueError("filepath cannot contain null bytes")
return self
class SearchCodeArgs(RepositoryArgs):
"""Arguments for search_code."""
query: str = Field(..., min_length=1, max_length=256)
ref: GitRef = Field(default="main", min_length=1, max_length=200)
page: int = Field(default=1, ge=1, le=10_000)
limit: int = Field(default=25, ge=1, le=100)
class ListCommitsArgs(RepositoryArgs):
"""Arguments for list_commits."""
ref: GitRef = Field(default="main", min_length=1, max_length=200)
page: int = Field(default=1, ge=1, le=10_000)
limit: int = Field(default=25, ge=1, le=100)
class CommitDiffArgs(RepositoryArgs):
"""Arguments for get_commit_diff."""
sha: GitRef = Field(..., min_length=7, max_length=64)
class CompareRefsArgs(RepositoryArgs):
"""Arguments for compare_refs."""
base: GitRef = Field(..., min_length=1, max_length=200)
head: GitRef = Field(..., min_length=1, max_length=200)
class ListIssuesArgs(RepositoryArgs):
"""Arguments for list_issues."""
state: Literal["open", "closed", "all"] = Field(default="open")
page: int = Field(default=1, ge=1, le=10_000)
limit: int = Field(default=25, ge=1, le=100)
labels: list[str] = Field(default_factory=list, max_length=20)
class IssueArgs(RepositoryArgs):
"""Arguments for get_issue."""
issue_number: int = Field(..., ge=1)
class ListPullRequestsArgs(RepositoryArgs):
"""Arguments for list_pull_requests."""
state: Literal["open", "closed", "all"] = Field(default="open")
page: int = Field(default=1, ge=1, le=10_000)
limit: int = Field(default=25, ge=1, le=100)
class PullRequestArgs(RepositoryArgs):
"""Arguments for get_pull_request."""
pull_number: int = Field(..., ge=1)
class ListLabelsArgs(RepositoryArgs):
"""Arguments for list_labels."""
page: int = Field(default=1, ge=1, le=10_000)
limit: int = Field(default=50, ge=1, le=100)
class ListTagsArgs(RepositoryArgs):
"""Arguments for list_tags."""
page: int = Field(default=1, ge=1, le=10_000)
limit: int = Field(default=50, ge=1, le=100)
class ListReleasesArgs(RepositoryArgs):
"""Arguments for list_releases."""
page: int = Field(default=1, ge=1, le=10_000)
limit: int = Field(default=25, ge=1, le=100)
class CreateIssueArgs(RepositoryArgs):
"""Arguments for create_issue."""
title: str = Field(..., min_length=1, max_length=256)
body: str = Field(default="", max_length=20_000)
labels: list[str] = Field(default_factory=list, max_length=20)
assignees: list[str] = Field(default_factory=list, max_length=20)
milestone: MilestoneRef | None = Field(
default=None, description="Milestone id or title to assign the issue to"
)
class UpdateIssueArgs(RepositoryArgs):
"""Arguments for update_issue."""
issue_number: int = Field(..., ge=1)
title: str | None = Field(default=None, min_length=1, max_length=256)
body: str | None = Field(default=None, max_length=20_000)
state: Literal["open", "closed"] | None = Field(default=None)
milestone: MilestoneRef | None = Field(
default=None, description="Milestone id or title to assign; 0 clears the milestone"
)
@model_validator(mode="after")
def require_change(self) -> UpdateIssueArgs:
"""Require at least one mutable field in update payload."""
if (
self.title is None
and self.body is None
and self.state is None
and self.milestone is None
):
raise ValueError("At least one of title, body, state, or milestone must be provided")
return self
class CreateIssueCommentArgs(RepositoryArgs):
"""Arguments for create_issue_comment."""
issue_number: int = Field(..., ge=1)
body: str = Field(..., min_length=1, max_length=10_000)
class CreatePrCommentArgs(RepositoryArgs):
"""Arguments for create_pr_comment."""
pull_number: int = Field(..., ge=1)
body: str = Field(..., min_length=1, max_length=10_000)
class AddLabelsArgs(RepositoryArgs):
"""Arguments for add_labels."""
issue_number: int = Field(..., ge=1)
labels: list[str] = Field(..., min_length=1, max_length=20)
class AssignIssueArgs(RepositoryArgs):
"""Arguments for assign_issue."""
issue_number: int = Field(..., ge=1)
assignees: list[str] = Field(..., min_length=1, max_length=20)
class CreateLabelArgs(RepositoryArgs):
"""Arguments for create_label."""
name: str = Field(..., min_length=1, max_length=50)
# Gitea requires a hex color; accept it with or without a leading '#'.
color: str = Field(..., pattern=r"^#?[0-9A-Fa-f]{6}$")
description: str = Field(default="", max_length=1000)
exclusive: bool = Field(default=False)
class UpdateLabelArgs(RepositoryArgs):
"""Arguments for update_label (located by current name)."""
name: str = Field(..., min_length=1, max_length=50)
new_name: str | None = Field(default=None, min_length=1, max_length=50)
color: str | None = Field(default=None, pattern=r"^#?[0-9A-Fa-f]{6}$")
description: str | None = Field(default=None, max_length=1000)
@model_validator(mode="after")
def require_change(self) -> UpdateLabelArgs:
"""Require at least one mutable field in the update payload."""
if self.new_name is None and self.color is None and self.description is None:
raise ValueError("At least one of new_name, color, or description must be provided")
return self
class RemoveLabelsArgs(RepositoryArgs):
"""Arguments for remove_labels."""
issue_number: int = Field(..., ge=1)
labels: list[str] = Field(..., min_length=1, max_length=20)
class CreatePullRequestArgs(RepositoryArgs):
"""Arguments for create_pull_request."""
title: str = Field(..., min_length=1, max_length=256)
head: GitRef = Field(..., min_length=1, max_length=200)
base: GitRef = Field(..., min_length=1, max_length=200)
body: str = Field(default="", max_length=20_000)
class CreateReleaseArgs(RepositoryArgs):
"""Arguments for create_release."""
tag_name: GitRef = Field(..., min_length=1, max_length=200)
name: str = Field(default="", max_length=256)
body: str = Field(default="", max_length=20_000)
draft: bool = Field(default=False)
prerelease: bool = Field(default=False)
target: str | None = Field(default=None, min_length=1, max_length=200)
class EditReleaseArgs(RepositoryArgs):
"""Arguments for edit_release."""
release_id: int = Field(..., ge=1)
name: str | None = Field(default=None, max_length=256)
body: str | None = Field(default=None, max_length=20_000)
draft: bool | None = Field(default=None)
prerelease: bool | None = Field(default=None)
@model_validator(mode="after")
def require_change(self) -> EditReleaseArgs:
"""Require at least one mutable field in the update payload."""
if (
self.name is None
and self.body is None
and self.draft is None
and self.prerelease is None
):
raise ValueError("At least one of name, body, draft, or prerelease must be provided")
return self
class CreateBranchArgs(RepositoryArgs):
"""Arguments for create_branch."""
new_branch_name: GitRef = Field(..., min_length=1, max_length=200)
old_branch_name: str | None = Field(default=None, min_length=1, max_length=200)
class CreateMilestoneArgs(RepositoryArgs):
"""Arguments for create_milestone."""
title: str = Field(..., min_length=1, max_length=256)
description: str = Field(default="", max_length=10_000)
due_on: str | None = Field(default=None, max_length=64)
class EditIssueCommentArgs(RepositoryArgs):
"""Arguments for edit_issue_comment."""
comment_id: int = Field(..., ge=1)
body: str = Field(..., min_length=1, max_length=10_000)
class ListPullRequestFilesArgs(RepositoryArgs):
"""Arguments for list_pull_request_files."""
pull_number: int = Field(..., ge=1)
page: int = Field(default=1, ge=1, le=10_000)
limit: int = Field(default=50, ge=1, le=100)
class ListPullRequestCommitsArgs(RepositoryArgs):
"""Arguments for list_pull_request_commits."""
pull_number: int = Field(..., ge=1)
page: int = Field(default=1, ge=1, le=10_000)
limit: int = Field(default=50, ge=1, le=100)
class ListIssueCommentsArgs(RepositoryArgs):
"""Arguments for list_issue_comments."""
issue_number: int = Field(..., ge=1)
page: int = Field(default=1, ge=1, le=10_000)
limit: int = Field(default=50, ge=1, le=100)
class ListBranchesArgs(RepositoryArgs):
"""Arguments for list_branches."""
page: int = Field(default=1, ge=1, le=10_000)
limit: int = Field(default=50, ge=1, le=100)
class GetBranchArgs(RepositoryArgs):
"""Arguments for get_branch."""
branch: GitRef = Field(..., min_length=1, max_length=200)
class GetReleaseArgs(RepositoryArgs):
"""Arguments for get_release."""
release_id: int = Field(..., ge=1)
class LatestReleaseArgs(RepositoryArgs):
"""Arguments for get_latest_release."""
class ListMilestonesArgs(RepositoryArgs):
"""Arguments for list_milestones."""
state: Literal["open", "closed", "all"] = Field(default="open")
page: int = Field(default=1, ge=1, le=10_000)
limit: int = Field(default=50, ge=1, le=100)
class CommitStatusArgs(RepositoryArgs):
"""Arguments for get_commit_status."""
sha: GitRef = Field(..., min_length=1, max_length=64)
class ListOrgRepositoriesArgs(StrictBaseModel):
"""Arguments for list_org_repositories."""
org: str = Field(..., pattern=_REPO_PART_PATTERN)
page: int = Field(default=1, ge=1, le=10_000)
limit: int = Field(default=50, ge=1, le=100)
class ListOrganizationsArgs(StrictBaseModel):
"""Arguments for list_organizations."""
page: int = Field(default=1, ge=1, le=10_000)
limit: int = Field(default=50, ge=1, le=100)
class RepoLanguagesArgs(RepositoryArgs):
"""Arguments for get_repo_languages."""
class RepoTopicsArgs(RepositoryArgs):
"""Arguments for list_repo_topics."""
# --- Raw API dispatch (gitea_request escape hatch) -------------------------
# HTTP methods the generic dispatch tool accepts. Everything outside GET/HEAD is
# treated as a write so the policy/write-mode gate applies.
RAW_API_METHODS = ("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE")
_RAW_WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
# Path segments/subpaths blocked for *every* method unless explicitly overridden
# via RAW_API_ALLOW_SENSITIVE. A GET on these already leaks credentials or
# privileged configuration, so they are denied independently of policy.yaml.
_RAW_SENSITIVE_SEGMENTS = frozenset({"admin", "tokens", "secrets", "hooks", "keys", "gpg_keys"})
_RAW_SENSITIVE_SUBPATHS = ("applications/oauth2", "actions/runners/registration-token")
# Endpoints under /repos/ that are not scoped to a single repository.
_RAW_CROSS_REPO_OWNERS = frozenset({"search", "issues"})
# Resources whose trailing segments form a file path target for policy checks.
_RAW_FILE_RESOURCES = frozenset({"contents", "raw", "media"})
# Known top-level segments of the Gitea ``/api/v1`` surface. A raw request whose
# first path segment is not in this set is rejected (fail closed): we never pass
# an unrecognized path straight through to Gitea.
KNOWN_API_PREFIXES = frozenset(
{
"activitypub",
"admin",
"gitignore",
"issues",
"label",
"licenses",
"markdown",
"markup",
"miscellaneous",
"nodeinfo",
"notifications",
"org",
"orgs",
"packages",
"repos",
"repositories",
"settings",
"signing-key.gpg",
"teams",
"topics",
"user",
"users",
"version",
}
)
# Override table: provably side-effect-free POSTs that may be treated as reads so
# they do not needlessly require WRITE_MODE. This table may ONLY ever DOWNGRADE a
# write to a read for endpoints that render content and mutate nothing — never
# the reverse. Keyed by the final path segment of the endpoint.
_RAW_READ_ONLY_POST_LEAVES = frozenset({"markdown", "markup", "raw"})
def raw_is_known_api_path(endpoint: str) -> bool:
"""Return whether the endpoint's top segment is a known Gitea API prefix."""
return raw_top_segment(endpoint) in KNOWN_API_PREFIXES
def raw_request_is_write(method: str, endpoint: str) -> bool:
"""Classify a raw request as read or write from its method and path.
``GET``/``HEAD`` are reads; every other method is a write — except for the
small, explicit override table of render-only POSTs (e.g. markdown/markup),
which are reads. The override can only make a request *more* permissive for
provably side-effect-free endpoints; it never reclassifies a mutating call as
a read, so a misclassified write cannot slip past the write-mode gate.
"""
upper = method.upper()
if upper in {"GET", "HEAD"}:
return False
if upper == "POST":
rel = _raw_relative_segments(endpoint)
if rel and rel[-1] in _RAW_READ_ONLY_POST_LEAVES:
return False
return True
def normalize_raw_endpoint(path: str) -> str:
"""Normalize a raw API path into an ``/api/v1``-prefixed endpoint.
Accepts a bare path (``/repos/o/r``), an already-prefixed path
(``/api/v1/repos/o/r``), or a full URL (the scheme/host and any query string
are stripped — the separate ``query`` argument carries query parameters).
Raises:
ValueError: When the path contains a ``..`` traversal segment.
"""
candidate = path.strip()
split = urlsplit(candidate)
# When a full URL is supplied, keep only its path component.
raw_path = split.path if (split.scheme or split.netloc) else candidate
# Drop any query/fragment a caller may have inlined into the path string.
raw_path = raw_path.split("?", 1)[0].split("#", 1)[0]
raw_path = raw_path.replace("\\", "/")
segments = [seg for seg in raw_path.split("/") if seg and seg != "."]
if any(seg == ".." for seg in segments):
raise ValueError("path must not contain '..' traversal segments")
rel_segments = segments[2:] if segments[:2] == ["api", "v1"] else segments
if not rel_segments:
return "/api/v1"
return "/api/v1/" + "/".join(rel_segments)
def _raw_relative_segments(endpoint: str) -> list[str]:
"""Return the endpoint segments after the ``/api/v1`` prefix."""
segments = [seg for seg in endpoint.split("/") if seg]
return segments[2:] if segments[:2] == ["api", "v1"] else segments
def raw_relative_segments(endpoint: str) -> list[str]:
"""Return the endpoint path segments after the ``/api/v1`` prefix (public)."""
return _raw_relative_segments(endpoint)
def raw_top_segment(endpoint: str) -> str:
"""Return the first path segment after ``/api/v1`` for coarse policy grouping."""
rel = _raw_relative_segments(endpoint)
return rel[0] if rel else ""
def raw_method_is_write(method: str) -> bool:
"""Return whether an HTTP method mutates state."""
return method.upper() in _RAW_WRITE_METHODS
def raw_is_sensitive(endpoint: str) -> bool:
"""Return whether an endpoint touches an admin/credential surface."""
rel = _raw_relative_segments(endpoint)
if any(seg in _RAW_SENSITIVE_SEGMENTS for seg in rel):
return True
joined = "/".join(rel)
return any(sub in joined for sub in _RAW_SENSITIVE_SUBPATHS)
def _raw_repo_segments(endpoint: str) -> list[str] | None:
"""Return ``[owner, repo, *rest]`` for a single-repository endpoint, else None."""
rel = _raw_relative_segments(endpoint)
if len(rel) < 3 or rel[0] != "repos":
return None
owner, repo = rel[1], rel[2]
if owner in _RAW_CROSS_REPO_OWNERS:
return None
if not (re.match(_REPO_PART_PATTERN, owner) and re.match(_REPO_PART_PATTERN, repo)):
return None
return [owner, repo, *rel[3:]]
def parse_raw_repository(endpoint: str) -> str | None:
"""Parse ``owner/repo`` from a repo-scoped endpoint; None for cross-repo paths."""
repo_segments = _raw_repo_segments(endpoint)
if repo_segments is None:
return None
return f"{repo_segments[0]}/{repo_segments[1]}"
def parse_raw_target_path(endpoint: str) -> str | None:
"""Parse a file-path target from ``contents``/``raw``/``media`` endpoints."""
repo_segments = _raw_repo_segments(endpoint)
if repo_segments is None or len(repo_segments) < 4:
return None
if repo_segments[2] not in _RAW_FILE_RESOURCES:
return None
file_path = "/".join(repo_segments[3:])
return file_path or None
class RawApiRequestArgs(StrictBaseModel):
"""Arguments for the generic ``gitea_request`` escape-hatch tool."""
method: Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"] = Field(
..., description="HTTP method"
)
path: str = Field(..., min_length=1, max_length=2048, description="Gitea REST path")
query: dict[str, Any] | None = Field(
default=None, description="Optional query-string parameters"
)
body: dict[str, Any] | None = Field(default=None, description="Optional JSON request body")
@field_validator("method", mode="before")
@classmethod
def _normalize_method(cls, value: object) -> object:
"""Uppercase the method before enum validation so 'get' is accepted."""
if isinstance(value, str):
return value.strip().upper()
return value
@model_validator(mode="after")
def _validate_path(self) -> RawApiRequestArgs:
"""Reject path traversal up front so the handler sees a clean endpoint."""
normalize_raw_endpoint(self.path)
return self
def extract_repository(arguments: dict[str, object]) -> str | None:
"""Extract `owner/repo` from raw argument mapping.
Args:
arguments: Raw tool arguments.
Returns:
`owner/repo` or None when arguments are incomplete.
"""
owner = arguments.get("owner")
repo = arguments.get("repo")
if isinstance(owner, str) and isinstance(repo, str) and owner and repo:
return f"{owner}/{repo}"
# Raw API dispatch: derive the repository from the request path so the central
# policy gate and the service-PAT per-user permission check evaluate the real
# target instead of treating every raw call as repo-less.
path = arguments.get("path")
method = arguments.get("method")
if isinstance(path, str) and isinstance(method, str):
try:
return parse_raw_repository(normalize_raw_endpoint(path))
except ValueError:
return None
return None
def extract_target_path(arguments: dict[str, object]) -> str | None:
"""Extract optional target path argument for policy path checks."""
filepath = arguments.get("filepath")
if isinstance(filepath, str) and filepath:
return filepath
# Raw API dispatch: expose the file path embedded in contents/raw/media
# endpoints so repository path allow/deny rules still apply to raw calls.
path = arguments.get("path")
method = arguments.get("method")
if isinstance(path, str) and isinstance(method, str):
try:
return parse_raw_target_path(normalize_raw_endpoint(path))
except ValueError:
return None
return None
-141
View File
@@ -1,141 +0,0 @@
"""Generic raw Gitea REST dispatch tool (escape hatch).
``gitea_request`` exposes the long tail of the Gitea API that the curated, typed
tools do not cover. A single tool surface would normally collapse the
granularity of ``policy.yaml``, so this handler re-derives a coarse virtual tool
name (``gitea_request:<METHOD>:<top-segment>``) and the target repository/path
from each request and runs them back through the policy engine. That reuses the
existing write-mode + write-whitelist enforcement and keeps per-method/per-repo
policy control intact behind the single tool.
Two layers of authorization apply:
* The central dispatch gate in ``server.py`` allows/denies the registered
``gitea_request`` name and, in service-PAT mode, verifies the signed-in user's
permission on the parsed repository.
* This handler then authorizes the fine-grained virtual tool name and enforces a
built-in admin/credential denylist that ``policy.yaml`` cannot re-open.
"""
from __future__ import annotations
import json
from typing import Any
from aegis_gitea_mcp.audit import get_audit_logger
from aegis_gitea_mcp.config import get_settings
from aegis_gitea_mcp.errors import ToolError
from aegis_gitea_mcp.gitea_client import (
GiteaAuthenticationError,
GiteaAuthorizationError,
GiteaClient,
GiteaError,
)
from aegis_gitea_mcp.policy import get_policy_engine
from aegis_gitea_mcp.response_limits import limit_items, limit_text
from aegis_gitea_mcp.tools.arguments import (
RawApiRequestArgs,
normalize_raw_endpoint,
parse_raw_repository,
parse_raw_target_path,
raw_is_known_api_path,
raw_is_sensitive,
raw_request_is_write,
raw_top_segment,
)
def _bound_response(data: Any) -> dict[str, Any]:
"""Bound a raw response into stable, size-limited envelope fields."""
if isinstance(data, list):
bounded, omitted = limit_items(list(data))
return {"data": bounded, "count": len(bounded), "omitted": omitted}
if isinstance(data, dict):
serialized = json.dumps(data, ensure_ascii=False, default=str)
capped = limit_text(serialized)
if len(capped) < len(serialized):
# Oversized dict: return a truncated JSON string instead of the object.
return {"data": capped, "truncated": True}
return {"data": data, "truncated": False}
if isinstance(data, str):
return {"data": limit_text(data)}
return {"data": data}
async def raw_api_request_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Dispatch an arbitrary Gitea REST endpoint subject to policy and denylists."""
settings = get_settings()
audit = get_audit_logger()
if not settings.raw_api_enabled:
raise ToolError(
"Raw API dispatch is disabled (set RAW_API_ENABLED=true to enable).",
status_code=403,
)
parsed = RawApiRequestArgs.model_validate(arguments)
method = parsed.method
endpoint = normalize_raw_endpoint(parsed.path)
# Fail closed on paths that do not match a known Gitea API prefix: an
# unrecognized path is never passed straight through to the backend.
if not raw_is_known_api_path(endpoint):
audit.log_access_denied(tool_name="gitea_request", reason="raw_unknown_path_denied")
raise ToolError(
"Endpoint does not match a known Gitea API route prefix.",
status_code=403,
)
# Deterministic read/write classification (override-aware): a non-GET/HEAD
# method is a write unless it is in the explicit render-only override table,
# so a mutating call can never be misclassified as a read and slip past the
# write-mode gate.
is_write = raw_request_is_write(method, endpoint)
# Admin/credential denylist applies to every method and cannot be re-opened
# from policy.yaml — only RAW_API_ALLOW_SENSITIVE overrides it.
if raw_is_sensitive(endpoint) and not settings.raw_api_allow_sensitive:
audit.log_access_denied(tool_name="gitea_request", reason="raw_sensitive_path_denied")
raise ToolError(
"Endpoint targets an admin/credential surface blocked by the raw-API "
"sensitive-path denylist.",
status_code=403,
)
repository = parse_raw_repository(endpoint)
target_path = parse_raw_target_path(endpoint)
# Coarse, stable virtual tool name so policy.yaml can allow/deny by method +
# top-level path segment (policy matches tool names by exact set membership).
policy_tool_name = f"gitea_request:{method}:{raw_top_segment(endpoint)}"
decision = get_policy_engine().authorize(
tool_name=policy_tool_name,
is_write=is_write,
repository=repository,
target_path=target_path,
)
if not decision.allowed:
audit.log_access_denied(
tool_name=policy_tool_name,
repository=repository,
reason=decision.reason,
)
raise ToolError(f"Policy denied raw request: {decision.reason}", status_code=403)
try:
data = await gitea.raw_request(method, endpoint, params=parsed.query, json_body=parsed.body)
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Raw API request failed: {exc}") from exc
envelope: dict[str, Any] = {
"method": method,
"path": endpoint,
"write": is_write,
"repository": repository,
}
envelope.update(_bound_response(data))
return envelope
-850
View File
@@ -1,850 +0,0 @@
"""Extended read-only MCP tools."""
from __future__ import annotations
import logging
from typing import Any
from aegis_gitea_mcp.gitea_client import (
GiteaAuthenticationError,
GiteaAuthorizationError,
GiteaClient,
GiteaError,
)
from aegis_gitea_mcp.logging_utils import log_event, log_nullable_field
from aegis_gitea_mcp.response_limits import limit_items, limit_text
from aegis_gitea_mcp.tools.arguments import (
CommitDiffArgs,
CommitStatusArgs,
CompareRefsArgs,
GetBranchArgs,
GetReleaseArgs,
IssueArgs,
LatestReleaseArgs,
ListBranchesArgs,
ListCommitsArgs,
ListIssueCommentsArgs,
ListIssuesArgs,
ListLabelsArgs,
ListMilestonesArgs,
ListOrganizationsArgs,
ListOrgRepositoriesArgs,
ListPullRequestCommitsArgs,
ListPullRequestFilesArgs,
ListPullRequestsArgs,
ListReleasesArgs,
ListTagsArgs,
PullRequestArgs,
RepoLanguagesArgs,
RepoTopicsArgs,
SearchCodeArgs,
)
logger = logging.getLogger(__name__)
async def search_code_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Search repository code and return bounded result snippets."""
parsed = SearchCodeArgs.model_validate(arguments)
try:
raw = await gitea.search_code(
parsed.owner,
parsed.repo,
parsed.query,
ref=parsed.ref,
page=parsed.page,
limit=parsed.limit,
)
hits_raw = raw.get("data", raw.get("hits", [])) if isinstance(raw, dict) else []
if not isinstance(hits_raw, list):
hits_raw = []
normalized_hits = []
for item in hits_raw:
if not isinstance(item, dict):
continue
snippet = str(item.get("content", item.get("snippet", "")))
normalized_hits.append(
{
"path": item.get("filename", item.get("path", "")),
"sha": item.get("sha", ""),
"ref": parsed.ref,
"snippet": limit_text(snippet),
"score": item.get("score", 0),
}
)
bounded, omitted = limit_items(normalized_hits, configured_limit=parsed.limit)
return {
"owner": parsed.owner,
"repo": parsed.repo,
"query": parsed.query,
"ref": parsed.ref,
"results": bounded,
"count": len(bounded),
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to search code: {exc}") from exc
async def list_commits_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""List commits for a repository reference."""
parsed = ListCommitsArgs.model_validate(arguments)
try:
commits = await gitea.list_commits(
parsed.owner,
parsed.repo,
ref=parsed.ref,
page=parsed.page,
limit=parsed.limit,
)
normalized = [
{
"sha": commit.get("sha", ""),
"message": limit_text(str(commit.get("commit", {}).get("message", ""))),
"author": commit.get("author", {}).get("login", ""),
"created": commit.get("commit", {}).get("author", {}).get("date", ""),
"url": commit.get("html_url", ""),
}
for commit in commits
if isinstance(commit, dict)
]
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
return {
"owner": parsed.owner,
"repo": parsed.repo,
"ref": parsed.ref,
"commits": bounded,
"count": len(bounded),
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to list commits: {exc}") from exc
async def get_commit_diff_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Return commit-level file diff metadata."""
parsed = CommitDiffArgs.model_validate(arguments)
try:
commit = await gitea.get_commit_diff(parsed.owner, parsed.repo, parsed.sha)
files = commit.get("files", []) if isinstance(commit, dict) else []
normalized_files = []
if isinstance(files, list):
for item in files:
if not isinstance(item, dict):
continue
normalized_files.append(
{
"filename": item.get("filename", ""),
"status": item.get("status", ""),
"additions": item.get("additions", 0),
"deletions": item.get("deletions", 0),
"changes": item.get("changes", 0),
"patch": limit_text(str(item.get("patch", ""))),
}
)
bounded, omitted = limit_items(normalized_files)
return {
"owner": parsed.owner,
"repo": parsed.repo,
"sha": parsed.sha,
"message": limit_text(
str(commit.get("message", commit.get("commit", {}).get("message", "")))
),
"files": bounded,
"count": len(bounded),
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to get commit diff: {exc}") from exc
async def compare_refs_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Compare two refs and return bounded commit/file changes."""
parsed = CompareRefsArgs.model_validate(arguments)
try:
comparison = await gitea.compare_refs(parsed.owner, parsed.repo, parsed.base, parsed.head)
commits_raw = comparison.get("commits", []) if isinstance(comparison, dict) else []
files_raw = comparison.get("files", []) if isinstance(comparison, dict) else []
commits = [
{
"sha": commit.get("sha", ""),
"message": limit_text(str(commit.get("commit", {}).get("message", ""))),
}
for commit in commits_raw
if isinstance(commit, dict)
]
commit_items, commit_omitted = limit_items(commits)
files = [
{
"filename": item.get("filename", ""),
"status": item.get("status", ""),
"additions": item.get("additions", 0),
"deletions": item.get("deletions", 0),
}
for item in files_raw
if isinstance(item, dict)
]
file_items, file_omitted = limit_items(files)
return {
"owner": parsed.owner,
"repo": parsed.repo,
"base": parsed.base,
"head": parsed.head,
"commits": commit_items,
"files": file_items,
"commit_count": len(commit_items),
"file_count": len(file_items),
"omitted_commits": commit_omitted,
"omitted_files": file_omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to compare refs: {exc}") from exc
async def list_issues_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""List issues for repository."""
parsed = ListIssuesArgs.model_validate(arguments)
try:
issues = await gitea.list_issues(
parsed.owner,
parsed.repo,
state=parsed.state,
page=parsed.page,
limit=parsed.limit,
labels=parsed.labels,
)
normalized = [
{
"number": issue.get("number", 0),
"title": limit_text(str(issue.get("title", ""))),
"state": issue.get("state", ""),
"author": issue.get("user", {}).get("login", ""),
"labels": [label.get("name", "") for label in issue.get("labels", [])],
"created_at": issue.get("created_at", ""),
"updated_at": issue.get("updated_at", ""),
"url": issue.get("html_url", ""),
}
for issue in issues
if isinstance(issue, dict)
]
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
return {
"owner": parsed.owner,
"repo": parsed.repo,
"state": parsed.state,
"issues": bounded,
"count": len(bounded),
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to list issues: {exc}") from exc
async def get_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Get issue details."""
parsed = IssueArgs.model_validate(arguments)
log_event(
logger,
logging.DEBUG,
"get_issue.start",
owner=parsed.owner,
repo=parsed.repo,
issue_number=parsed.issue_number,
)
try:
issue = await gitea.get_issue(parsed.owner, parsed.repo, parsed.issue_number)
log_event(
logger,
logging.DEBUG,
"get_issue.payload_shape",
top_level_keys=sorted(issue.keys()) if issue else None,
)
# Surface nullable collections that previously broke parsing (see #13).
log_nullable_field(logger, "get_issue.field_check", "labels", issue.get("labels"))
log_nullable_field(logger, "get_issue.field_check", "assignees", issue.get("assignees"))
log_nullable_field(logger, "get_issue.field_check", "user", issue.get("user"))
return {
"number": issue.get("number", 0),
"title": limit_text(str(issue.get("title", ""))),
"body": limit_text(str(issue.get("body", ""))),
"state": issue.get("state", ""),
"author": (issue.get("user") or {}).get("login", ""),
"labels": [
label.get("name", "")
for label in (issue.get("labels") or [])
if isinstance(label, dict)
],
"assignees": [
assignee.get("login", "")
for assignee in (issue.get("assignees") or [])
if isinstance(assignee, dict)
],
"created_at": issue.get("created_at", ""),
"updated_at": issue.get("updated_at", ""),
"url": issue.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to get issue: {exc}") from exc
async def list_pull_requests_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""List pull requests."""
parsed = ListPullRequestsArgs.model_validate(arguments)
try:
pull_requests = await gitea.list_pull_requests(
parsed.owner,
parsed.repo,
state=parsed.state,
page=parsed.page,
limit=parsed.limit,
)
normalized = [
{
"number": pull.get("number", 0),
"title": limit_text(str(pull.get("title", ""))),
"state": pull.get("state", ""),
"author": pull.get("user", {}).get("login", ""),
"draft": pull.get("draft", False),
"mergeable": pull.get("mergeable", False),
"created_at": pull.get("created_at", ""),
"updated_at": pull.get("updated_at", ""),
"url": pull.get("html_url", ""),
}
for pull in pull_requests
if isinstance(pull, dict)
]
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
return {
"owner": parsed.owner,
"repo": parsed.repo,
"state": parsed.state,
"pull_requests": bounded,
"count": len(bounded),
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to list pull requests: {exc}") from exc
async def get_pull_request_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Get pull request details."""
parsed = PullRequestArgs.model_validate(arguments)
try:
pull = await gitea.get_pull_request(parsed.owner, parsed.repo, parsed.pull_number)
return {
"number": pull.get("number", 0),
"title": limit_text(str(pull.get("title", ""))),
"body": limit_text(str(pull.get("body", ""))),
"state": pull.get("state", ""),
"draft": pull.get("draft", False),
"mergeable": pull.get("mergeable", False),
"author": pull.get("user", {}).get("login", ""),
"base": pull.get("base", {}).get("ref", ""),
"head": pull.get("head", {}).get("ref", ""),
"created_at": pull.get("created_at", ""),
"updated_at": pull.get("updated_at", ""),
"url": pull.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to get pull request: {exc}") from exc
async def list_labels_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""List labels configured on repository."""
parsed = ListLabelsArgs.model_validate(arguments)
try:
labels = await gitea.list_labels(
parsed.owner, parsed.repo, page=parsed.page, limit=parsed.limit
)
normalized = [
{
"id": label.get("id", 0),
"name": label.get("name", ""),
"color": label.get("color", ""),
"description": limit_text(str(label.get("description", ""))),
}
for label in labels
if isinstance(label, dict)
]
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
return {
"owner": parsed.owner,
"repo": parsed.repo,
"labels": bounded,
"count": len(bounded),
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to list labels: {exc}") from exc
async def list_tags_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""List repository tags."""
parsed = ListTagsArgs.model_validate(arguments)
try:
tags = await gitea.list_tags(
parsed.owner, parsed.repo, page=parsed.page, limit=parsed.limit
)
normalized = [
{
"name": tag.get("name", ""),
"commit": tag.get("commit", {}).get("sha", ""),
"zipball_url": tag.get("zipball_url", ""),
"tarball_url": tag.get("tarball_url", ""),
}
for tag in tags
if isinstance(tag, dict)
]
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
return {
"owner": parsed.owner,
"repo": parsed.repo,
"tags": bounded,
"count": len(bounded),
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to list tags: {exc}") from exc
async def list_releases_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""List repository releases."""
parsed = ListReleasesArgs.model_validate(arguments)
try:
releases = await gitea.list_releases(
parsed.owner,
parsed.repo,
page=parsed.page,
limit=parsed.limit,
)
normalized = [
{
"id": release.get("id", 0),
"tag_name": release.get("tag_name", ""),
"name": limit_text(str(release.get("name", ""))),
"draft": release.get("draft", False),
"prerelease": release.get("prerelease", False),
"body": limit_text(str(release.get("body", ""))),
"created_at": release.get("created_at", ""),
"published_at": release.get("published_at", ""),
"url": release.get("html_url", ""),
}
for release in releases
if isinstance(release, dict)
]
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
return {
"owner": parsed.owner,
"repo": parsed.repo,
"releases": bounded,
"count": len(bounded),
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to list releases: {exc}") from exc
async def list_pull_request_files_tool(
gitea: GiteaClient, arguments: dict[str, Any]
) -> dict[str, Any]:
"""List files changed in a pull request."""
parsed = ListPullRequestFilesArgs.model_validate(arguments)
try:
files = await gitea.list_pull_request_files(
parsed.owner, parsed.repo, parsed.pull_number, page=parsed.page, limit=parsed.limit
)
normalized = [
{
"filename": item.get("filename", ""),
"status": item.get("status", ""),
"additions": item.get("additions", 0),
"deletions": item.get("deletions", 0),
"changes": item.get("changes", 0),
}
for item in files
if isinstance(item, dict)
]
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
return {
"owner": parsed.owner,
"repo": parsed.repo,
"pull_number": parsed.pull_number,
"files": bounded,
"count": len(bounded),
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to list pull request files: {exc}") from exc
async def list_pull_request_commits_tool(
gitea: GiteaClient, arguments: dict[str, Any]
) -> dict[str, Any]:
"""List commits in a pull request."""
parsed = ListPullRequestCommitsArgs.model_validate(arguments)
try:
commits = await gitea.list_pull_request_commits(
parsed.owner, parsed.repo, parsed.pull_number, page=parsed.page, limit=parsed.limit
)
normalized = [
{
"sha": commit.get("sha", ""),
"message": limit_text(str(commit.get("commit", {}).get("message", ""))),
"author": (
commit.get("author", {}).get("login", "")
if isinstance(commit.get("author"), dict)
else ""
),
}
for commit in commits
if isinstance(commit, dict)
]
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
return {
"owner": parsed.owner,
"repo": parsed.repo,
"pull_number": parsed.pull_number,
"commits": bounded,
"count": len(bounded),
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to list pull request commits: {exc}") from exc
async def list_issue_comments_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""List comments on an issue or pull request."""
parsed = ListIssueCommentsArgs.model_validate(arguments)
try:
comments = await gitea.list_issue_comments(
parsed.owner, parsed.repo, parsed.issue_number, page=parsed.page, limit=parsed.limit
)
normalized = [
{
"id": comment.get("id", 0),
"author": (
comment.get("user", {}).get("login", "")
if isinstance(comment.get("user"), dict)
else ""
),
"body": limit_text(str(comment.get("body", ""))),
"created_at": comment.get("created_at", ""),
"updated_at": comment.get("updated_at", ""),
"url": comment.get("html_url", ""),
}
for comment in comments
if isinstance(comment, dict)
]
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
return {
"owner": parsed.owner,
"repo": parsed.repo,
"issue_number": parsed.issue_number,
"comments": bounded,
"count": len(bounded),
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to list issue comments: {exc}") from exc
def _normalize_branch(branch: dict[str, Any]) -> dict[str, Any]:
"""Normalize a Gitea branch payload."""
commit = branch.get("commit", {}) if isinstance(branch.get("commit"), dict) else {}
return {
"name": branch.get("name", ""),
"protected": branch.get("protected", False),
"commit": commit.get("id", ""),
}
async def list_branches_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""List repository branches."""
parsed = ListBranchesArgs.model_validate(arguments)
try:
branches = await gitea.list_branches(
parsed.owner, parsed.repo, page=parsed.page, limit=parsed.limit
)
normalized = [_normalize_branch(b) for b in branches if isinstance(b, dict)]
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
return {
"owner": parsed.owner,
"repo": parsed.repo,
"branches": bounded,
"count": len(bounded),
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to list branches: {exc}") from exc
async def get_branch_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Get a single branch."""
parsed = GetBranchArgs.model_validate(arguments)
try:
branch = await gitea.get_branch(parsed.owner, parsed.repo, parsed.branch)
result = _normalize_branch(branch)
result.update({"owner": parsed.owner, "repo": parsed.repo})
return result
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to get branch: {exc}") from exc
def _normalize_release(release: dict[str, Any]) -> dict[str, Any]:
"""Normalize a Gitea release payload."""
return {
"id": release.get("id", 0),
"tag_name": release.get("tag_name", ""),
"name": limit_text(str(release.get("name", ""))),
"draft": release.get("draft", False),
"prerelease": release.get("prerelease", False),
"body": limit_text(str(release.get("body", ""))),
"created_at": release.get("created_at", ""),
"published_at": release.get("published_at", ""),
"url": release.get("html_url", ""),
}
async def get_release_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Get a release by id."""
parsed = GetReleaseArgs.model_validate(arguments)
try:
release = await gitea.get_release(parsed.owner, parsed.repo, parsed.release_id)
return _normalize_release(release)
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to get release: {exc}") from exc
async def get_latest_release_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Get the latest published release."""
parsed = LatestReleaseArgs.model_validate(arguments)
try:
release = await gitea.get_latest_release(parsed.owner, parsed.repo)
return _normalize_release(release)
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to get latest release: {exc}") from exc
async def list_milestones_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""List repository milestones."""
parsed = ListMilestonesArgs.model_validate(arguments)
try:
milestones = await gitea.list_milestones(
parsed.owner, parsed.repo, state=parsed.state, page=parsed.page, limit=parsed.limit
)
normalized = [
{
"id": m.get("id", 0),
"title": limit_text(str(m.get("title", ""))),
"state": m.get("state", ""),
"open_issues": m.get("open_issues", 0),
"closed_issues": m.get("closed_issues", 0),
"due_on": m.get("due_on", ""),
}
for m in milestones
if isinstance(m, dict)
]
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
return {
"owner": parsed.owner,
"repo": parsed.repo,
"state": parsed.state,
"milestones": bounded,
"count": len(bounded),
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to list milestones: {exc}") from exc
async def get_commit_status_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Get the combined commit status for a ref/sha."""
parsed = CommitStatusArgs.model_validate(arguments)
try:
status = await gitea.get_commit_status(parsed.owner, parsed.repo, parsed.sha)
statuses_raw = status.get("statuses", []) if isinstance(status, dict) else []
statuses = [
{
"context": s.get("context", ""),
"state": s.get("status", s.get("state", "")),
"target_url": s.get("target_url", ""),
}
for s in statuses_raw
if isinstance(s, dict)
]
bounded, omitted = limit_items(statuses)
return {
"owner": parsed.owner,
"repo": parsed.repo,
"sha": parsed.sha,
"state": status.get("state", "") if isinstance(status, dict) else "",
"statuses": bounded,
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to get commit status: {exc}") from exc
def _normalize_repo_summary(repo: dict[str, Any]) -> dict[str, Any]:
"""Normalize a repository payload to a compact summary."""
owner = repo.get("owner", {})
return {
"owner": owner.get("login", "") if isinstance(owner, dict) else "",
"name": repo.get("name", ""),
"full_name": repo.get("full_name", ""),
"private": repo.get("private", False),
"description": limit_text(str(repo.get("description", ""))),
"url": repo.get("html_url", ""),
}
async def list_org_repositories_tool(
gitea: GiteaClient, arguments: dict[str, Any]
) -> dict[str, Any]:
"""List repositories belonging to an organization."""
parsed = ListOrgRepositoriesArgs.model_validate(arguments)
try:
repos = await gitea.list_org_repositories(parsed.org, page=parsed.page, limit=parsed.limit)
normalized = [_normalize_repo_summary(r) for r in repos if isinstance(r, dict)]
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
return {
"org": parsed.org,
"repositories": bounded,
"count": len(bounded),
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to list org repositories: {exc}") from exc
async def list_organizations_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""List organizations the authenticated user belongs to."""
parsed = ListOrganizationsArgs.model_validate(arguments)
try:
orgs = await gitea.list_organizations(page=parsed.page, limit=parsed.limit)
normalized = [
{
"id": org.get("id", 0),
"name": org.get("username", org.get("name", "")),
"full_name": org.get("full_name", ""),
"description": limit_text(str(org.get("description", ""))),
}
for org in orgs
if isinstance(org, dict)
]
bounded, omitted = limit_items(normalized, configured_limit=parsed.limit)
return {
"organizations": bounded,
"count": len(bounded),
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to list organizations: {exc}") from exc
async def get_repo_languages_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Get the language breakdown for a repository."""
parsed = RepoLanguagesArgs.model_validate(arguments)
try:
languages = await gitea.get_repo_languages(parsed.owner, parsed.repo)
cleaned = {str(name): value for name, value in languages.items() if isinstance(name, str)}
return {
"owner": parsed.owner,
"repo": parsed.repo,
"languages": cleaned,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to get repository languages: {exc}") from exc
async def list_repo_topics_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""List the topics assigned to a repository."""
parsed = RepoTopicsArgs.model_validate(arguments)
try:
topics = await gitea.list_repo_topics(parsed.owner, parsed.repo)
bounded, omitted = limit_items([{"topic": t} for t in topics])
return {
"owner": parsed.owner,
"repo": parsed.repo,
"topics": [entry["topic"] for entry in bounded],
"count": len(bounded),
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to list repository topics: {exc}") from exc
-190
View File
@@ -1,190 +0,0 @@
"""Repository-related MCP tool implementations."""
from __future__ import annotations
import base64
import binascii
from typing import Any
from aegis_gitea_mcp.config import get_settings
from aegis_gitea_mcp.gitea_client import (
GiteaAuthenticationError,
GiteaAuthorizationError,
GiteaClient,
GiteaError,
)
from aegis_gitea_mcp.request_context import get_gitea_user_login
from aegis_gitea_mcp.response_limits import limit_items, limit_text
from aegis_gitea_mcp.security import sanitize_untrusted_text
from aegis_gitea_mcp.tools.arguments import (
FileContentsArgs,
FileTreeArgs,
ListRepositoriesArgs,
RepositoryArgs,
)
async def list_repositories_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""List repositories visible to the active Gitea API token.
Args:
gitea: Initialized Gitea client.
arguments: Tool arguments.
Returns:
Response payload with bounded repository list.
"""
ListRepositoriesArgs.model_validate(arguments)
settings = get_settings()
login = get_gitea_user_login()
try:
# In service-PAT mode the API token is the bot's, so scope the listing to
# the authenticated user's own repositories. In pure-OAuth mode the API
# token already belongs to the user, so Gitea scopes /user/repos for us.
if settings.gitea_token.strip() and login and login != "unknown":
repositories = await gitea.list_user_repositories(login)
else:
repositories = await gitea.list_repositories()
simplified = [
{
"owner": repo.get("owner", {}).get("login", ""),
"name": repo.get("name", ""),
"full_name": repo.get("full_name", ""),
"description": repo.get("description", ""),
"private": repo.get("private", False),
"default_branch": repo.get("default_branch", "main"),
"language": repo.get("language", ""),
"stars": repo.get("stars_count", 0),
"url": repo.get("html_url", ""),
}
for repo in repositories
]
bounded, omitted = limit_items(simplified)
return {
"repositories": bounded,
"count": len(bounded),
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to list repositories: {exc}") from exc
async def get_repository_info_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Get detailed metadata for a repository."""
parsed = RepositoryArgs.model_validate(arguments)
try:
repo_data = await gitea.get_repository(parsed.owner, parsed.repo)
return {
"owner": repo_data.get("owner", {}).get("login", ""),
"name": repo_data.get("name", ""),
"full_name": repo_data.get("full_name", ""),
"description": repo_data.get("description", ""),
"private": repo_data.get("private", False),
"fork": repo_data.get("fork", False),
"default_branch": repo_data.get("default_branch", "main"),
"language": repo_data.get("language", ""),
"stars": repo_data.get("stars_count", 0),
"forks": repo_data.get("forks_count", 0),
"open_issues": repo_data.get("open_issues_count", 0),
"size": repo_data.get("size", 0),
"created_at": repo_data.get("created_at", ""),
"updated_at": repo_data.get("updated_at", ""),
"url": repo_data.get("html_url", ""),
"clone_url": repo_data.get("clone_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to get repository info: {exc}") from exc
async def get_file_tree_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Get repository file tree at selected ref."""
parsed = FileTreeArgs.model_validate(arguments)
try:
tree_data = await gitea.get_tree(parsed.owner, parsed.repo, parsed.ref, parsed.recursive)
tree_entries = tree_data.get("tree", [])
simplified = [
{
"path": entry.get("path", ""),
"type": entry.get("type", ""),
"size": entry.get("size", 0),
"sha": entry.get("sha", ""),
}
for entry in tree_entries
]
bounded, omitted = limit_items(simplified)
return {
"owner": parsed.owner,
"repo": parsed.repo,
"ref": parsed.ref,
"recursive": parsed.recursive,
"tree": bounded,
"count": len(bounded),
"omitted": omitted,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to get file tree: {exc}") from exc
async def get_file_contents_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Read file contents from a repository ref.
Security notes:
- Repository content is treated as untrusted data and never executed.
- Text output is truncated to configured limits to reduce prompt-stuffing risk.
"""
parsed = FileContentsArgs.model_validate(arguments)
try:
file_data = await gitea.get_file_contents(
parsed.owner, parsed.repo, parsed.filepath, parsed.ref
)
content_b64 = file_data.get("content", "")
encoding = file_data.get("encoding", "base64")
content = str(content_b64)
if encoding == "base64":
try:
decoded_bytes = base64.b64decode(content_b64)
try:
content = decoded_bytes.decode("utf-8")
except UnicodeDecodeError:
# Edge case: binary files should remain encoded instead of forcing invalid text.
content = str(content_b64)
except (binascii.Error, ValueError):
content = str(content_b64)
# Validation logic: keep untrusted content bounded before returning it to LLM clients.
content = sanitize_untrusted_text(content, max_chars=200_000)
content = limit_text(content)
return {
"owner": parsed.owner,
"repo": parsed.repo,
"filepath": parsed.filepath,
"ref": parsed.ref,
"content": content,
"encoding": encoding,
"size": file_data.get("size", 0),
"sha": file_data.get("sha", ""),
"url": file_data.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to get file contents: {exc}") from exc
-413
View File
@@ -1,413 +0,0 @@
"""Write-mode MCP tool implementations (disabled by default)."""
from __future__ import annotations
from typing import Any
from aegis_gitea_mcp.gitea_client import (
GiteaAuthenticationError,
GiteaAuthorizationError,
GiteaClient,
GiteaError,
)
from aegis_gitea_mcp.response_limits import limit_text
from aegis_gitea_mcp.tools.arguments import (
AddLabelsArgs,
AssignIssueArgs,
CreateBranchArgs,
CreateIssueArgs,
CreateIssueCommentArgs,
CreateLabelArgs,
CreateMilestoneArgs,
CreatePrCommentArgs,
CreatePullRequestArgs,
CreateReleaseArgs,
EditIssueCommentArgs,
EditReleaseArgs,
RemoveLabelsArgs,
UpdateIssueArgs,
UpdateLabelArgs,
)
def _milestone_title(issue: dict[str, Any]) -> str:
"""Extract the milestone title from an issue payload, or '' if unset."""
milestone = issue.get("milestone")
if isinstance(milestone, dict):
return limit_text(str(milestone.get("title", "")))
return ""
async def create_label_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Create a repository label in write mode."""
parsed = CreateLabelArgs.model_validate(arguments)
# Gitea expects the color with a leading '#'; normalize either form.
color = parsed.color if parsed.color.startswith("#") else f"#{parsed.color}"
try:
label = await gitea.create_label(
parsed.owner,
parsed.repo,
name=parsed.name,
color=color,
description=parsed.description,
exclusive=parsed.exclusive,
)
return {
"id": label.get("id", 0),
"name": limit_text(str(label.get("name", ""))),
"color": label.get("color", ""),
"description": limit_text(str(label.get("description", ""))),
"url": label.get("url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to create label: {exc}") from exc
async def update_label_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Update an existing repository label (located by current name)."""
parsed = UpdateLabelArgs.model_validate(arguments)
color = parsed.color
if color is not None and not color.startswith("#"):
color = f"#{color}"
try:
label = await gitea.update_label(
parsed.owner,
parsed.repo,
name=parsed.name,
new_name=parsed.new_name,
color=color,
description=parsed.description,
)
return {
"id": label.get("id", 0),
"name": limit_text(str(label.get("name", ""))),
"color": label.get("color", ""),
"description": limit_text(str(label.get("description", ""))),
"url": label.get("url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to update label: {exc}") from exc
async def remove_labels_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Remove labels (by name) from an issue or pull request."""
parsed = RemoveLabelsArgs.model_validate(arguments)
try:
result = await gitea.remove_labels(
parsed.owner, parsed.repo, parsed.issue_number, parsed.labels
)
remaining: list[str] = []
if isinstance(result, list):
remaining = [label.get("name", "") for label in result if isinstance(label, dict)]
return {
"issue_number": parsed.issue_number,
"removed": parsed.labels,
"remaining_labels": remaining,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to remove labels: {exc}") from exc
async def create_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Create a new issue in write mode."""
parsed = CreateIssueArgs.model_validate(arguments)
try:
issue = await gitea.create_issue(
parsed.owner,
parsed.repo,
title=parsed.title,
body=parsed.body,
labels=parsed.labels,
assignees=parsed.assignees,
milestone=parsed.milestone,
)
return {
"number": issue.get("number", 0),
"title": limit_text(str(issue.get("title", ""))),
"state": issue.get("state", ""),
"milestone": _milestone_title(issue),
"url": issue.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to create issue: {exc}") from exc
async def update_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Update issue fields in write mode."""
parsed = UpdateIssueArgs.model_validate(arguments)
try:
issue = await gitea.update_issue(
parsed.owner,
parsed.repo,
parsed.issue_number,
title=parsed.title,
body=parsed.body,
state=parsed.state,
milestone=parsed.milestone,
)
return {
"number": issue.get("number", parsed.issue_number),
"title": limit_text(str(issue.get("title", ""))),
"state": issue.get("state", ""),
"milestone": _milestone_title(issue),
"url": issue.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to update issue: {exc}") from exc
async def create_issue_comment_tool(
gitea: GiteaClient, arguments: dict[str, Any]
) -> dict[str, Any]:
"""Create issue comment in write mode."""
parsed = CreateIssueCommentArgs.model_validate(arguments)
try:
comment = await gitea.create_issue_comment(
parsed.owner,
parsed.repo,
parsed.issue_number,
parsed.body,
)
return {
"id": comment.get("id", 0),
"issue_number": parsed.issue_number,
"body": limit_text(str(comment.get("body", ""))),
"url": comment.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to create issue comment: {exc}") from exc
async def create_pr_comment_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Create PR discussion comment in write mode."""
parsed = CreatePrCommentArgs.model_validate(arguments)
try:
comment = await gitea.create_pr_comment(
parsed.owner,
parsed.repo,
parsed.pull_number,
parsed.body,
)
return {
"id": comment.get("id", 0),
"pull_number": parsed.pull_number,
"body": limit_text(str(comment.get("body", ""))),
"url": comment.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to create PR comment: {exc}") from exc
async def add_labels_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Add labels to an issue or pull request."""
parsed = AddLabelsArgs.model_validate(arguments)
try:
result = await gitea.add_labels(
parsed.owner, parsed.repo, parsed.issue_number, parsed.labels
)
label_names = []
if isinstance(result, dict):
label_names = [label.get("name", "") for label in result.get("labels", [])]
return {
"issue_number": parsed.issue_number,
"labels": label_names or parsed.labels,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to add labels: {exc}") from exc
async def assign_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Assign users to an issue or pull request."""
parsed = AssignIssueArgs.model_validate(arguments)
try:
result = await gitea.assign_issue(
parsed.owner,
parsed.repo,
parsed.issue_number,
parsed.assignees,
)
assignees = []
if isinstance(result, dict):
assignees = [assignee.get("login", "") for assignee in result.get("assignees", [])]
return {
"issue_number": parsed.issue_number,
"assignees": assignees or parsed.assignees,
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to assign issue: {exc}") from exc
async def create_pull_request_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Open a pull request in write mode."""
parsed = CreatePullRequestArgs.model_validate(arguments)
try:
pull = await gitea.create_pull_request(
parsed.owner,
parsed.repo,
title=parsed.title,
head=parsed.head,
base=parsed.base,
body=parsed.body,
)
return {
"number": pull.get("number", 0),
"title": limit_text(str(pull.get("title", ""))),
"state": pull.get("state", ""),
"url": pull.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to create pull request: {exc}") from exc
async def create_release_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Create a release in write mode."""
parsed = CreateReleaseArgs.model_validate(arguments)
try:
release = await gitea.create_release(
parsed.owner,
parsed.repo,
tag_name=parsed.tag_name,
name=parsed.name,
body=parsed.body,
draft=parsed.draft,
prerelease=parsed.prerelease,
target=parsed.target,
)
return {
"id": release.get("id", 0),
"tag_name": release.get("tag_name", ""),
"name": limit_text(str(release.get("name", ""))),
"draft": release.get("draft", False),
"prerelease": release.get("prerelease", False),
"url": release.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to create release: {exc}") from exc
async def edit_release_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Edit an existing release in write mode."""
parsed = EditReleaseArgs.model_validate(arguments)
try:
release = await gitea.edit_release(
parsed.owner,
parsed.repo,
parsed.release_id,
name=parsed.name,
body=parsed.body,
draft=parsed.draft,
prerelease=parsed.prerelease,
)
return {
"id": release.get("id", parsed.release_id),
"tag_name": release.get("tag_name", ""),
"name": limit_text(str(release.get("name", ""))),
"draft": release.get("draft", False),
"prerelease": release.get("prerelease", False),
"url": release.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to edit release: {exc}") from exc
async def create_branch_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Create a branch in write mode."""
parsed = CreateBranchArgs.model_validate(arguments)
try:
branch = await gitea.create_branch(
parsed.owner,
parsed.repo,
new_branch_name=parsed.new_branch_name,
old_branch_name=parsed.old_branch_name,
)
commit = branch.get("commit", {}) if isinstance(branch, dict) else {}
return {
"name": branch.get("name", parsed.new_branch_name),
"commit": commit.get("id", "") if isinstance(commit, dict) else "",
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to create branch: {exc}") from exc
async def create_milestone_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Create a milestone in write mode."""
parsed = CreateMilestoneArgs.model_validate(arguments)
try:
milestone = await gitea.create_milestone(
parsed.owner,
parsed.repo,
title=parsed.title,
description=parsed.description,
due_on=parsed.due_on,
)
return {
"id": milestone.get("id", 0),
"title": limit_text(str(milestone.get("title", ""))),
"state": milestone.get("state", ""),
"url": milestone.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to create milestone: {exc}") from exc
async def edit_issue_comment_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Edit an existing issue or PR comment in write mode."""
parsed = EditIssueCommentArgs.model_validate(arguments)
try:
comment = await gitea.edit_issue_comment(
parsed.owner,
parsed.repo,
parsed.comment_id,
parsed.body,
)
return {
"id": comment.get("id", parsed.comment_id),
"body": limit_text(str(comment.get("body", ""))),
"url": comment.get("html_url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to edit comment: {exc}") from exc