feat: harden gateway with policy engine, secure tools, and governance docs

This commit is contained in:
2026-02-14 16:05:56 +01:00
parent e17d34e6d7
commit 5969892af3
55 changed files with 4711 additions and 1587 deletions
+412 -157
View File
@@ -1,8 +1,9 @@
"""Gitea API client with bot user authentication."""
"""Gitea API client with hardened request handling."""
from typing import Any, Dict, List, Optional
from __future__ import annotations
from typing import Any
import httpx
from httpx import AsyncClient, Response
from aegis_gitea_mcp.audit import get_audit_logger
@@ -12,47 +13,37 @@ from aegis_gitea_mcp.config import get_settings
class GiteaError(Exception):
"""Base exception for Gitea API errors."""
pass
class GiteaAuthenticationError(GiteaError):
"""Raised when authentication with Gitea fails."""
pass
class GiteaAuthorizationError(GiteaError):
"""Raised when bot user lacks permission for an operation."""
pass
class GiteaNotFoundError(GiteaError):
"""Raised when a requested resource is not found."""
pass
"""Raised when requested resource is not found."""
class GiteaClient:
"""Client for interacting with Gitea API as a bot user."""
def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None) -> None:
def __init__(self, base_url: str | None = None, token: str | None = None) -> None:
"""Initialize Gitea client.
Args:
base_url: Base URL of Gitea instance (defaults to config value)
token: Bot user access token (defaults to config value)
base_url: Optional base URL override.
token: Optional token override.
"""
self.settings = get_settings()
self.audit = get_audit_logger()
self.base_url = (base_url or self.settings.gitea_base_url).rstrip("/")
self.token = token or self.settings.gitea_token
self.client: AsyncClient | None = None
self.client: Optional[AsyncClient] = None
async def __aenter__(self) -> "GiteaClient":
"""Async context manager entry."""
async def __aenter__(self) -> GiteaClient:
"""Create async HTTP client context."""
self.client = AsyncClient(
base_url=self.base_url,
headers={
@@ -65,26 +56,22 @@ class GiteaClient:
return self
async def __aexit__(self, *args: Any) -> None:
"""Async context manager exit."""
"""Close async HTTP client context."""
if self.client:
await self.client.aclose()
def _handle_response(self, response: Response, correlation_id: str) -> Any:
"""Handle Gitea API response and raise appropriate exceptions.
Args:
response: HTTP response from Gitea
correlation_id: Correlation ID for audit logging
Returns:
Parsed JSON response
def _ensure_client(self) -> AsyncClient:
"""Return initialized HTTP client.
Raises:
GiteaAuthenticationError: On 401 responses
GiteaAuthorizationError: On 403 responses
GiteaNotFoundError: On 404 responses
GiteaError: On other error responses
RuntimeError: If called outside async context manager.
"""
if not self.client:
raise RuntimeError("Client not initialized - use async context manager")
return self.client
def _handle_response(self, response: Response, correlation_id: str) -> Any:
"""Handle HTTP response and map to domain exceptions."""
if response.status_code == 401:
self.audit.log_security_event(
event_type="authentication_failure",
@@ -97,7 +84,7 @@ class GiteaClient:
if response.status_code == 403:
self.audit.log_access_denied(
tool_name="gitea_api",
reason="Bot user lacks permission",
reason="bot user lacks permission",
correlation_id=correlation_id,
)
raise GiteaAuthorizationError("Bot user lacks permission for this operation")
@@ -109,7 +96,9 @@ class GiteaClient:
error_msg = f"Gitea API error: {response.status_code}"
try:
error_data = response.json()
error_msg = f"{error_msg} - {error_data.get('message', '')}"
message = error_data.get("message") if isinstance(error_data, dict) else None
if message:
error_msg = f"{error_msg} - {message}"
except Exception:
pass
raise GiteaError(error_msg)
@@ -119,35 +108,34 @@ class GiteaClient:
except Exception:
return {}
async def get_current_user(self) -> Dict[str, Any]:
"""Get information about the current bot user.
Returns:
User information dict
Raises:
GiteaError: On API errors
"""
if not self.client:
raise RuntimeError("Client not initialized - use async context manager")
async def _request(
self,
method: str,
endpoint: str,
*,
correlation_id: str,
params: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
) -> Any:
"""Execute a request to Gitea API with shared error handling."""
client = self._ensure_client()
response = await client.request(method=method, url=endpoint, params=params, json=json_body)
return self._handle_response(response, correlation_id)
async def get_current_user(self) -> dict[str, Any]:
"""Get current bot user profile."""
correlation_id = self.audit.log_tool_invocation(
tool_name="get_current_user",
result_status="pending",
)
try:
response = await self.client.get("/api/v1/user")
user_data = self._handle_response(response, correlation_id)
result = await self._request("GET", "/api/v1/user", correlation_id=correlation_id)
self.audit.log_tool_invocation(
tool_name="get_current_user",
correlation_id=correlation_id,
result_status="success",
)
return user_data
return result if isinstance(result, dict) else {}
except Exception as exc:
self.audit.log_tool_invocation(
tool_name="get_current_user",
@@ -157,39 +145,22 @@ class GiteaClient:
)
raise
async def list_repositories(self) -> List[Dict[str, Any]]:
"""List all repositories visible to the bot user.
Returns:
List of repository information dicts
Raises:
GiteaError: On API errors
"""
if not self.client:
raise RuntimeError("Client not initialized - use async context manager")
async def list_repositories(self) -> list[dict[str, Any]]:
"""List all repositories visible to the bot user."""
correlation_id = self.audit.log_tool_invocation(
tool_name="list_repositories",
result_status="pending",
)
try:
response = await self.client.get("/api/v1/user/repos")
repos_data = self._handle_response(response, correlation_id)
# Ensure we have a list
repos = repos_data if isinstance(repos_data, list) else []
result = await self._request("GET", "/api/v1/user/repos", correlation_id=correlation_id)
repositories = result if isinstance(result, list) else []
self.audit.log_tool_invocation(
tool_name="list_repositories",
correlation_id=correlation_id,
result_status="success",
params={"count": len(repos)},
params={"count": len(repositories)},
)
return repos
return repositories
except Exception as exc:
self.audit.log_tool_invocation(
tool_name="list_repositories",
@@ -199,43 +170,27 @@ class GiteaClient:
)
raise
async def get_repository(self, owner: str, repo: str) -> Dict[str, Any]:
"""Get information about a specific repository.
Args:
owner: Repository owner username
repo: Repository name
Returns:
Repository information dict
Raises:
GiteaNotFoundError: If repository doesn't exist or bot lacks access
GiteaError: On other API errors
"""
if not self.client:
raise RuntimeError("Client not initialized - use async context manager")
async def get_repository(self, owner: str, repo: str) -> dict[str, Any]:
"""Get repository metadata."""
repo_id = f"{owner}/{repo}"
correlation_id = self.audit.log_tool_invocation(
tool_name="get_repository",
repository=repo_id,
result_status="pending",
)
try:
response = await self.client.get(f"/api/v1/repos/{owner}/{repo}")
repo_data = self._handle_response(response, correlation_id)
result = await self._request(
"GET",
f"/api/v1/repos/{owner}/{repo}",
correlation_id=correlation_id,
)
self.audit.log_tool_invocation(
tool_name="get_repository",
repository=repo_id,
correlation_id=correlation_id,
result_status="success",
)
return repo_data
return result if isinstance(result, dict) else {}
except Exception as exc:
self.audit.log_tool_invocation(
tool_name="get_repository",
@@ -247,26 +202,13 @@ class GiteaClient:
raise
async def get_file_contents(
self, owner: str, repo: str, filepath: str, ref: str = "main"
) -> Dict[str, Any]:
"""Get contents of a file in a repository.
Args:
owner: Repository owner username
repo: Repository name
filepath: Path to file within repository
ref: Branch, tag, or commit ref (defaults to 'main')
Returns:
File contents dict with 'content', 'encoding', 'size', etc.
Raises:
GiteaNotFoundError: If file doesn't exist
GiteaError: On other API errors
"""
if not self.client:
raise RuntimeError("Client not initialized - use async context manager")
self,
owner: str,
repo: str,
filepath: str,
ref: str = "main",
) -> dict[str, Any]:
"""Get file contents from a repository."""
repo_id = f"{owner}/{repo}"
correlation_id = self.audit.log_tool_invocation(
tool_name="get_file_contents",
@@ -275,20 +217,22 @@ class GiteaClient:
params={"ref": ref},
result_status="pending",
)
try:
response = await self.client.get(
result = await self._request(
"GET",
f"/api/v1/repos/{owner}/{repo}/contents/{filepath}",
params={"ref": ref},
correlation_id=correlation_id,
)
file_data = self._handle_response(response, correlation_id)
# Check file size against limit
file_size = file_data.get("size", 0)
if not isinstance(result, dict):
raise GiteaError("Unexpected response type for file contents")
file_size = int(result.get("size", 0))
if file_size > self.settings.max_file_size_bytes:
error_msg = (
f"File size ({file_size} bytes) exceeds "
f"limit ({self.settings.max_file_size_bytes} bytes)"
f"File size ({file_size} bytes) exceeds limit "
f"({self.settings.max_file_size_bytes} bytes)"
)
self.audit.log_security_event(
event_type="file_size_limit_exceeded",
@@ -311,9 +255,7 @@ class GiteaClient:
result_status="success",
params={"ref": ref, "size": file_size},
)
return file_data
return result
except Exception as exc:
self.audit.log_tool_invocation(
tool_name="get_file_contents",
@@ -326,25 +268,13 @@ class GiteaClient:
raise
async def get_tree(
self, owner: str, repo: str, ref: str = "main", recursive: bool = False
) -> Dict[str, Any]:
"""Get file tree for a repository.
Args:
owner: Repository owner username
repo: Repository name
ref: Branch, tag, or commit ref (defaults to 'main')
recursive: Whether to recursively fetch tree (default: False for safety)
Returns:
Tree information dict
Raises:
GiteaError: On API errors
"""
if not self.client:
raise RuntimeError("Client not initialized - use async context manager")
self,
owner: str,
repo: str,
ref: str = "main",
recursive: bool = False,
) -> dict[str, Any]:
"""Get repository tree at given ref."""
repo_id = f"{owner}/{repo}"
correlation_id = self.audit.log_tool_invocation(
tool_name="get_tree",
@@ -352,24 +282,26 @@ class GiteaClient:
params={"ref": ref, "recursive": recursive},
result_status="pending",
)
try:
response = await self.client.get(
result = await self._request(
"GET",
f"/api/v1/repos/{owner}/{repo}/git/trees/{ref}",
params={"recursive": str(recursive).lower()},
correlation_id=correlation_id,
)
tree_data = self._handle_response(response, correlation_id)
tree_data = result if isinstance(result, dict) else {}
self.audit.log_tool_invocation(
tool_name="get_tree",
repository=repo_id,
correlation_id=correlation_id,
result_status="success",
params={"ref": ref, "recursive": recursive, "count": len(tree_data.get("tree", []))},
params={
"ref": ref,
"recursive": recursive,
"count": len(tree_data.get("tree", [])),
},
)
return tree_data
except Exception as exc:
self.audit.log_tool_invocation(
tool_name="get_tree",
@@ -379,3 +311,326 @@ class GiteaClient:
error=str(exc),
)
raise
async def search_code(
self,
owner: str,
repo: str,
query: str,
*,
ref: str,
page: int,
limit: int,
) -> dict[str, Any]:
"""Search repository code by query."""
correlation_id = self.audit.log_tool_invocation(
tool_name="search_code",
repository=f"{owner}/{repo}",
params={"query": query, "ref": ref, "page": page, "limit": limit},
result_status="pending",
)
try:
result = await self._request(
"GET",
f"/api/v1/repos/{owner}/{repo}/search",
params={"q": query, "page": page, "limit": limit, "ref": ref},
correlation_id=correlation_id,
)
self.audit.log_tool_invocation(
tool_name="search_code",
repository=f"{owner}/{repo}",
correlation_id=correlation_id,
result_status="success",
)
return result if isinstance(result, dict) else {}
except Exception as exc:
self.audit.log_tool_invocation(
tool_name="search_code",
repository=f"{owner}/{repo}",
correlation_id=correlation_id,
result_status="error",
error=str(exc),
)
raise
async def list_commits(
self,
owner: str,
repo: str,
*,
ref: str,
page: int,
limit: int,
) -> list[dict[str, Any]]:
"""List commits for a repository ref."""
result = await self._request(
"GET",
f"/api/v1/repos/{owner}/{repo}/commits",
params={"sha": ref, "page": page, "limit": limit},
correlation_id=str(
self.audit.log_tool_invocation(tool_name="list_commits", result_status="pending")
),
)
return result if isinstance(result, list) else []
async def get_commit_diff(self, owner: str, repo: str, sha: str) -> dict[str, Any]:
"""Get detailed commit including changed files and patch metadata."""
result = await self._request(
"GET",
f"/api/v1/repos/{owner}/{repo}/git/commits/{sha}",
correlation_id=str(
self.audit.log_tool_invocation(tool_name="get_commit_diff", result_status="pending")
),
)
return result if isinstance(result, dict) else {}
async def compare_refs(self, owner: str, repo: str, base: str, head: str) -> dict[str, Any]:
"""Compare two refs and return commit/file deltas."""
result = await self._request(
"GET",
f"/api/v1/repos/{owner}/{repo}/compare/{base}...{head}",
correlation_id=str(
self.audit.log_tool_invocation(tool_name="compare_refs", result_status="pending")
),
)
return result if isinstance(result, dict) else {}
async def list_issues(
self,
owner: str,
repo: str,
*,
state: str,
page: int,
limit: int,
labels: list[str] | None = None,
) -> list[dict[str, Any]]:
"""List repository issues."""
params: dict[str, Any] = {"state": state, "page": page, "limit": limit}
if labels:
params["labels"] = ",".join(labels)
result = await self._request(
"GET",
f"/api/v1/repos/{owner}/{repo}/issues",
params=params,
correlation_id=str(
self.audit.log_tool_invocation(tool_name="list_issues", result_status="pending")
),
)
return result if isinstance(result, list) else []
async def get_issue(self, owner: str, repo: str, index: int) -> dict[str, Any]:
"""Get issue details."""
result = await self._request(
"GET",
f"/api/v1/repos/{owner}/{repo}/issues/{index}",
correlation_id=str(
self.audit.log_tool_invocation(tool_name="get_issue", result_status="pending")
),
)
return result if isinstance(result, dict) else {}
async def list_pull_requests(
self,
owner: str,
repo: str,
*,
state: str,
page: int,
limit: int,
) -> list[dict[str, Any]]:
"""List pull requests for repository."""
result = await self._request(
"GET",
f"/api/v1/repos/{owner}/{repo}/pulls",
params={"state": state, "page": page, "limit": limit},
correlation_id=str(
self.audit.log_tool_invocation(
tool_name="list_pull_requests", result_status="pending"
)
),
)
return result if isinstance(result, list) else []
async def get_pull_request(self, owner: str, repo: str, index: int) -> dict[str, Any]:
"""Get a single pull request."""
result = await self._request(
"GET",
f"/api/v1/repos/{owner}/{repo}/pulls/{index}",
correlation_id=str(
self.audit.log_tool_invocation(
tool_name="get_pull_request", result_status="pending"
)
),
)
return result if isinstance(result, dict) else {}
async def list_labels(
self, owner: str, repo: str, *, page: int, limit: int
) -> list[dict[str, Any]]:
"""List repository labels."""
result = await self._request(
"GET",
f"/api/v1/repos/{owner}/{repo}/labels",
params={"page": page, "limit": limit},
correlation_id=str(
self.audit.log_tool_invocation(tool_name="list_labels", result_status="pending")
),
)
return result if isinstance(result, list) else []
async def list_tags(
self, owner: str, repo: str, *, page: int, limit: int
) -> list[dict[str, Any]]:
"""List repository tags."""
result = await self._request(
"GET",
f"/api/v1/repos/{owner}/{repo}/tags",
params={"page": page, "limit": limit},
correlation_id=str(
self.audit.log_tool_invocation(tool_name="list_tags", result_status="pending")
),
)
return result if isinstance(result, list) else []
async def list_releases(
self,
owner: str,
repo: str,
*,
page: int,
limit: int,
) -> list[dict[str, Any]]:
"""List repository releases."""
result = await self._request(
"GET",
f"/api/v1/repos/{owner}/{repo}/releases",
params={"page": page, "limit": limit},
correlation_id=str(
self.audit.log_tool_invocation(tool_name="list_releases", result_status="pending")
),
)
return result if isinstance(result, list) else []
async def create_issue(
self,
owner: str,
repo: str,
*,
title: str,
body: str,
labels: list[str] | None = None,
assignees: list[str] | None = None,
) -> dict[str, Any]:
"""Create repository issue."""
payload: dict[str, Any] = {"title": title, "body": body}
if labels:
payload["labels"] = labels
if assignees:
payload["assignees"] = assignees
result = await self._request(
"POST",
f"/api/v1/repos/{owner}/{repo}/issues",
json_body=payload,
correlation_id=str(
self.audit.log_tool_invocation(tool_name="create_issue", result_status="pending")
),
)
return result if isinstance(result, dict) else {}
async def update_issue(
self,
owner: str,
repo: str,
index: int,
*,
title: str | None = None,
body: str | None = None,
state: str | None = None,
) -> dict[str, Any]:
"""Update issue fields."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if body is not None:
payload["body"] = body
if state is not None:
payload["state"] = state
result = await self._request(
"PATCH",
f"/api/v1/repos/{owner}/{repo}/issues/{index}",
json_body=payload,
correlation_id=str(
self.audit.log_tool_invocation(tool_name="update_issue", result_status="pending")
),
)
return result if isinstance(result, dict) else {}
async def create_issue_comment(
self, owner: str, repo: str, index: int, body: str
) -> dict[str, Any]:
"""Create a comment on issue (and PR discussion if issue index refers to PR)."""
result = await self._request(
"POST",
f"/api/v1/repos/{owner}/{repo}/issues/{index}/comments",
json_body={"body": body},
correlation_id=str(
self.audit.log_tool_invocation(
tool_name="create_issue_comment", result_status="pending"
)
),
)
return result if isinstance(result, dict) else {}
async def create_pr_comment(
self, owner: str, repo: str, index: int, body: str
) -> dict[str, Any]:
"""Create PR discussion comment."""
result = await self._request(
"POST",
f"/api/v1/repos/{owner}/{repo}/issues/{index}/comments",
json_body={"body": body},
correlation_id=str(
self.audit.log_tool_invocation(
tool_name="create_pr_comment", result_status="pending"
)
),
)
return result if isinstance(result, dict) else {}
async def add_labels(
self,
owner: str,
repo: str,
index: int,
labels: list[str],
) -> dict[str, Any]:
"""Add labels to issue/PR."""
result = await self._request(
"POST",
f"/api/v1/repos/{owner}/{repo}/issues/{index}/labels",
json_body={"labels": labels},
correlation_id=str(
self.audit.log_tool_invocation(tool_name="add_labels", result_status="pending")
),
)
return result if isinstance(result, dict) else {}
async def assign_issue(
self,
owner: str,
repo: str,
index: int,
assignees: list[str],
) -> dict[str, Any]:
"""Assign users to issue/PR."""
result = await self._request(
"POST",
f"/api/v1/repos/{owner}/{repo}/issues/{index}/assignees",
json_body={"assignees": assignees},
correlation_id=str(
self.audit.log_tool_invocation(tool_name="assign_issue", result_status="pending")
),
)
return result if isinstance(result, dict) else {}