Dev #72
No files matched your search
-23
@@ -1,23 +0,0 @@
|
||||
defaults:
|
||||
read: allow
|
||||
write: deny
|
||||
|
||||
tools:
|
||||
deny: []
|
||||
# The generic `gitea_request` tool authorizes each call under a coarse virtual
|
||||
# tool name of the form `gitea_request:<METHOD>:<top-path-segment>`, e.g.
|
||||
# `gitea_request:GET:repos` or `gitea_request:DELETE:repos`. To keep raw
|
||||
# dispatch read-only while still allowing GETs, deny the write methods here:
|
||||
#
|
||||
# deny:
|
||||
# - gitea_request:POST:repos
|
||||
# - gitea_request:PUT:repos
|
||||
# - gitea_request:PATCH:repos
|
||||
# - gitea_request:DELETE:repos
|
||||
#
|
||||
# NOTE: The admin/credential denylist (/admin, *tokens*, *secrets*, *hooks*,
|
||||
# *keys*, applications/oauth2, runner registration tokens) is enforced in the
|
||||
# handler independently of this file and is NOT configured here. It can only be
|
||||
# overridden by setting RAW_API_ALLOW_SENSITIVE=true.
|
||||
|
||||
repositories: {}
|
||||
@@ -1,140 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check API key age and alert if rotation needed."""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Check API key age from keys/ metadata directory."""
|
||||
print("=" * 70)
|
||||
print("AegisGitea MCP - API Key Age Check")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Check keys directory
|
||||
keys_dir = Path(__file__).parent.parent / "keys"
|
||||
|
||||
if not keys_dir.exists():
|
||||
print("⚠️ No keys/ directory found")
|
||||
print()
|
||||
print(" Key metadata is not being tracked.")
|
||||
print(" Run scripts/generate_api_key.py and save metadata to track key age.")
|
||||
print()
|
||||
sys.exit(0)
|
||||
|
||||
# Find all key metadata files
|
||||
metadata_files = list(keys_dir.glob("key-*.txt"))
|
||||
|
||||
if not metadata_files:
|
||||
print("⚠️ No key metadata files found in keys/")
|
||||
print()
|
||||
print(" Run scripts/generate_api_key.py and save metadata to track key age.")
|
||||
print()
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Found {len(metadata_files)} key metadata file(s)")
|
||||
print()
|
||||
print("-" * 70)
|
||||
print()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
warnings = []
|
||||
critical = []
|
||||
|
||||
for metadata_file in sorted(metadata_files):
|
||||
with open(metadata_file, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Extract metadata
|
||||
key_id_match = re.search(r"Key ID:\s+([^\n]+)", content)
|
||||
created_match = re.search(r"Created:\s+([^\n]+)", content)
|
||||
expires_match = re.search(r"Expires:\s+([^\n]+)", content)
|
||||
|
||||
if not all([key_id_match, created_match, expires_match]):
|
||||
print(f"⚠️ Could not parse: {metadata_file.name}")
|
||||
continue
|
||||
|
||||
key_id = key_id_match.group(1).strip()
|
||||
created_str = created_match.group(1).strip()
|
||||
expires_str = expires_match.group(1).strip()
|
||||
|
||||
# Parse dates
|
||||
try:
|
||||
created_at = datetime.fromisoformat(created_str.replace("Z", "+00:00"))
|
||||
expires_at = datetime.fromisoformat(expires_str.replace("Z", "+00:00"))
|
||||
except ValueError as e:
|
||||
print(f"⚠️ Could not parse dates in: {metadata_file.name}")
|
||||
print(f" Error: {e}")
|
||||
continue
|
||||
|
||||
# Calculate age and time to expiration
|
||||
age_days = (now - created_at).days
|
||||
days_to_expiration = (expires_at - now).days
|
||||
|
||||
# Status
|
||||
if days_to_expiration < 0:
|
||||
status = "❌ EXPIRED"
|
||||
critical.append(key_id)
|
||||
elif days_to_expiration <= 7:
|
||||
status = "⚠️ CRITICAL"
|
||||
critical.append(key_id)
|
||||
elif days_to_expiration <= 14:
|
||||
status = "⚠️ WARNING"
|
||||
warnings.append(key_id)
|
||||
else:
|
||||
status = "✓ OK"
|
||||
|
||||
print(f"Key: {key_id}")
|
||||
print(f" Created: {created_at.strftime('%Y-%m-%d')} ({age_days} days ago)")
|
||||
print(f" Expires: {expires_at.strftime('%Y-%m-%d')} (in {days_to_expiration} days)")
|
||||
print(f" Status: {status}")
|
||||
print()
|
||||
|
||||
print("-" * 70)
|
||||
print()
|
||||
|
||||
# Summary
|
||||
if critical:
|
||||
print("❌ CRITICAL: {} key(s) require immediate rotation!".format(len(critical)))
|
||||
print()
|
||||
print(" Keys:")
|
||||
for key_id in critical:
|
||||
print(f" • {key_id}")
|
||||
print()
|
||||
print(" Action: Run scripts/rotate_api_key.py NOW")
|
||||
print()
|
||||
sys.exit(2)
|
||||
elif warnings:
|
||||
print("⚠️ WARNING: {} key(s) will expire soon".format(len(warnings)))
|
||||
print()
|
||||
print(" Keys:")
|
||||
for key_id in warnings:
|
||||
print(f" • {key_id}")
|
||||
print()
|
||||
print(" Action: Schedule key rotation in the next few days")
|
||||
print(" Run: scripts/rotate_api_key.py")
|
||||
print()
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("✓ All keys are valid and within rotation schedule")
|
||||
print()
|
||||
print(" Next check: Run this script again in 1 week")
|
||||
print()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nOperation cancelled.")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -1,113 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a cryptographically secure API key for AegisGitea MCP."""
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from aegis_gitea_mcp.auth import generate_api_key
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Generate and display a new API key."""
|
||||
print("=" * 70)
|
||||
print("AegisGitea MCP - API Key Generator")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Get optional description
|
||||
description = input("Enter description for this key (e.g., 'Claude Code'): ").strip()
|
||||
if not description:
|
||||
description = "Generated key"
|
||||
|
||||
# Generate key
|
||||
api_key = generate_api_key(length=64)
|
||||
|
||||
# Calculate expiration date (90 days from now)
|
||||
created_at = datetime.now(timezone.utc)
|
||||
expires_at = created_at + timedelta(days=90)
|
||||
|
||||
print()
|
||||
print("✓ API Key Generated Successfully!")
|
||||
print()
|
||||
print("-" * 70)
|
||||
print(f"Description: {description}")
|
||||
print(f"Created: {created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
||||
print(f"Expires: {expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')} (90 days)")
|
||||
print("-" * 70)
|
||||
print()
|
||||
print("API KEY:")
|
||||
print(f" {api_key}")
|
||||
print()
|
||||
print("-" * 70)
|
||||
print()
|
||||
print("📋 Next Steps:")
|
||||
print()
|
||||
print("1. Add this key to your .env file:")
|
||||
print()
|
||||
print(f" MCP_API_KEYS={api_key}")
|
||||
print()
|
||||
print(" (If you have multiple keys, separate them with commas)")
|
||||
print()
|
||||
print("2. Restart the MCP server:")
|
||||
print()
|
||||
print(" docker-compose restart aegis-mcp")
|
||||
print()
|
||||
print("3. Configure your MCP client:")
|
||||
print()
|
||||
print(" - Add the server in your MCP client settings")
|
||||
print(" - Add custom header:")
|
||||
print(f" Authorization: Bearer {api_key}")
|
||||
print()
|
||||
print("4. Test the connection:")
|
||||
print()
|
||||
print(" Ask the client: 'List my Gitea repositories'")
|
||||
print()
|
||||
print("-" * 70)
|
||||
print()
|
||||
print("⚠️ IMPORTANT:")
|
||||
print()
|
||||
print(" • Store this key securely - it won't be shown again")
|
||||
print(" • This key should be rotated in 90 days")
|
||||
print(" • Set a reminder to rotate before expiration")
|
||||
print(" • Never commit this key to version control")
|
||||
print()
|
||||
print("=" * 70)
|
||||
|
||||
# Offer to save metadata to file
|
||||
save = input("\nSave key metadata to keys/ directory? [y/N]: ").strip().lower()
|
||||
if save == "y":
|
||||
keys_dir = Path(__file__).parent.parent / "keys"
|
||||
keys_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Create metadata file
|
||||
key_id = api_key[:12]
|
||||
metadata_file = keys_dir / f"key-{key_id}-{created_at.strftime('%Y%m%d')}.txt"
|
||||
|
||||
with open(metadata_file, "w", encoding="utf-8") as f:
|
||||
f.write(f"API Key Metadata\n")
|
||||
f.write(f"================\n\n")
|
||||
f.write(f"Key ID: {key_id}...\n")
|
||||
f.write(f"Description: {description}\n")
|
||||
f.write(f"Created: {created_at.isoformat()}\n")
|
||||
f.write(f"Expires: {expires_at.isoformat()}\n")
|
||||
f.write(f"\n")
|
||||
f.write(f"NOTE: The actual API key is NOT stored in this file for security.\n")
|
||||
f.write(f" Only metadata is saved for reference.\n")
|
||||
|
||||
print(f"\n✓ Metadata saved to: {metadata_file}")
|
||||
print(f"\n (The actual key is NOT saved - only you have it)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nOperation cancelled.")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -1,171 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rotate API key for AegisGitea MCP."""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from aegis_gitea_mcp.auth import generate_api_key
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Rotate API key in .env file."""
|
||||
print("=" * 70)
|
||||
print("AegisGitea MCP - API Key Rotation")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Find .env file
|
||||
env_file = Path(__file__).parent.parent / ".env"
|
||||
|
||||
if not env_file.exists():
|
||||
print("❌ Error: .env file not found")
|
||||
print(f" Expected location: {env_file}")
|
||||
print()
|
||||
print(" Please create .env from .env.example first")
|
||||
sys.exit(1)
|
||||
|
||||
# Read current .env
|
||||
with open(env_file, "r", encoding="utf-8") as f:
|
||||
env_content = f.read()
|
||||
|
||||
# Check if MCP_API_KEYS exists
|
||||
if "MCP_API_KEYS" not in env_content:
|
||||
print("❌ Error: MCP_API_KEYS not found in .env file")
|
||||
print()
|
||||
print(" Please add MCP_API_KEYS to your .env file first")
|
||||
sys.exit(1)
|
||||
|
||||
# Extract current key
|
||||
match = re.search(r"MCP_API_KEYS=([^\n]+)", env_content)
|
||||
if not match:
|
||||
print("❌ Error: Could not parse MCP_API_KEYS from .env")
|
||||
sys.exit(1)
|
||||
|
||||
current_keys = match.group(1).strip()
|
||||
key_list = [k.strip() for k in current_keys.split(",") if k.strip()]
|
||||
|
||||
print(f"Found {len(key_list)} existing key(s)")
|
||||
print()
|
||||
|
||||
# Show current keys (first 8 chars only)
|
||||
for i, key in enumerate(key_list, 1):
|
||||
key_hint = f"{key[:8]}...{key[-4:]}" if len(key) >= 12 else "invalid"
|
||||
print(f" {i}. {key_hint}")
|
||||
|
||||
print()
|
||||
print("-" * 70)
|
||||
print()
|
||||
|
||||
# Generate new key
|
||||
new_key = generate_api_key(length=64)
|
||||
created_at = datetime.now(timezone.utc)
|
||||
expires_at = created_at + timedelta(days=90)
|
||||
|
||||
print("✓ New API key generated!")
|
||||
print()
|
||||
print(f"Created: {created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
||||
print(f"Expires: {expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')} (90 days)")
|
||||
print()
|
||||
print("-" * 70)
|
||||
print()
|
||||
|
||||
# Ask what to do with old keys
|
||||
print("Rotation Strategy:")
|
||||
print()
|
||||
print(" 1. Replace all keys with new key (recommended)")
|
||||
print(" 2. Add new key, keep old keys (grace period)")
|
||||
print(" 3. Cancel")
|
||||
print()
|
||||
|
||||
choice = input("Choose option [1/2/3]: ").strip()
|
||||
|
||||
if choice == "3":
|
||||
print("\nRotation cancelled.")
|
||||
sys.exit(0)
|
||||
elif choice == "2":
|
||||
# Add new key to list
|
||||
key_list.append(new_key)
|
||||
new_keys_str = ",".join(key_list)
|
||||
print("\n✓ New key will be added (total: {} keys)".format(len(key_list)))
|
||||
print("\n⚠️ IMPORTANT: Remove old keys manually after updating the client config")
|
||||
elif choice == "1":
|
||||
# Replace with only new key
|
||||
new_keys_str = new_key
|
||||
print("\n✓ All old keys will be replaced with new key")
|
||||
else:
|
||||
print("\n❌ Invalid choice. Operation cancelled.")
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print("-" * 70)
|
||||
print()
|
||||
|
||||
# Update .env file
|
||||
new_env_content = re.sub(r"MCP_API_KEYS=([^\n]+)", f"MCP_API_KEYS={new_keys_str}", env_content)
|
||||
|
||||
# Backup old .env
|
||||
backup_file = env_file.with_suffix(f".env.backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}")
|
||||
with open(backup_file, "w", encoding="utf-8") as f:
|
||||
f.write(env_content)
|
||||
|
||||
print(f"✓ Backed up old .env to: {backup_file.name}")
|
||||
|
||||
# Write new .env
|
||||
with open(env_file, "w", encoding="utf-8") as f:
|
||||
f.write(new_env_content)
|
||||
|
||||
print(f"✓ Updated .env file with new key(s)")
|
||||
print()
|
||||
print("-" * 70)
|
||||
print()
|
||||
print("📋 Next Steps:")
|
||||
print()
|
||||
print("1. Restart the MCP server:")
|
||||
print()
|
||||
print(" docker-compose restart aegis-mcp")
|
||||
print()
|
||||
print("2. Update your MCP client configuration:")
|
||||
print()
|
||||
print(" - Update the MCP server entry in your client")
|
||||
print(" - Update Authorization header:")
|
||||
print(f" Authorization: Bearer {new_key}")
|
||||
print()
|
||||
print("3. Test the connection:")
|
||||
print()
|
||||
print(" Ask the client: 'List my Gitea repositories'")
|
||||
print()
|
||||
print("4. If using grace period (option 2):")
|
||||
print()
|
||||
print(" - After confirming the client works with the new key")
|
||||
print(" - Manually remove old keys from .env")
|
||||
print(" - Restart server again")
|
||||
print()
|
||||
print("-" * 70)
|
||||
print()
|
||||
print("⚠️ IMPORTANT:")
|
||||
print()
|
||||
print(f" • New API Key: {new_key}")
|
||||
print(" • Store this securely - it won't be shown again")
|
||||
print(" • Set a reminder to rotate in 90 days")
|
||||
print(" • Old .env backed up to:", backup_file.name)
|
||||
print()
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nOperation cancelled.")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,208 +0,0 @@
|
||||
"""Tests for authentication module."""
|
||||
|
||||
import pytest
|
||||
|
||||
from aegis_gitea_mcp.auth import (
|
||||
APIKeyValidator,
|
||||
generate_api_key,
|
||||
get_validator,
|
||||
hash_api_key,
|
||||
reset_validator,
|
||||
)
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_auth():
|
||||
"""Reset authentication state between tests."""
|
||||
reset_validator()
|
||||
reset_settings()
|
||||
yield
|
||||
reset_validator()
|
||||
reset_settings()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_env_with_key(monkeypatch):
|
||||
"""Set up environment with valid API key."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-token-12345")
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64) # 64-char key
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def validator(mock_env_with_key):
|
||||
"""Create API key validator instance."""
|
||||
return APIKeyValidator()
|
||||
|
||||
|
||||
def test_generate_api_key():
|
||||
"""Test API key generation."""
|
||||
key = generate_api_key(length=64)
|
||||
|
||||
assert len(key) == 64
|
||||
assert all(c in "0123456789abcdef" for c in key)
|
||||
|
||||
|
||||
def test_generate_api_key_custom_length():
|
||||
"""Test API key generation with custom length."""
|
||||
key = generate_api_key(length=128)
|
||||
|
||||
assert len(key) == 128
|
||||
|
||||
|
||||
def test_hash_api_key():
|
||||
"""Test API key hashing."""
|
||||
key = "test-key-12345"
|
||||
hashed = hash_api_key(key)
|
||||
|
||||
assert len(hashed) == 64 # SHA256 produces 64-char hex string
|
||||
assert hashed == hash_api_key(key) # Deterministic
|
||||
|
||||
|
||||
def test_validator_singleton(mock_env_with_key):
|
||||
"""Test that get_validator returns same instance."""
|
||||
validator1 = get_validator()
|
||||
validator2 = get_validator()
|
||||
|
||||
assert validator1 is validator2
|
||||
|
||||
|
||||
def test_constant_time_compare(validator):
|
||||
"""Test constant-time string comparison."""
|
||||
# Same strings
|
||||
assert validator._constant_time_compare("test", "test")
|
||||
|
||||
# Different strings
|
||||
assert not validator._constant_time_compare("test", "fail")
|
||||
|
||||
# Different lengths
|
||||
assert not validator._constant_time_compare("test", "testing")
|
||||
|
||||
|
||||
def test_extract_bearer_token(validator):
|
||||
"""Test bearer token extraction from Authorization header."""
|
||||
# Valid bearer token
|
||||
token = validator.extract_bearer_token("Bearer abc123")
|
||||
assert token == "abc123"
|
||||
|
||||
# No header
|
||||
token = validator.extract_bearer_token(None)
|
||||
assert token is None
|
||||
|
||||
# Wrong format
|
||||
token = validator.extract_bearer_token("abc123")
|
||||
assert token is None
|
||||
|
||||
# Wrong scheme
|
||||
token = validator.extract_bearer_token("Basic abc123")
|
||||
assert token is None
|
||||
|
||||
# Too many parts
|
||||
token = validator.extract_bearer_token("Bearer abc 123")
|
||||
assert token is None
|
||||
|
||||
|
||||
def test_validate_api_key_missing(validator):
|
||||
"""Test validation with missing API key."""
|
||||
is_valid, error = validator.validate_api_key(None, "127.0.0.1", "test-agent")
|
||||
|
||||
assert not is_valid
|
||||
assert "Authorization header missing" in error
|
||||
|
||||
|
||||
def test_validate_api_key_too_short(validator):
|
||||
"""Test validation with too-short API key."""
|
||||
is_valid, error = validator.validate_api_key("short", "127.0.0.1", "test-agent")
|
||||
|
||||
assert not is_valid
|
||||
assert "Invalid API key format" in error
|
||||
|
||||
|
||||
def test_validate_api_key_invalid(validator, mock_env_with_key):
|
||||
"""Test validation with invalid API key."""
|
||||
is_valid, error = validator.validate_api_key("b" * 64, "127.0.0.1", "test-agent")
|
||||
|
||||
assert not is_valid
|
||||
assert "Invalid API key" in error
|
||||
|
||||
|
||||
def test_validate_api_key_valid(validator, mock_env_with_key):
|
||||
"""Test validation with valid API key."""
|
||||
is_valid, error = validator.validate_api_key("a" * 64, "127.0.0.1", "test-agent")
|
||||
|
||||
assert is_valid
|
||||
assert error is None
|
||||
|
||||
|
||||
def test_rate_limiting(validator):
|
||||
"""Test rate limiting after multiple failures."""
|
||||
client_ip = "192.168.1.1"
|
||||
|
||||
# First 5 attempts should be allowed
|
||||
for _ in range(5):
|
||||
is_valid, error = validator.validate_api_key("b" * 64, client_ip, "test-agent")
|
||||
assert not is_valid
|
||||
|
||||
# 6th attempt should be rate limited
|
||||
is_valid, error = validator.validate_api_key("b" * 64, client_ip, "test-agent")
|
||||
assert not is_valid
|
||||
assert "Too many failed" in error
|
||||
|
||||
|
||||
def test_rate_limiting_per_ip(validator):
|
||||
"""Test that rate limiting is per IP address."""
|
||||
ip1 = "192.168.1.1"
|
||||
ip2 = "192.168.1.2"
|
||||
|
||||
# Fail 5 times from IP1
|
||||
for _ in range(5):
|
||||
validator.validate_api_key("b" * 64, ip1, "test-agent")
|
||||
|
||||
# IP1 should be rate limited
|
||||
is_valid, error = validator.validate_api_key("b" * 64, ip1, "test-agent")
|
||||
assert "Too many failed" in error
|
||||
|
||||
# IP2 should still work
|
||||
is_valid, error = validator.validate_api_key("b" * 64, ip2, "test-agent")
|
||||
assert "Invalid API key" in error # Wrong key, but not rate limited
|
||||
|
||||
|
||||
def test_auth_disabled(monkeypatch):
|
||||
"""Test that authentication can be disabled."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-token-12345")
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "") # No keys needed when disabled
|
||||
|
||||
validator = APIKeyValidator()
|
||||
|
||||
# Should allow access without key
|
||||
is_valid, error = validator.validate_api_key(None, "127.0.0.1", "test-agent")
|
||||
assert is_valid
|
||||
assert error is None
|
||||
|
||||
|
||||
def test_multiple_keys(monkeypatch):
|
||||
"""Test validation with multiple API keys."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-token-12345")
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv("MCP_API_KEYS", f"{'a' * 64},{'b' * 64},{'c' * 64}")
|
||||
|
||||
validator = APIKeyValidator()
|
||||
|
||||
# All three keys should work
|
||||
is_valid, _ = validator.validate_api_key("a" * 64, "127.0.0.1", "test-agent")
|
||||
assert is_valid
|
||||
|
||||
is_valid, _ = validator.validate_api_key("b" * 64, "127.0.0.1", "test-agent")
|
||||
assert is_valid
|
||||
|
||||
is_valid, _ = validator.validate_api_key("c" * 64, "127.0.0.1", "test-agent")
|
||||
assert is_valid
|
||||
|
||||
# Invalid key should fail
|
||||
is_valid, _ = validator.validate_api_key("d" * 64, "127.0.0.1", "test-agent")
|
||||
assert not is_valid
|
||||
@@ -1,261 +0,0 @@
|
||||
"""Tests for resource-type-aware authorization (fail-closed)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from aegis_gitea_mcp import authz
|
||||
from aegis_gitea_mcp.authz import (
|
||||
ResourceClass,
|
||||
ResourceType,
|
||||
authorize_non_repository_access,
|
||||
classify_raw_endpoint,
|
||||
classify_tool,
|
||||
verify_org_membership,
|
||||
verify_site_admin,
|
||||
)
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.errors import ToolError
|
||||
from aegis_gitea_mcp.tools.arguments import normalize_raw_endpoint
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authz_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Service-PAT-mode settings used by the authorization layer."""
|
||||
reset_settings()
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "service-pat-token")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing-policy.yaml"))
|
||||
|
||||
|
||||
def _endpoint(path: str) -> str:
|
||||
return normalize_raw_endpoint(path)
|
||||
|
||||
|
||||
# --- Classification ---------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path", "rtype", "ident_field", "ident_value"),
|
||||
[
|
||||
("GET", "/repos/acme/app/pulls/1", ResourceType.REPOSITORY, "repository", "acme/app"),
|
||||
("GET", "/repos/issues/search", ResourceType.REPOSITORY, "repository", None),
|
||||
("GET", "/orgs/acme/repos", ResourceType.ORG, "org", "acme"),
|
||||
("GET", "/users/bob/repos", ResourceType.USER_OWNED, "owner", "bob"),
|
||||
("GET", "/packages/bob/pypi", ResourceType.USER_OWNED, "owner", "bob"),
|
||||
("GET", "/user/repos", ResourceType.USER_SELF, "repository", None),
|
||||
("GET", "/notifications", ResourceType.USER_SELF, "repository", None),
|
||||
("GET", "/markdown", ResourceType.MISC_GLOBAL, "repository", None),
|
||||
("GET", "/version", ResourceType.MISC_GLOBAL, "repository", None),
|
||||
("DELETE", "/admin/users/bob", ResourceType.ADMIN, "repository", None),
|
||||
],
|
||||
)
|
||||
def test_classify_raw_endpoint(
|
||||
method: str, path: str, rtype: ResourceType, ident_field: str, ident_value: str | None
|
||||
) -> None:
|
||||
result = classify_raw_endpoint(method, _endpoint(path))
|
||||
assert result.resource_type is rtype
|
||||
assert getattr(result, ident_field) == ident_value
|
||||
|
||||
|
||||
def test_classify_tool_maps_typed_tools() -> None:
|
||||
assert classify_tool("list_org_repositories", {"org": "acme"}).resource_type is ResourceType.ORG
|
||||
assert classify_tool("list_org_repositories", {"org": "acme"}).org == "acme"
|
||||
assert classify_tool("list_organizations", {}).resource_type is ResourceType.USER_SELF
|
||||
# An unrecognized non-repo tool is UNKNOWN (deny).
|
||||
assert classify_tool("something_new", {}).resource_type is ResourceType.UNKNOWN
|
||||
|
||||
|
||||
def test_classify_tool_gitea_request_uses_path() -> None:
|
||||
cls = classify_tool("gitea_request", {"method": "GET", "path": "/orgs/acme/repos"})
|
||||
assert cls.resource_type is ResourceType.ORG
|
||||
assert cls.org == "acme"
|
||||
|
||||
|
||||
def test_classify_tool_gitea_request_traversal_is_unknown() -> None:
|
||||
cls = classify_tool("gitea_request", {"method": "GET", "path": "/repos/../../admin"})
|
||||
assert cls.resource_type is ResourceType.UNKNOWN
|
||||
|
||||
|
||||
# --- Decision matrix (verification mocked) ----------------------------------
|
||||
|
||||
|
||||
async def test_org_member_allowed(authz_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=True))
|
||||
cls = ResourceClass(ResourceType.ORG, is_write=False, org="acme")
|
||||
await authorize_non_repository_access(
|
||||
classification=cls, user_login="alice", tool_name="gitea_request"
|
||||
)
|
||||
|
||||
|
||||
async def test_org_nonmember_denied(authz_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=False))
|
||||
cls = ResourceClass(ResourceType.ORG, is_write=False, org="acme")
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await authorize_non_repository_access(
|
||||
classification=cls, user_login="alice", tool_name="gitea_request"
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
async def test_user_owned_self_allowed(authz_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=False))
|
||||
cls = ResourceClass(ResourceType.USER_OWNED, is_write=False, owner="alice")
|
||||
await authorize_non_repository_access(
|
||||
classification=cls, user_login="alice", tool_name="gitea_request"
|
||||
)
|
||||
|
||||
|
||||
async def test_user_owned_member_org_allowed(
|
||||
authz_env: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=True))
|
||||
cls = ResourceClass(ResourceType.USER_OWNED, is_write=False, owner="acme")
|
||||
await authorize_non_repository_access(
|
||||
classification=cls, user_login="alice", tool_name="gitea_request"
|
||||
)
|
||||
|
||||
|
||||
async def test_user_owned_other_denied(authz_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=False))
|
||||
cls = ResourceClass(ResourceType.USER_OWNED, is_write=False, owner="bob")
|
||||
with pytest.raises(ToolError):
|
||||
await authorize_non_repository_access(
|
||||
classification=cls, user_login="alice", tool_name="gitea_request"
|
||||
)
|
||||
|
||||
|
||||
async def test_user_self_denied_in_service_pat_mode(authz_env: None) -> None:
|
||||
cls = ResourceClass(ResourceType.USER_SELF, is_write=False)
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await authorize_non_repository_access(
|
||||
classification=cls, user_login="alice", tool_name="gitea_request"
|
||||
)
|
||||
assert "token-owner-scoped" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
async def test_misc_global_read_allowed_write_denied(authz_env: None) -> None:
|
||||
read_cls = ResourceClass(ResourceType.MISC_GLOBAL, is_write=False)
|
||||
await authorize_non_repository_access(
|
||||
classification=read_cls, user_login="alice", tool_name="gitea_request"
|
||||
)
|
||||
write_cls = ResourceClass(ResourceType.MISC_GLOBAL, is_write=True)
|
||||
with pytest.raises(ToolError):
|
||||
await authorize_non_repository_access(
|
||||
classification=write_cls, user_login="alice", tool_name="gitea_request"
|
||||
)
|
||||
|
||||
|
||||
async def test_admin_denied_without_opt_in(authz_env: None) -> None:
|
||||
cls = ResourceClass(ResourceType.ADMIN, is_write=True)
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await authorize_non_repository_access(
|
||||
classification=cls, user_login="alice", tool_name="gitea_request"
|
||||
)
|
||||
assert "RAW_API_ALLOW_SENSITIVE" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
async def test_admin_allowed_only_for_site_admin_with_opt_in(
|
||||
authz_env: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("RAW_API_ALLOW_SENSITIVE", "true")
|
||||
reset_settings()
|
||||
|
||||
monkeypatch.setattr(authz, "verify_site_admin", AsyncMock(return_value=True))
|
||||
cls = ResourceClass(ResourceType.ADMIN, is_write=True)
|
||||
await authorize_non_repository_access(
|
||||
classification=cls, user_login="root", tool_name="gitea_request"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(authz, "verify_site_admin", AsyncMock(return_value=False))
|
||||
with pytest.raises(ToolError):
|
||||
await authorize_non_repository_access(
|
||||
classification=cls, user_login="alice", tool_name="gitea_request"
|
||||
)
|
||||
|
||||
|
||||
async def test_unknown_resource_denied(authz_env: None) -> None:
|
||||
cls = ResourceClass(ResourceType.UNKNOWN, is_write=False)
|
||||
with pytest.raises(ToolError):
|
||||
await authorize_non_repository_access(
|
||||
classification=cls, user_login="alice", tool_name="gitea_request"
|
||||
)
|
||||
|
||||
|
||||
async def test_repository_without_target_denied(authz_env: None) -> None:
|
||||
"""A repo-typed call that could not be scoped to owner/repo fails closed."""
|
||||
cls = ResourceClass(ResourceType.REPOSITORY, is_write=False, repository=None)
|
||||
with pytest.raises(ToolError):
|
||||
await authorize_non_repository_access(
|
||||
classification=cls, user_login="alice", tool_name="gitea_request"
|
||||
)
|
||||
|
||||
|
||||
# --- Gitea verification helpers (fail-closed) -------------------------------
|
||||
|
||||
|
||||
def _patch_service_response(status_code: int, json_value: object = None) -> Any:
|
||||
response = MagicMock()
|
||||
response.status_code = status_code
|
||||
response.json.return_value = json_value
|
||||
return response
|
||||
|
||||
|
||||
def _patched_client(response: object) -> Any:
|
||||
patcher = patch("aegis_gitea_mcp.authz.httpx.AsyncClient")
|
||||
mock_client_cls = patcher.start()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
return patcher
|
||||
|
||||
|
||||
async def test_verify_org_membership_204_true(authz_env: None) -> None:
|
||||
patcher = _patched_client(_patch_service_response(204))
|
||||
try:
|
||||
assert await verify_org_membership(org="acme", user_login="alice") is True
|
||||
finally:
|
||||
patcher.stop()
|
||||
|
||||
|
||||
async def test_verify_org_membership_404_false(authz_env: None) -> None:
|
||||
patcher = _patched_client(_patch_service_response(404))
|
||||
try:
|
||||
assert await verify_org_membership(org="acme", user_login="alice") is False
|
||||
finally:
|
||||
patcher.stop()
|
||||
|
||||
|
||||
async def test_verify_org_membership_unknown_user_false(authz_env: None) -> None:
|
||||
assert await verify_org_membership(org="acme", user_login="unknown") is False
|
||||
|
||||
|
||||
async def test_verify_site_admin_true_only_when_flag_set(authz_env: None) -> None:
|
||||
patcher = _patched_client(_patch_service_response(200, {"is_admin": True}))
|
||||
try:
|
||||
assert await verify_site_admin(user_login="root") is True
|
||||
finally:
|
||||
patcher.stop()
|
||||
|
||||
|
||||
async def test_verify_site_admin_false_when_flag_absent(authz_env: None) -> None:
|
||||
patcher = _patched_client(_patch_service_response(200, {"is_admin": False}))
|
||||
try:
|
||||
assert await verify_site_admin(user_login="alice") is False
|
||||
finally:
|
||||
patcher.stop()
|
||||
|
||||
|
||||
async def test_verify_site_admin_non_200_false(authz_env: None) -> None:
|
||||
patcher = _patched_client(_patch_service_response(403, {}))
|
||||
try:
|
||||
assert await verify_site_admin(user_login="alice") is False
|
||||
finally:
|
||||
patcher.stop()
|
||||
@@ -1,155 +0,0 @@
|
||||
"""Tests for automation endpoints and controls."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def allow_oauth(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Mock OAuth validation to return a deterministic authenticated principal."""
|
||||
|
||||
async def _validate(_self, token, _ip, _ua):
|
||||
if token == "a" * 64:
|
||||
return True, None, {"login": "automation-user", "scopes": ["read:repository"]}
|
||||
return False, "Invalid or expired OAuth token.", None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aegis_gitea_mcp.oauth.GiteaOAuthValidator.validate_oauth_token",
|
||||
_validate,
|
||||
)
|
||||
|
||||
|
||||
def _set_base_env(
|
||||
monkeypatch: pytest.MonkeyPatch, automation_enabled: bool, policy_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-token")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
|
||||
monkeypatch.setenv("AUTOMATION_ENABLED", "true" if automation_enabled else "false")
|
||||
monkeypatch.setenv("POLICY_FILE_PATH", str(policy_path))
|
||||
|
||||
|
||||
def _yaml_with_trailing_newline(content: str) -> str:
|
||||
return content.strip() + "\n"
|
||||
|
||||
|
||||
def test_automation_job_denied_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, allow_oauth: None
|
||||
) -> None:
|
||||
"""Automation endpoints should deny requests when automation mode is disabled."""
|
||||
policy_path = tmp_path / "policy.yaml"
|
||||
policy_path.write_text("defaults:\n read: allow\n write: deny\n", encoding="utf-8")
|
||||
_set_base_env(monkeypatch, automation_enabled=False, policy_path=policy_path)
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/automation/jobs/run",
|
||||
headers={"Authorization": f"Bearer {'a' * 64}"},
|
||||
json={"job_name": "dependency_hygiene_scan", "owner": "acme", "repo": "app"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "disabled" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_automation_job_executes_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, allow_oauth: None
|
||||
) -> None:
|
||||
"""Dependency scan job should execute when automation is enabled and policy allows it."""
|
||||
policy_path = tmp_path / "policy.yaml"
|
||||
policy_path.write_text(
|
||||
_yaml_with_trailing_newline("""
|
||||
defaults:
|
||||
read: allow
|
||||
write: deny
|
||||
tools:
|
||||
allow:
|
||||
- automation_dependency_hygiene_scan
|
||||
- automation_webhook_ingest
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_set_base_env(monkeypatch, automation_enabled=True, policy_path=policy_path)
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/automation/jobs/run",
|
||||
headers={"Authorization": f"Bearer {'a' * 64}"},
|
||||
json={"job_name": "dependency_hygiene_scan", "owner": "acme", "repo": "app"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["success"] is True
|
||||
assert payload["result"]["job"] == "dependency_hygiene_scan"
|
||||
|
||||
|
||||
def test_automation_webhook_policy_denied(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, allow_oauth: None
|
||||
) -> None:
|
||||
"""Webhook ingestion must respect policy deny rules."""
|
||||
policy_path = tmp_path / "policy.yaml"
|
||||
policy_path.write_text(
|
||||
_yaml_with_trailing_newline("""
|
||||
defaults:
|
||||
read: allow
|
||||
write: deny
|
||||
tools:
|
||||
deny:
|
||||
- automation_webhook_ingest
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_set_base_env(monkeypatch, automation_enabled=True, policy_path=policy_path)
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/automation/webhook",
|
||||
headers={"Authorization": f"Bearer {'a' * 64}"},
|
||||
json={"event_type": "scan.completed", "payload": {"status": "ok"}},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "policy denied" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_auto_issue_creation_denied_without_write_mode(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, allow_oauth: None
|
||||
) -> None:
|
||||
"""Auto issue creation job should be denied unless write mode is enabled."""
|
||||
policy_path = tmp_path / "policy.yaml"
|
||||
policy_path.write_text(
|
||||
_yaml_with_trailing_newline("""
|
||||
defaults:
|
||||
read: allow
|
||||
write: allow
|
||||
tools:
|
||||
allow:
|
||||
- automation_auto_issue_creation
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_set_base_env(monkeypatch, automation_enabled=True, policy_path=policy_path)
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/automation/jobs/run",
|
||||
headers={"Authorization": f"Bearer {'a' * 64}"},
|
||||
json={"job_name": "auto_issue_creation", "owner": "acme", "repo": "app"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "write mode is disabled" in response.json()["detail"].lower()
|
||||
@@ -1,140 +0,0 @@
|
||||
"""Unit tests for automation manager job paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from aegis_gitea_mcp.automation import AutomationError, AutomationManager
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.policy import reset_policy_engine
|
||||
|
||||
|
||||
class StubAutomationGiteaClient:
|
||||
"""Async context manager stub for automation jobs."""
|
||||
|
||||
def __init__(self, token: str, issues: list[dict] | None = None) -> None:
|
||||
self.token = token
|
||||
self._issues = issues or []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def list_issues(self, owner, repo, *, state, page, limit, labels=None):
|
||||
return self._issues
|
||||
|
||||
async def create_issue(self, owner, repo, *, title, body, labels=None, assignees=None):
|
||||
return {"number": 77, "title": title, "body": body}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_globals() -> None:
|
||||
"""Reset singleton state between tests."""
|
||||
reset_settings()
|
||||
reset_policy_engine()
|
||||
yield
|
||||
reset_settings()
|
||||
reset_policy_engine()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def automation_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
||||
"""Set environment for automation manager tests."""
|
||||
policy_path = tmp_path / "policy.yaml"
|
||||
policy_path.write_text(
|
||||
(
|
||||
"defaults:\n"
|
||||
" read: allow\n"
|
||||
" write: allow\n"
|
||||
"tools:\n"
|
||||
" allow:\n"
|
||||
" - automation_stale_issue_detection\n"
|
||||
" - automation_auto_issue_creation\n"
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "legacy-token")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("AUTOMATION_ENABLED", "true")
|
||||
monkeypatch.setenv("POLICY_FILE_PATH", str(policy_path))
|
||||
monkeypatch.setenv("WRITE_MODE", "true")
|
||||
monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/demo")
|
||||
monkeypatch.setenv("AUTOMATION_STALE_DAYS", "30")
|
||||
return policy_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_issue_detection_job_finds_old_issues(
|
||||
automation_env: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Stale issue detection returns issue numbers older than cutoff."""
|
||||
|
||||
issues = [
|
||||
{"number": 1, "updated_at": "2020-01-01T00:00:00Z"},
|
||||
{"number": 2, "updated_at": "2999-01-01T00:00:00Z"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aegis_gitea_mcp.automation.GiteaClient",
|
||||
lambda token: StubAutomationGiteaClient(token=token, issues=issues),
|
||||
)
|
||||
|
||||
manager = AutomationManager()
|
||||
result = await manager.run_job(
|
||||
job_name="stale_issue_detection",
|
||||
owner="acme",
|
||||
repo="demo",
|
||||
user_token="user-token",
|
||||
)
|
||||
|
||||
assert result["stale_issue_numbers"] == [1]
|
||||
assert result["stale_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_issue_creation_requires_token(
|
||||
automation_env: Path,
|
||||
) -> None:
|
||||
"""Auto-issue creation is denied when no user token is provided."""
|
||||
manager = AutomationManager()
|
||||
|
||||
with pytest.raises(AutomationError, match="missing authenticated user token"):
|
||||
await manager.run_job(
|
||||
job_name="auto_issue_creation",
|
||||
owner="acme",
|
||||
repo="demo",
|
||||
user_token=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_issue_creation_job_success(
|
||||
automation_env: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Auto-issue creation succeeds with write mode + scope + token."""
|
||||
monkeypatch.setattr(
|
||||
"aegis_gitea_mcp.automation.GiteaClient",
|
||||
lambda token: StubAutomationGiteaClient(token=token),
|
||||
)
|
||||
|
||||
manager = AutomationManager()
|
||||
result = await manager.run_job(
|
||||
job_name="auto_issue_creation",
|
||||
owner="acme",
|
||||
repo="demo",
|
||||
user_token="user-token",
|
||||
finding_title="Security finding",
|
||||
finding_body="Details",
|
||||
)
|
||||
|
||||
assert result["job"] == "auto_issue_creation"
|
||||
assert result["issue_number"] == 77
|
||||
@@ -1,72 +0,0 @@
|
||||
"""Tests for the bounded TTL cache utility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from aegis_gitea_mcp.cache import BoundedTTLCache
|
||||
|
||||
|
||||
def test_set_and_get_returns_value() -> None:
|
||||
"""A stored value is returned before it expires."""
|
||||
cache: BoundedTTLCache[str, int] = BoundedTTLCache(ttl_seconds=60, max_size=8)
|
||||
cache.set("a", 1)
|
||||
assert cache.get("a") == 1
|
||||
|
||||
|
||||
def test_missing_key_returns_none() -> None:
|
||||
"""An unknown key returns None."""
|
||||
cache: BoundedTTLCache[str, int] = BoundedTTLCache(ttl_seconds=60)
|
||||
assert cache.get("missing") is None
|
||||
|
||||
|
||||
def test_entry_expires_after_ttl() -> None:
|
||||
"""An entry is evicted once its TTL elapses."""
|
||||
cache: BoundedTTLCache[str, int] = BoundedTTLCache(ttl_seconds=0.05, max_size=8)
|
||||
cache.set("a", 1)
|
||||
assert cache.get("a") == 1
|
||||
time.sleep(0.06)
|
||||
assert cache.get("a") is None
|
||||
|
||||
|
||||
def test_size_bound_evicts_oldest() -> None:
|
||||
"""The cache never exceeds max_size; oldest entries are evicted first."""
|
||||
cache: BoundedTTLCache[int, int] = BoundedTTLCache(ttl_seconds=60, max_size=3)
|
||||
for i in range(5):
|
||||
cache.set(i, i)
|
||||
assert len(cache) == 3
|
||||
# 0 and 1 were evicted; 2, 3, 4 remain.
|
||||
assert cache.get(0) is None
|
||||
assert cache.get(1) is None
|
||||
assert cache.get(4) == 4
|
||||
|
||||
|
||||
def test_reinsert_refreshes_recency() -> None:
|
||||
"""Re-setting a key refreshes its position so it is not evicted first."""
|
||||
cache: BoundedTTLCache[str, int] = BoundedTTLCache(ttl_seconds=60, max_size=2)
|
||||
cache.set("a", 1)
|
||||
cache.set("b", 2)
|
||||
cache.set("a", 3) # refresh "a"
|
||||
cache.set("c", 4) # should evict "b", the oldest
|
||||
assert cache.get("b") is None
|
||||
assert cache.get("a") == 3
|
||||
assert cache.get("c") == 4
|
||||
|
||||
|
||||
def test_clear_empties_cache() -> None:
|
||||
"""clear() removes all entries."""
|
||||
cache: BoundedTTLCache[str, int] = BoundedTTLCache(ttl_seconds=60)
|
||||
cache.set("a", 1)
|
||||
cache.clear()
|
||||
assert cache.get("a") is None
|
||||
assert len(cache) == 0
|
||||
|
||||
|
||||
def test_invalid_constructor_args() -> None:
|
||||
"""Non-positive TTL or size is rejected."""
|
||||
with pytest.raises(ValueError):
|
||||
BoundedTTLCache(ttl_seconds=0)
|
||||
with pytest.raises(ValueError):
|
||||
BoundedTTLCache(ttl_seconds=60, max_size=0)
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Tests for the gitea_request read/write classifier and known-path gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.errors import ToolError
|
||||
from aegis_gitea_mcp.tools.arguments import (
|
||||
normalize_raw_endpoint,
|
||||
raw_is_known_api_path,
|
||||
raw_request_is_write,
|
||||
)
|
||||
from aegis_gitea_mcp.tools.raw_tools import raw_api_request_tool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def raw_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""API-key-mode settings with default policy (read allow, write deny)."""
|
||||
reset_settings()
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-token")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing-policy.yaml"))
|
||||
|
||||
|
||||
class StubRawGitea:
|
||||
"""Stub Gitea client capturing raw_request calls."""
|
||||
|
||||
def __init__(self, response: Any = None) -> None:
|
||||
self._response: Any = {"ok": True} if response is None else response
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
async def raw_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
self.calls.append({"method": method, "endpoint": endpoint})
|
||||
return self._response
|
||||
|
||||
|
||||
# --- Pure classifier --------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path", "expected_write"),
|
||||
[
|
||||
("GET", "/repos/o/r/issues", False),
|
||||
("HEAD", "/repos/o/r", False),
|
||||
("POST", "/repos/o/r/issues", True),
|
||||
("PUT", "/repos/o/r/pulls/1/merge", True),
|
||||
("PATCH", "/repos/o/r/issues/1", True),
|
||||
("DELETE", "/repos/o/r/issues/1", True),
|
||||
# Render-only overrides are reads even though they are POSTs.
|
||||
("POST", "/markdown", False),
|
||||
("POST", "/markdown/raw", False),
|
||||
("POST", "/repos/o/r/markup", False),
|
||||
],
|
||||
)
|
||||
def test_raw_request_is_write(method: str, path: str, expected_write: bool) -> None:
|
||||
endpoint = normalize_raw_endpoint(path)
|
||||
assert raw_request_is_write(method, endpoint) is expected_write
|
||||
|
||||
|
||||
def test_override_never_upgrades_a_mutating_post() -> None:
|
||||
"""A normal mutating POST is never reclassified as a read."""
|
||||
endpoint = normalize_raw_endpoint("/repos/o/r/issues")
|
||||
assert raw_request_is_write("POST", endpoint) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "known"),
|
||||
[
|
||||
("/repos/o/r", True),
|
||||
("/orgs/acme/repos", True),
|
||||
("/admin/users", True),
|
||||
("/user/repos", True),
|
||||
("/markdown", True),
|
||||
("/version", True),
|
||||
("/definitely/not/a/real/prefix", False),
|
||||
("/wibble", False),
|
||||
],
|
||||
)
|
||||
def test_raw_is_known_api_path(path: str, known: bool) -> None:
|
||||
assert raw_is_known_api_path(normalize_raw_endpoint(path)) is known
|
||||
|
||||
|
||||
# --- Handler: unknown path is denied before any network call ----------------
|
||||
|
||||
|
||||
async def test_unknown_prefix_denied_before_network(raw_env: None) -> None:
|
||||
stub = StubRawGitea()
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await raw_api_request_tool(stub, {"method": "GET", "path": "/wibble/wobble"})
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "known Gitea API route prefix" in str(exc_info.value.detail)
|
||||
assert stub.calls == []
|
||||
|
||||
|
||||
# --- Write-mode bypass: a write that "looks like a read" is still a write ----
|
||||
|
||||
|
||||
async def test_write_method_denied_with_write_mode_off_even_on_readish_path(
|
||||
raw_env: None,
|
||||
) -> None:
|
||||
"""A POST to a known repo path is a write and is denied while write-mode is off."""
|
||||
stub = StubRawGitea()
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await raw_api_request_tool(stub, {"method": "POST", "path": "/repos/acme/app/issues"})
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "write mode is disabled" in str(exc_info.value.detail)
|
||||
assert stub.calls == []
|
||||
|
||||
|
||||
async def test_render_only_post_allowed_as_read_without_write_mode(raw_env: None) -> None:
|
||||
"""A markdown-render POST is classified read and proceeds with write-mode off."""
|
||||
stub = StubRawGitea({"rendered": "<p>hi</p>"})
|
||||
result = await raw_api_request_tool(stub, {"method": "POST", "path": "/markdown"})
|
||||
assert result["write"] is False
|
||||
assert stub.calls and stub.calls[0]["endpoint"] == "/api/v1/markdown"
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Lock the transport-agnostic core boundary.
|
||||
|
||||
The core (tool registry, Gitea client, policy, audit, config, request context,
|
||||
tools) must import cleanly without dragging in the web stack. If a stray
|
||||
``import fastapi`` creeps back into a core module, the local stdio package would
|
||||
gain a needless heavy dependency and the ``[server]`` extra split would leak.
|
||||
|
||||
The check runs in a subprocess because, within the pytest process, FastAPI is
|
||||
already imported by the server tests — so ``'fastapi' in sys.modules`` would be
|
||||
true regardless. A clean interpreter is the only reliable probe.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_SRC = Path(__file__).resolve().parents[1] / "src"
|
||||
|
||||
# Core modules that must stay free of the web stack.
|
||||
_CORE_MODULES = [
|
||||
"aegis_gitea_mcp.registry",
|
||||
"aegis_gitea_mcp.gitea_client",
|
||||
"aegis_gitea_mcp.policy",
|
||||
"aegis_gitea_mcp.audit",
|
||||
"aegis_gitea_mcp.config",
|
||||
"aegis_gitea_mcp.request_context",
|
||||
"aegis_gitea_mcp.response_limits",
|
||||
"aegis_gitea_mcp.security",
|
||||
"aegis_gitea_mcp.cache",
|
||||
"aegis_gitea_mcp.logging_utils",
|
||||
"aegis_gitea_mcp.mcp_protocol",
|
||||
"aegis_gitea_mcp.errors",
|
||||
"aegis_gitea_mcp.tools.raw_tools",
|
||||
"aegis_gitea_mcp.tools.read_tools",
|
||||
"aegis_gitea_mcp.tools.write_tools",
|
||||
"aegis_gitea_mcp.tools.repository",
|
||||
"aegis_gitea_mcp.tools.arguments",
|
||||
]
|
||||
|
||||
|
||||
def test_core_does_not_import_fastapi() -> None:
|
||||
"""Importing the core in a clean interpreter must not import FastAPI."""
|
||||
imports = "\n".join(f"import {module}" for module in _CORE_MODULES)
|
||||
program = (
|
||||
f"import sys\n{imports}\n"
|
||||
"leaked = [m for m in ('fastapi', 'uvicorn', 'starlette') if m in sys.modules]\n"
|
||||
"assert not leaked, f'core leaked web stack: {leaked}'\n"
|
||||
"print('ok')\n"
|
||||
)
|
||||
env = dict(os.environ)
|
||||
env["PYTHONPATH"] = str(_SRC)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", program],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
detail = f"core import boundary violated.\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
||||
assert result.returncode == 0, detail
|
||||
assert "ok" in result.stdout
|
||||
@@ -1,413 +0,0 @@
|
||||
"""Unit tests for Gitea client request behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import Client, Request, Response
|
||||
from pydantic import ValidationError
|
||||
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.gitea_client import (
|
||||
GiteaAuthenticationError,
|
||||
GiteaAuthorizationError,
|
||||
GiteaClient,
|
||||
GiteaError,
|
||||
GiteaNotFoundError,
|
||||
)
|
||||
from aegis_gitea_mcp.tools.arguments import (
|
||||
CommitDiffArgs,
|
||||
CompareRefsArgs,
|
||||
FileTreeArgs,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def gitea_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Provide minimal environment for client initialization."""
|
||||
reset_settings()
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "legacy-token")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
yield
|
||||
reset_settings()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_context_uses_bearer_header() -> None:
|
||||
"""HTTP client is created with bearer token and closed on exit."""
|
||||
with patch("aegis_gitea_mcp.gitea_client.AsyncClient") as mock_async_client:
|
||||
mock_instance = AsyncMock()
|
||||
mock_async_client.return_value = mock_instance
|
||||
|
||||
async with GiteaClient(token="user-oauth-token"):
|
||||
pass
|
||||
|
||||
_, kwargs = mock_async_client.call_args
|
||||
assert kwargs["headers"]["Authorization"] == "Bearer user-oauth-token"
|
||||
mock_instance.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
def test_client_requires_non_empty_token() -> None:
|
||||
"""Client construction fails when token is missing."""
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
GiteaClient(token=" ")
|
||||
|
||||
|
||||
def test_handle_response_maps_error_codes() -> None:
|
||||
"""HTTP status codes map to explicit domain exceptions."""
|
||||
client = GiteaClient(token="user-token")
|
||||
request = Request("GET", "https://gitea.example.com/api/v1/user")
|
||||
|
||||
with pytest.raises(GiteaAuthenticationError):
|
||||
client._handle_response(Response(401, request=request), correlation_id="c1")
|
||||
|
||||
with pytest.raises(GiteaAuthorizationError):
|
||||
client._handle_response(Response(403, request=request), correlation_id="c2")
|
||||
|
||||
with pytest.raises(GiteaNotFoundError):
|
||||
client._handle_response(Response(404, request=request), correlation_id="c3")
|
||||
|
||||
with pytest.raises(GiteaError, match="boom"):
|
||||
client._handle_response(
|
||||
Response(500, request=request, json={"message": "boom"}),
|
||||
correlation_id="c4",
|
||||
)
|
||||
|
||||
assert client._handle_response(Response(200, request=request, json={"ok": True}), "c5") == {
|
||||
"ok": True
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_methods_delegate_to_request_and_normalize() -> None:
|
||||
"""Wrapper methods call shared request logic and normalize return types."""
|
||||
client = GiteaClient(token="user-token")
|
||||
|
||||
async def fake_request(method: str, endpoint: str, **kwargs):
|
||||
if endpoint == "/api/v1/user":
|
||||
return {"login": "alice"}
|
||||
if endpoint == "/api/v1/user/repos":
|
||||
return [{"name": "repo"}]
|
||||
if endpoint == "/api/v1/repos/acme/demo":
|
||||
return {"name": "demo"}
|
||||
if endpoint == "/api/v1/repos/acme/demo/contents/README.md":
|
||||
return {"size": 8, "content": "aGVsbG8=", "encoding": "base64"}
|
||||
if endpoint == "/api/v1/repos/acme/demo/git/trees/main":
|
||||
return {"tree": [{"path": "README.md"}]}
|
||||
if endpoint == "/api/v1/repos/acme/demo/search":
|
||||
return {"hits": []}
|
||||
if endpoint == "/api/v1/repos/acme/demo/commits":
|
||||
return [{"sha": "abc"}]
|
||||
if endpoint == "/api/v1/repos/acme/demo/git/commits/abc":
|
||||
return {"sha": "abc"}
|
||||
if endpoint == "/api/v1/repos/acme/demo/compare/main...feature":
|
||||
return {"total_commits": 1}
|
||||
if endpoint == "/api/v1/repos/acme/demo/issues":
|
||||
if method == "GET":
|
||||
return [{"number": 1}]
|
||||
return {"number": 12}
|
||||
if endpoint == "/api/v1/repos/acme/demo/issues/1":
|
||||
if method == "GET":
|
||||
return {"number": 1}
|
||||
return {"number": 1, "state": "closed"}
|
||||
if endpoint == "/api/v1/repos/acme/demo/pulls":
|
||||
return [{"number": 2}]
|
||||
if endpoint == "/api/v1/repos/acme/demo/pulls/2":
|
||||
return {"number": 2}
|
||||
if endpoint == "/api/v1/repos/acme/demo/labels":
|
||||
return [{"id": 1, "name": "bug"}]
|
||||
if endpoint == "/api/v1/repos/acme/demo/tags":
|
||||
return [{"name": "v1"}]
|
||||
if endpoint == "/api/v1/repos/acme/demo/releases":
|
||||
return [{"id": 1}]
|
||||
if endpoint == "/api/v1/repos/acme/demo/issues/1/comments":
|
||||
return {"id": 9}
|
||||
if endpoint == "/api/v1/repos/acme/demo/issues/1/labels":
|
||||
return {"labels": [{"name": "bug"}]}
|
||||
if endpoint == "/api/v1/repos/acme/demo/issues/1/assignees":
|
||||
return {"assignees": [{"login": "alice"}]}
|
||||
return {}
|
||||
|
||||
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
|
||||
|
||||
assert (await client.get_current_user())["login"] == "alice"
|
||||
assert len(await client.list_repositories()) == 1
|
||||
assert (await client.get_repository("acme", "demo"))["name"] == "demo"
|
||||
assert (await client.get_file_contents("acme", "demo", "README.md"))["size"] == 8
|
||||
assert len((await client.get_tree("acme", "demo"))["tree"]) == 1
|
||||
assert isinstance(
|
||||
await client.search_code("acme", "demo", "needle", ref="main", page=1, limit=5), dict
|
||||
)
|
||||
assert len(await client.list_commits("acme", "demo", ref="main", page=1, limit=5)) == 1
|
||||
assert (await client.get_commit_diff("acme", "demo", "abc"))["sha"] == "abc"
|
||||
assert isinstance(await client.compare_refs("acme", "demo", "main", "feature"), dict)
|
||||
assert len(await client.list_issues("acme", "demo", state="open", page=1, limit=10)) == 1
|
||||
assert (await client.get_issue("acme", "demo", 1))["number"] == 1
|
||||
assert len(await client.list_pull_requests("acme", "demo", state="open", page=1, limit=10)) == 1
|
||||
assert (await client.get_pull_request("acme", "demo", 2))["number"] == 2
|
||||
assert len(await client.list_labels("acme", "demo", page=1, limit=10)) == 1
|
||||
assert len(await client.list_tags("acme", "demo", page=1, limit=10)) == 1
|
||||
assert len(await client.list_releases("acme", "demo", page=1, limit=10)) == 1
|
||||
assert (await client.create_issue("acme", "demo", title="Hi", body="Body"))["number"] == 12
|
||||
assert (await client.update_issue("acme", "demo", 1, state="closed"))["state"] == "closed"
|
||||
assert (await client.create_issue_comment("acme", "demo", 1, "comment"))["id"] == 9
|
||||
assert (await client.create_pr_comment("acme", "demo", 1, "comment"))["id"] == 9
|
||||
assert isinstance(await client.add_labels("acme", "demo", 1, ["bug"]), dict)
|
||||
assert isinstance(await client.assign_issue("acme", "demo", 1, ["alice"]), dict)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_contents_blocks_oversized_payload(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""File size limits are enforced before returning content."""
|
||||
monkeypatch.setenv("MAX_FILE_SIZE_BYTES", "5")
|
||||
reset_settings()
|
||||
client = GiteaClient(token="user-token")
|
||||
|
||||
client._request = AsyncMock( # type: ignore[method-assign]
|
||||
return_value={"size": 50, "content": "x", "encoding": "base64"}
|
||||
)
|
||||
|
||||
with pytest.raises(GiteaError, match="exceeds limit"):
|
||||
await client.get_file_contents("acme", "demo", "big.bin")
|
||||
|
||||
|
||||
_MALICIOUS_REFS = [
|
||||
"../../../x/y",
|
||||
"..",
|
||||
"/etc/passwd",
|
||||
"a\x00b",
|
||||
"a?b",
|
||||
"a#b",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", _MALICIOUS_REFS)
|
||||
def test_file_tree_args_reject_traversal_ref(value: str) -> None:
|
||||
"""Layer 1: FileTreeArgs.ref rejects traversal/unsafe values."""
|
||||
with pytest.raises(ValidationError):
|
||||
FileTreeArgs(owner="o", repo="r", ref=value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", _MALICIOUS_REFS)
|
||||
def test_commit_diff_args_reject_traversal_sha(value: str) -> None:
|
||||
"""Layer 1: CommitDiffArgs.sha rejects traversal/unsafe values."""
|
||||
# ".." is shorter than the 7-char min_length; still rejected (length or ref check).
|
||||
with pytest.raises(ValidationError):
|
||||
CommitDiffArgs(owner="o", repo="r", sha=value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", _MALICIOUS_REFS)
|
||||
def test_compare_refs_args_reject_traversal_base(value: str) -> None:
|
||||
"""Layer 1: CompareRefsArgs.base rejects traversal/unsafe values."""
|
||||
with pytest.raises(ValidationError):
|
||||
CompareRefsArgs(owner="o", repo="r", base=value, head="main")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", _MALICIOUS_REFS)
|
||||
def test_compare_refs_args_reject_traversal_head(value: str) -> None:
|
||||
"""Layer 1: CompareRefsArgs.head rejects traversal/unsafe values."""
|
||||
with pytest.raises(ValidationError):
|
||||
CompareRefsArgs(owner="o", repo="r", base="main", head=value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_user_repositories_scopes_by_uid() -> None:
|
||||
"""User-scoped listing resolves the uid and filters repo search by it."""
|
||||
client = GiteaClient(token="service-pat")
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_request(method: str, endpoint: str, **kwargs):
|
||||
if endpoint == "/api/v1/users/alice":
|
||||
return {"id": 7, "login": "alice"}
|
||||
if endpoint == "/api/v1/repos/search":
|
||||
captured["params"] = kwargs.get("params")
|
||||
return {"ok": True, "data": [{"full_name": "alice/demo"}, "not-a-dict"]}
|
||||
return {}
|
||||
|
||||
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
|
||||
|
||||
repos = await client.list_user_repositories("alice")
|
||||
assert captured["params"]["uid"] == 7
|
||||
assert repos == [{"full_name": "alice/demo"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_user_repositories_unknown_user_returns_empty() -> None:
|
||||
"""A user that cannot be resolved yields an empty list, not an error."""
|
||||
client = GiteaClient(token="service-pat")
|
||||
|
||||
async def fake_request(method: str, endpoint: str, **kwargs):
|
||||
return {} # no id field
|
||||
|
||||
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
|
||||
assert await client.list_user_repositories("ghost") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_label_ids_maps_names_case_insensitively() -> None:
|
||||
"""Label names are resolved to ids regardless of case."""
|
||||
client = GiteaClient(token="user-token")
|
||||
|
||||
async def fake_request(method: str, endpoint: str, **kwargs):
|
||||
return [{"id": 3, "name": "Bug"}, {"id": 9, "name": "wontfix"}]
|
||||
|
||||
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
|
||||
ids = await client._resolve_label_ids("o", "r", ["bug", "WONTFIX"], correlation_id="c")
|
||||
assert ids == [3, 9]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_label_ids_rejects_unknown_label() -> None:
|
||||
"""An unknown label name raises a clear error instead of a silent failure."""
|
||||
client = GiteaClient(token="user-token")
|
||||
|
||||
async def fake_request(method: str, endpoint: str, **kwargs):
|
||||
return [{"id": 3, "name": "bug"}]
|
||||
|
||||
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
|
||||
with pytest.raises(GiteaError, match="Unknown label"):
|
||||
await client._resolve_label_ids("o", "r", ["ghost"], correlation_id="c")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_milestone_id_passes_through_integer() -> None:
|
||||
"""An integer milestone reference is used as a Gitea milestone id as-is."""
|
||||
client = GiteaClient(token="user-token")
|
||||
client._request = AsyncMock() # type: ignore[method-assign]
|
||||
assert await client._resolve_milestone_id("o", "r", 7, correlation_id="c") == 7
|
||||
# Integer ids must not trigger a milestone lookup.
|
||||
client._request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_milestone_id_maps_title_case_insensitively() -> None:
|
||||
"""A milestone title is resolved to its id regardless of case."""
|
||||
client = GiteaClient(token="user-token")
|
||||
|
||||
async def fake_request(method: str, endpoint: str, **kwargs):
|
||||
return [{"id": 11, "title": "Sprint 1"}, {"id": 12, "title": "Backlog"}]
|
||||
|
||||
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
|
||||
resolved = await client._resolve_milestone_id("o", "r", "sprint 1", correlation_id="c")
|
||||
assert resolved == 11
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_milestone_id_rejects_unknown_title() -> None:
|
||||
"""An unknown milestone title raises a clear error."""
|
||||
client = GiteaClient(token="user-token")
|
||||
|
||||
async def fake_request(method: str, endpoint: str, **kwargs):
|
||||
return [{"id": 11, "title": "Sprint 1"}]
|
||||
|
||||
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
|
||||
with pytest.raises(GiteaError, match="Unknown milestone"):
|
||||
await client._resolve_milestone_id("o", "r", "Sprint 2", correlation_id="c")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_issue_resolves_milestone_title() -> None:
|
||||
"""create_issue resolves a milestone title to an id in the POST payload."""
|
||||
client = GiteaClient(token="user-token")
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_request(method: str, endpoint: str, **kwargs):
|
||||
if endpoint.endswith("/milestones") and method == "GET":
|
||||
return [{"id": 11, "title": "Sprint 1"}]
|
||||
if endpoint.endswith("/issues") and method == "POST":
|
||||
captured["payload"] = kwargs.get("json_body")
|
||||
return {"number": 1, "title": "Issue", "state": "open"}
|
||||
return {}
|
||||
|
||||
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
|
||||
await client.create_issue("o", "r", title="Issue", body="", milestone="Sprint 1")
|
||||
assert captured["payload"]["milestone"] == 11
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_issue_clears_milestone_with_zero() -> None:
|
||||
"""update_issue forwards milestone id 0 verbatim to clear the milestone."""
|
||||
client = GiteaClient(token="user-token")
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_request(method: str, endpoint: str, **kwargs):
|
||||
captured["payload"] = kwargs.get("json_body")
|
||||
return {"number": 1, "title": "Issue", "state": "open"}
|
||||
|
||||
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
|
||||
await client.update_issue("o", "r", 1, milestone=0)
|
||||
assert captured["payload"]["milestone"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_labels_resolves_names_to_ids() -> None:
|
||||
"""add_labels translates names to ids before POSTing to Gitea."""
|
||||
client = GiteaClient(token="user-token")
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_request(method: str, endpoint: str, **kwargs):
|
||||
if endpoint.endswith("/labels") and method == "GET":
|
||||
return [{"id": 42, "name": "bug"}]
|
||||
if endpoint.endswith("/issues/1/labels") and method == "POST":
|
||||
captured["body"] = kwargs.get("json_body")
|
||||
return {"labels": [{"name": "bug"}]}
|
||||
return {}
|
||||
|
||||
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
|
||||
await client.add_labels("o", "r", 1, ["bug"])
|
||||
assert captured["body"] == {"labels": [42]}
|
||||
|
||||
|
||||
def test_git_refs_allow_slash_containing_refs() -> None:
|
||||
"""Legitimate refs that contain '/' validate successfully."""
|
||||
tree = FileTreeArgs(owner="o", repo="r", ref="feature/foo")
|
||||
assert tree.ref == "feature/foo"
|
||||
|
||||
compare = CompareRefsArgs(owner="o", repo="r", base="release/1.0", head="main")
|
||||
assert compare.base == "release/1.0"
|
||||
assert compare.head == "main"
|
||||
|
||||
|
||||
def test_git_ref_length_bounds_still_enforced() -> None:
|
||||
"""Field min/max length still applies alongside the AfterValidator."""
|
||||
with pytest.raises(ValidationError):
|
||||
FileTreeArgs(owner="o", repo="r", ref="")
|
||||
with pytest.raises(ValidationError):
|
||||
FileTreeArgs(owner="o", repo="r", ref="a" * 201)
|
||||
with pytest.raises(ValidationError):
|
||||
# sha below 7-char minimum
|
||||
CommitDiffArgs(owner="o", repo="r", sha="abc")
|
||||
|
||||
|
||||
def test_layer2_encoding_confines_unsafe_chars_to_path_segment() -> None:
|
||||
"""Layer 2 defense-in-depth: quote(..., safe='/') confines unsafe chars.
|
||||
|
||||
A sha containing ``?``, ``#``, whitespace, or a backslash that hypothetically
|
||||
bypassed Layer 1 is percent-encoded so it cannot split off a query string,
|
||||
fragment, or extra path component -- it stays inside the single ref segment
|
||||
under the declared ``owner/repo``. (Note: ``..`` collapse is closed by Layer 1
|
||||
validation, since ``quote`` intentionally leaves ``.`` and ``/`` literal to
|
||||
preserve refs like ``feature/foo``.)
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
owner, repo = "alice", "repoA"
|
||||
prefix = f"/api/v1/repos/{owner}/{repo}/git/commits/"
|
||||
|
||||
for malicious_sha in ["abc?injected=1", "abc#frag", "abc def", "abc\\..\\evil"]:
|
||||
endpoint = (
|
||||
f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}"
|
||||
f"/git/commits/{quote(malicious_sha, safe='/')}"
|
||||
)
|
||||
with Client(base_url="https://gitea.example.com") as http_client:
|
||||
request = http_client.build_request("GET", endpoint)
|
||||
|
||||
# Stays scoped to declared repo, no query/fragment broke out.
|
||||
assert request.url.path.startswith(prefix)
|
||||
assert request.url.query == b""
|
||||
assert request.url.fragment == ""
|
||||
# Exactly one ref segment after the prefix (no path splitting).
|
||||
assert "/" not in request.url.path[len(prefix) :]
|
||||
@@ -1,121 +0,0 @@
|
||||
"""Integration tests for end-to-end MCP authentication behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.oauth import reset_oauth_validator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_state() -> None:
|
||||
"""Reset global state between tests."""
|
||||
reset_settings()
|
||||
reset_oauth_validator()
|
||||
yield
|
||||
reset_settings()
|
||||
reset_oauth_validator()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def full_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Set OAuth-enabled environment for integration tests."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("OAUTH_MODE", "true")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
|
||||
monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef")
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("MCP_HOST", "127.0.0.1")
|
||||
monkeypatch.setenv("MCP_PORT", "8080")
|
||||
monkeypatch.setenv("LOG_LEVEL", "INFO")
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(full_env: None, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
"""Create test client with deterministic OAuth behavior."""
|
||||
|
||||
async def _validate(_self, token: str | None, _ip: str, _ua: str):
|
||||
if token == "valid-read-token":
|
||||
return True, None, {"login": "alice", "scopes": ["read:repository"]}
|
||||
return False, "Invalid or expired OAuth token.", None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aegis_gitea_mcp.oauth.GiteaOAuthValidator.validate_oauth_token",
|
||||
_validate,
|
||||
)
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_no_token_returns_401_with_www_authenticate(client: TestClient) -> None:
|
||||
"""Missing bearer token is rejected with OAuth challenge metadata."""
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "WWW-Authenticate" in response.headers
|
||||
assert "resource_metadata=" in response.headers["WWW-Authenticate"]
|
||||
|
||||
|
||||
def test_invalid_token_returns_401(client: TestClient) -> None:
|
||||
"""Invalid OAuth token is rejected."""
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer invalid-token"},
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_valid_token_executes_tool(client: TestClient) -> None:
|
||||
"""Valid OAuth token allows tool execution."""
|
||||
with patch("aegis_gitea_mcp.gitea_client.GiteaClient.list_repositories") as mock_list_repos:
|
||||
mock_list_repos.return_value = [{"id": 1, "name": "repo-one", "owner": {"login": "alice"}}]
|
||||
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-read-token"},
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["success"] is True
|
||||
assert "result" in payload
|
||||
|
||||
|
||||
def test_write_scope_enforcement_returns_403(client: TestClient) -> None:
|
||||
"""Write tool calls are denied when token lacks write scope."""
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-read-token"},
|
||||
json={
|
||||
"tool": "create_issue",
|
||||
"arguments": {"owner": "acme", "repo": "demo", "title": "Needs write scope"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "required scope: write:repository" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_error_responses_include_helpful_messages(client: TestClient) -> None:
|
||||
"""Auth failures include actionable guidance."""
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert "Provide Authorization" in data["message"]
|
||||
@@ -1,640 +0,0 @@
|
||||
"""Tests for OAuth2 per-user Gitea authentication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import stat
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.oauth import GiteaOAuthValidator, get_oauth_validator, reset_oauth_validator
|
||||
from aegis_gitea_mcp.oauth_flow import OAuthClientRegistry, OAuthRegistrationRequest
|
||||
from aegis_gitea_mcp.request_context import (
|
||||
get_gitea_user_login,
|
||||
get_gitea_user_token,
|
||||
set_gitea_user_login,
|
||||
set_gitea_user_token,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_state():
|
||||
"""Reset global state between tests."""
|
||||
reset_settings()
|
||||
reset_oauth_validator()
|
||||
yield
|
||||
reset_settings()
|
||||
reset_oauth_validator()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_env_oauth(monkeypatch):
|
||||
"""Environment for OAuth mode tests."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("OAUTH_MODE", "true")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
|
||||
monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef")
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oauth_validator(mock_env_oauth):
|
||||
"""Create GiteaOAuthValidator instance in OAuth mode."""
|
||||
return GiteaOAuthValidator()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oauth_client(mock_env_oauth):
|
||||
"""Create FastAPI test client in OAuth mode."""
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def _register_public_client(oauth_client: TestClient, redirect_uri: str) -> dict[str, str]:
|
||||
"""Register a public OAuth client for test flows."""
|
||||
response = oauth_client.post(
|
||||
"/register",
|
||||
json={
|
||||
"client_name": "pytest-client",
|
||||
"redirect_uris": [redirect_uri],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert "client_id" in payload
|
||||
return payload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GiteaOAuthValidator unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_oauth_token_success(oauth_validator):
|
||||
"""Valid Gitea OAuth token returns is_valid=True and user_data."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"login": "testuser", "id": 42}
|
||||
|
||||
with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
is_valid, error, user_data = await oauth_validator.validate_oauth_token(
|
||||
"valid-gitea-token", "127.0.0.1", "TestAgent/1.0"
|
||||
)
|
||||
|
||||
assert is_valid is True
|
||||
assert error is None
|
||||
assert user_data is not None
|
||||
assert user_data["login"] == "testuser"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_oauth_token_invalid_401(oauth_validator):
|
||||
"""Gitea returning 401 results in is_valid=False."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
is_valid, error, user_data = await oauth_validator.validate_oauth_token(
|
||||
"expired-token", "127.0.0.1", "TestAgent/1.0"
|
||||
)
|
||||
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert user_data is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_oauth_token_missing_token(oauth_validator):
|
||||
"""Missing token results in is_valid=False."""
|
||||
is_valid, error, user_data = await oauth_validator.validate_oauth_token(
|
||||
None, "127.0.0.1", "TestAgent/1.0"
|
||||
)
|
||||
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert user_data is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_oauth_token_network_error(oauth_validator):
|
||||
"""Network error results in is_valid=False with informative message."""
|
||||
import httpx
|
||||
|
||||
with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(
|
||||
side_effect=httpx.RequestError("Connection refused", request=MagicMock())
|
||||
)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
is_valid, error, user_data = await oauth_validator.validate_oauth_token(
|
||||
"some-token", "127.0.0.1", "TestAgent/1.0"
|
||||
)
|
||||
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert "unable to validate oauth token" in error.lower()
|
||||
assert user_data is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_oauth_token_rate_limit(oauth_validator):
|
||||
"""Exceeding failure threshold triggers rate limiting."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
# Exhaust failures (default MAX_AUTH_FAILURES=5)
|
||||
for _ in range(5):
|
||||
await oauth_validator.validate_oauth_token("bad-token", "10.0.0.1", "Agent")
|
||||
|
||||
# Next attempt should be rate-limited
|
||||
is_valid, error, user_data = await oauth_validator.validate_oauth_token(
|
||||
"bad-token", "10.0.0.1", "Agent"
|
||||
)
|
||||
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert "too many" in error.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_oauth_validator_singleton(mock_env_oauth):
|
||||
"""get_oauth_validator returns the same instance on repeated calls."""
|
||||
v1 = get_oauth_validator()
|
||||
v2 = get_oauth_validator()
|
||||
assert v1 is v2
|
||||
|
||||
|
||||
def test_reset_oauth_validator(mock_env_oauth):
|
||||
"""reset_oauth_validator creates a fresh instance after reset."""
|
||||
v1 = get_oauth_validator()
|
||||
reset_oauth_validator()
|
||||
v2 = get_oauth_validator()
|
||||
assert v1 is not v2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ContextVar isolation tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_context_var_token_isolation():
|
||||
"""ContextVar values do not leak between coroutines."""
|
||||
results = {}
|
||||
|
||||
async def task_a():
|
||||
set_gitea_user_token("token-for-a")
|
||||
await asyncio.sleep(0)
|
||||
results["a"] = get_gitea_user_token()
|
||||
|
||||
async def task_b():
|
||||
# task_b never sets the token; should see None (default)
|
||||
await asyncio.sleep(0)
|
||||
results["b"] = get_gitea_user_token()
|
||||
|
||||
async def run():
|
||||
await asyncio.gather(task_a(), task_b())
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
assert results["a"] == "token-for-a"
|
||||
assert results["b"] is None # ContextVar isolation: task_b sees default
|
||||
|
||||
|
||||
def test_context_var_login_set_and_get():
|
||||
"""set_gitea_user_login / get_gitea_user_login work correctly."""
|
||||
set_gitea_user_login("alice")
|
||||
assert get_gitea_user_login() == "alice"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /oauth/token proxy endpoint tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_oauth_token_endpoint_available_when_oauth_mode_false(monkeypatch):
|
||||
"""POST /oauth/token remains available regardless of OAUTH_MODE."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-token-12345")
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("OAUTH_MODE", "false")
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"access_token": "token"}
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
registration = client.post(
|
||||
"/register",
|
||||
json={
|
||||
"client_name": "pytest-client",
|
||||
"redirect_uris": ["http://127.0.0.1:8080/callback"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"grant_types": ["authorization_code"],
|
||||
"response_types": ["code"],
|
||||
},
|
||||
)
|
||||
assert registration.status_code == 200
|
||||
client_id = registration.json()["client_id"]
|
||||
response = client.post(
|
||||
"/oauth/token",
|
||||
data={"client_id": client_id, "code": "abc123", "code_verifier": "pkce"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_oauth_token_endpoint_missing_code(oauth_client):
|
||||
"""POST /oauth/token without a code returns 400."""
|
||||
client_data = _register_public_client(oauth_client, "http://127.0.0.1:8080/callback")
|
||||
response = oauth_client.post(
|
||||
"/oauth/token",
|
||||
data={"client_id": client_data["client_id"], "code_verifier": "pkce"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_oauth_token_endpoint_proxy_success(oauth_client):
|
||||
"""POST /oauth/token proxies successfully to Gitea and returns access_token."""
|
||||
client_data = _register_public_client(oauth_client, "http://127.0.0.1:8080/callback")
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"access_token": "gitea-access-token-xyz",
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
response = oauth_client.post(
|
||||
"/oauth/token",
|
||||
data={
|
||||
"client_id": client_data["client_id"],
|
||||
"code": "auth-code-123",
|
||||
"code_verifier": "pkce-verifier",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["access_token"] == "gitea-access-token-xyz"
|
||||
|
||||
|
||||
def test_oauth_token_endpoint_gitea_error(oauth_client):
|
||||
"""POST /oauth/token propagates Gitea error status."""
|
||||
client_data = _register_public_client(oauth_client, "http://127.0.0.1:8080/callback")
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 400
|
||||
mock_response.json.return_value = {"error": "invalid_grant"}
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
response = oauth_client.post(
|
||||
"/oauth/token",
|
||||
data={
|
||||
"client_id": client_data["client_id"],
|
||||
"code": "bad-code",
|
||||
"code_verifier": "pkce-verifier",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_oauth_authorize_and_callback_round_trip(oauth_client):
|
||||
"""OAuth authorize/callback round-trip preserves the original redirect URI and state."""
|
||||
client_data = _register_public_client(oauth_client, "http://127.0.0.1:8080/callback")
|
||||
|
||||
authorize_response = oauth_client.get(
|
||||
"/oauth/authorize",
|
||||
params={
|
||||
"client_id": client_data["client_id"],
|
||||
"redirect_uri": "http://127.0.0.1:8080/callback",
|
||||
"state": "original-state",
|
||||
"code_challenge": "pkce-challenge",
|
||||
"code_challenge_method": "S256",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert authorize_response.status_code == 302
|
||||
location = authorize_response.headers["location"]
|
||||
assert "state=" in location
|
||||
assert "redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fcallback" not in location
|
||||
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
parsed = urlparse(location)
|
||||
query = parse_qs(parsed.query)
|
||||
proxy_state = query["state"][0]
|
||||
|
||||
callback_response = oauth_client.get(
|
||||
"/oauth/callback",
|
||||
params={"state": proxy_state, "code": "auth-code-123"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert callback_response.status_code == 302
|
||||
callback_location = callback_response.headers["location"]
|
||||
assert callback_location.startswith("http://127.0.0.1:8080/callback?")
|
||||
assert "code=auth-code-123" in callback_location
|
||||
assert "state=original-state" in callback_location
|
||||
|
||||
|
||||
def test_oauth_callback_rejects_tampered_state(oauth_client):
|
||||
"""OAuth callback rejects modified signed proxy state."""
|
||||
client_data = _register_public_client(oauth_client, "http://127.0.0.1:8080/callback")
|
||||
authorize_response = oauth_client.get(
|
||||
"/oauth/authorize",
|
||||
params={
|
||||
"client_id": client_data["client_id"],
|
||||
"redirect_uri": "http://127.0.0.1:8080/callback",
|
||||
"state": "original-state",
|
||||
"code_challenge": "pkce-challenge",
|
||||
"code_challenge_method": "S256",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
proxy_state = parse_qs(urlparse(authorize_response.headers["location"]).query)["state"][0]
|
||||
tampered_state = proxy_state[:-1] + ("A" if proxy_state[-1] != "A" else "B")
|
||||
|
||||
callback_response = oauth_client.get(
|
||||
"/oauth/callback",
|
||||
params={"state": tampered_state, "code": "auth-code-123"},
|
||||
)
|
||||
|
||||
assert callback_response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"redirect_uri",
|
||||
[
|
||||
"https://claude.ai/api/mcp/auth_callback",
|
||||
"https://claude.com/api/mcp/auth_callback",
|
||||
],
|
||||
)
|
||||
def test_dcr_accepts_default_claude_callbacks(oauth_client, redirect_uri):
|
||||
"""Claude's hosted connector callback URLs are allowed by default."""
|
||||
response = oauth_client.post(
|
||||
"/register",
|
||||
json={
|
||||
"client_name": "claude-client",
|
||||
"redirect_uris": [redirect_uri],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_oauth_authorize_rejects_unknown_client(oauth_client):
|
||||
"""OAuth authorize returns invalid_client for unregistered client IDs."""
|
||||
response = oauth_client.get(
|
||||
"/oauth/authorize",
|
||||
params={
|
||||
"client_id": "unknown-client",
|
||||
"redirect_uri": "http://127.0.0.1:8080/callback",
|
||||
"state": "x",
|
||||
"code_challenge": "pkce-challenge",
|
||||
"code_challenge_method": "S256",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "invalid_client"
|
||||
|
||||
|
||||
def test_oauth_token_rejects_unknown_dcr_client(oauth_client):
|
||||
"""Unknown dynamic clients receive RFC 6749 invalid_client from token endpoint."""
|
||||
response = oauth_client.post(
|
||||
"/oauth/token",
|
||||
data={
|
||||
"client_id": "deleted-or-unknown-client",
|
||||
"code": "auth-code-123",
|
||||
"code_verifier": "pkce-verifier",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"error": "invalid_client"}
|
||||
|
||||
|
||||
def test_oauth_authorize_requires_pkce_s256(oauth_client):
|
||||
"""Authorization endpoint enforces PKCE S256 for public clients."""
|
||||
client_data = _register_public_client(oauth_client, "http://127.0.0.1:8080/callback")
|
||||
missing_challenge = oauth_client.get(
|
||||
"/oauth/authorize",
|
||||
params={
|
||||
"client_id": client_data["client_id"],
|
||||
"redirect_uri": "http://127.0.0.1:8080/callback",
|
||||
"state": "x",
|
||||
},
|
||||
)
|
||||
plain_method = oauth_client.get(
|
||||
"/oauth/authorize",
|
||||
params={
|
||||
"client_id": client_data["client_id"],
|
||||
"redirect_uri": "http://127.0.0.1:8080/callback",
|
||||
"state": "x",
|
||||
"code_challenge": "pkce-challenge",
|
||||
"code_challenge_method": "plain",
|
||||
},
|
||||
)
|
||||
|
||||
assert missing_challenge.status_code == 400
|
||||
assert plain_method.status_code == 400
|
||||
|
||||
|
||||
def test_register_rejects_foreign_redirect_uri(oauth_client):
|
||||
"""DCR rejects redirect URIs outside the allowlist and loopback/Claude patterns."""
|
||||
response = oauth_client.post(
|
||||
"/register",
|
||||
json={
|
||||
"client_name": "pytest-client",
|
||||
"redirect_uris": ["https://evil.example.com/callback"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"grant_types": ["authorization_code"],
|
||||
"response_types": ["code"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_dcr_registry_persists_registered_clients(tmp_path):
|
||||
"""Registered OAuth clients survive registry reloads."""
|
||||
storage_path = tmp_path / "dcr_clients.json"
|
||||
registry = OAuthClientRegistry(storage_path)
|
||||
request = OAuthRegistrationRequest.model_validate(
|
||||
{
|
||||
"client_name": "persisted-client",
|
||||
"redirect_uris": ["http://127.0.0.1:8080/callback"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"grant_types": ["authorization_code"],
|
||||
"response_types": ["code"],
|
||||
}
|
||||
)
|
||||
|
||||
response = registry.register(request)
|
||||
reloaded = OAuthClientRegistry(storage_path)
|
||||
|
||||
assert reloaded.get(response["client_id"]) is not None
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name != "posix", reason="POSIX permission bits are not enforced on this platform"
|
||||
)
|
||||
def test_dcr_storage_is_written_owner_only(tmp_path):
|
||||
"""The persisted DCR store must not be readable beyond its owner (0o600)."""
|
||||
storage_path = tmp_path / "dcr_clients.json"
|
||||
registry = OAuthClientRegistry(storage_path)
|
||||
request = OAuthRegistrationRequest.model_validate(
|
||||
{
|
||||
"client_name": "perm-client",
|
||||
"redirect_uris": ["http://127.0.0.1:8080/callback"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"grant_types": ["authorization_code"],
|
||||
"response_types": ["code"],
|
||||
}
|
||||
)
|
||||
|
||||
registry.register(request)
|
||||
|
||||
mode = stat.S_IMODE(os.stat(storage_path).st_mode)
|
||||
assert mode == 0o600
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config validation tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_config_oauth_mode_requires_client_id(monkeypatch):
|
||||
"""OAUTH_MODE=true without client_id raises ValueError."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("OAUTH_MODE", "true")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "some-secret")
|
||||
|
||||
from aegis_gitea_mcp.config import Settings
|
||||
|
||||
with pytest.raises(Exception, match="GITEA_OAUTH_CLIENT_ID"):
|
||||
Settings() # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_config_oauth_mode_requires_client_secret(monkeypatch):
|
||||
"""OAUTH_MODE=true without client_secret raises ValueError."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("OAUTH_MODE", "true")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "some-id")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "")
|
||||
|
||||
from aegis_gitea_mcp.config import Settings
|
||||
|
||||
with pytest.raises(Exception, match="GITEA_OAUTH_CLIENT_SECRET"):
|
||||
Settings() # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_config_standard_mode_requires_gitea_token(monkeypatch):
|
||||
"""Standard mode without GITEA_TOKEN raises ValueError."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("OAUTH_MODE", "false")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
|
||||
from aegis_gitea_mcp.config import Settings
|
||||
|
||||
with pytest.raises(Exception, match="GITEA_TOKEN"):
|
||||
Settings() # type: ignore[call-arg]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server middleware: OAuth mode authentication
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_mcp_tool_call_requires_valid_gitea_token(oauth_client):
|
||||
"""POST /mcp/tool/call with an invalid Gitea token returns 401."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
response = oauth_client.post(
|
||||
"/mcp/tool/call",
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
headers={"Authorization": "Bearer invalid-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_mcp_tool_call_no_token_returns_401(oauth_client):
|
||||
"""POST /mcp/tool/call without Authorization header returns 401."""
|
||||
response = oauth_client.post(
|
||||
"/mcp/tool/call",
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
@@ -1,215 +0,0 @@
|
||||
"""OIDC/JWKS-focused OAuth validator tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from jwt.algorithms import RSAAlgorithm
|
||||
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.oauth import GiteaOAuthValidator, reset_oauth_validator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Reset state and configure OAuth validation environment."""
|
||||
reset_settings()
|
||||
reset_oauth_validator()
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("OAUTH_MODE", "true")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
|
||||
monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef")
|
||||
monkeypatch.setenv("OAUTH_CACHE_TTL_SECONDS", "600")
|
||||
yield
|
||||
reset_settings()
|
||||
reset_oauth_validator()
|
||||
|
||||
|
||||
def _build_jwt_fixture(aud: str = "test-client-id") -> tuple[str, dict[str, object]]:
|
||||
"""Generate RS256 access token and matching JWKS payload."""
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
public_key = private_key.public_key()
|
||||
jwk = json.loads(RSAAlgorithm.to_jwk(public_key))
|
||||
jwk["kid"] = "kid-123"
|
||||
|
||||
now = int(time.time())
|
||||
token = jwt.encode(
|
||||
{
|
||||
"sub": "user-1",
|
||||
"preferred_username": "alice",
|
||||
"scope": "read:repository write:repository",
|
||||
"aud": aud,
|
||||
"iss": "https://gitea.example.com",
|
||||
"iat": now,
|
||||
"exp": now + 3600,
|
||||
},
|
||||
private_key,
|
||||
algorithm="RS256",
|
||||
headers={"kid": "kid-123"},
|
||||
)
|
||||
return token, {"keys": [jwk]}
|
||||
|
||||
|
||||
async def _validate_with_jwks(
|
||||
validator: GiteaOAuthValidator, token: str, jwks: dict[str, object]
|
||||
) -> tuple[bool, str | None, dict[str, object] | None]:
|
||||
"""Drive a JWT validation with mocked discovery + JWKS responses."""
|
||||
discovery_response = MagicMock()
|
||||
discovery_response.status_code = 200
|
||||
discovery_response.json.return_value = {
|
||||
"issuer": "https://gitea.example.com",
|
||||
"jwks_uri": "https://gitea.example.com/login/oauth/keys",
|
||||
}
|
||||
jwks_response = MagicMock()
|
||||
jwks_response.status_code = 200
|
||||
jwks_response.json.return_value = jwks
|
||||
|
||||
with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(side_effect=[discovery_response, jwks_response])
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
return await validator.validate_oauth_token(token, "127.0.0.1", "TestAgent")
|
||||
|
||||
|
||||
def test_acceptable_audiences_includes_resource_and_client_id(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The canonical MCP resource and the Gitea client id are accepted audiences."""
|
||||
monkeypatch.setenv("PUBLIC_BASE_URL", "https://mcp.example.com")
|
||||
reset_settings()
|
||||
reset_oauth_validator()
|
||||
audiences = GiteaOAuthValidator()._acceptable_audiences()
|
||||
assert "https://mcp.example.com" in audiences
|
||||
assert "test-client-id" in audiences
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_with_canonical_resource_audience_is_accepted(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A token whose aud is the canonical MCP resource URL validates (P4)."""
|
||||
monkeypatch.setenv("PUBLIC_BASE_URL", "https://mcp.example.com")
|
||||
reset_settings()
|
||||
reset_oauth_validator()
|
||||
token, jwks = _build_jwt_fixture(aud="https://mcp.example.com")
|
||||
valid, error, principal = await _validate_with_jwks(GiteaOAuthValidator(), token, jwks)
|
||||
assert valid is True
|
||||
assert error is None
|
||||
assert principal is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_with_foreign_audience_is_rejected() -> None:
|
||||
"""A token minted for a different audience is rejected (audience binding)."""
|
||||
token, jwks = _build_jwt_fixture(aud="some-other-service")
|
||||
# Foreign-audience JWT fails JWT validation, then falls back to userinfo, which
|
||||
# is not mocked here and raises a network error -> overall failure.
|
||||
with patch("aegis_gitea_mcp.oauth.GiteaOAuthValidator._validate_userinfo") as mock_userinfo:
|
||||
from aegis_gitea_mcp.oauth import OAuthTokenValidationError
|
||||
|
||||
mock_userinfo.side_effect = OAuthTokenValidationError("Invalid", "userinfo_denied")
|
||||
valid, _error, principal = await _validate_with_jwks(GiteaOAuthValidator(), token, jwks)
|
||||
assert valid is False
|
||||
assert principal is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_oauth_token_with_oidc_jwt_and_cache() -> None:
|
||||
"""JWT token validation uses discovery + JWKS and caches both documents."""
|
||||
token, jwks = _build_jwt_fixture()
|
||||
validator = GiteaOAuthValidator()
|
||||
|
||||
discovery_response = MagicMock()
|
||||
discovery_response.status_code = 200
|
||||
discovery_response.json.return_value = {
|
||||
"issuer": "https://gitea.example.com",
|
||||
"jwks_uri": "https://gitea.example.com/login/oauth/keys",
|
||||
}
|
||||
|
||||
jwks_response = MagicMock()
|
||||
jwks_response.status_code = 200
|
||||
jwks_response.json.return_value = jwks
|
||||
|
||||
with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(side_effect=[discovery_response, jwks_response])
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
valid1, error1, principal1 = await validator.validate_oauth_token(
|
||||
token, "127.0.0.1", "TestAgent"
|
||||
)
|
||||
valid2, error2, principal2 = await validator.validate_oauth_token(
|
||||
token, "127.0.0.1", "TestAgent"
|
||||
)
|
||||
|
||||
assert valid1 is True
|
||||
assert error1 is None
|
||||
assert principal1 is not None
|
||||
assert principal1["login"] == "alice"
|
||||
assert "write:repository" in principal1["scopes"]
|
||||
|
||||
assert valid2 is True
|
||||
assert error2 is None
|
||||
assert principal2 is not None
|
||||
# Discovery + JWKS fetched once each because of cache.
|
||||
assert mock_client.get.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_jwt_falls_back_and_fails_userinfo() -> None:
|
||||
"""Invalid JWT returns auth failure when userinfo fallback rejects token."""
|
||||
validator = GiteaOAuthValidator()
|
||||
|
||||
# JWT-shaped token with invalid signature/header.
|
||||
bad_token = "abc.def.ghi"
|
||||
|
||||
discovery_response = MagicMock()
|
||||
discovery_response.status_code = 200
|
||||
discovery_response.json.return_value = {
|
||||
"issuer": "https://gitea.example.com",
|
||||
"jwks_uri": "https://gitea.example.com/login/oauth/keys",
|
||||
}
|
||||
|
||||
jwks_response = MagicMock()
|
||||
jwks_response.status_code = 200
|
||||
jwks_response.json.return_value = {
|
||||
"keys": [{"kid": "missing", "kty": "RSA", "n": "x", "e": "AQAB"}]
|
||||
}
|
||||
|
||||
userinfo_denied = MagicMock()
|
||||
userinfo_denied.status_code = 401
|
||||
|
||||
with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(
|
||||
side_effect=[discovery_response, jwks_response, userinfo_denied]
|
||||
)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
is_valid, error, principal = await validator.validate_oauth_token(
|
||||
bad_token,
|
||||
"127.0.0.1",
|
||||
"TestAgent",
|
||||
)
|
||||
|
||||
assert is_valid is False
|
||||
assert principal is None
|
||||
assert error is not None
|
||||
|
||||
|
||||
def test_extract_bearer_token_strict_parsing() -> None:
|
||||
"""Bearer extraction accepts only strict `Bearer <token>` format."""
|
||||
assert GiteaOAuthValidator.extract_bearer_token("Bearer abc123") == "abc123"
|
||||
assert GiteaOAuthValidator.extract_bearer_token("bearer abc123") is None
|
||||
assert GiteaOAuthValidator.extract_bearer_token("Bearer ") is None
|
||||
assert GiteaOAuthValidator.extract_bearer_token("Basic abc") is None
|
||||
@@ -1,148 +0,0 @@
|
||||
"""Tests for YAML policy engine."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from aegis_gitea_mcp.config import get_settings, reset_settings
|
||||
from aegis_gitea_mcp.policy import PolicyEngine, PolicyError
|
||||
|
||||
|
||||
def _set_base_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "token-12345")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
|
||||
|
||||
def _yaml_with_trailing_newline(content: str) -> str:
|
||||
return content.strip() + "\n"
|
||||
|
||||
|
||||
def test_default_policy_allows_read_and_denies_write(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Default policy should allow reads and deny writes when write mode is disabled."""
|
||||
_set_base_env(monkeypatch)
|
||||
reset_settings()
|
||||
_ = get_settings()
|
||||
|
||||
engine = PolicyEngine.from_yaml_file(tmp_path / "does-not-exist.yaml")
|
||||
read_decision = engine.authorize("list_repositories", is_write=False)
|
||||
write_decision = engine.authorize("create_issue", is_write=True, repository="owner/repo")
|
||||
|
||||
assert read_decision.allowed
|
||||
assert not write_decision.allowed
|
||||
|
||||
|
||||
def test_policy_global_deny(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Global deny should reject matching tool names."""
|
||||
_set_base_env(monkeypatch)
|
||||
policy_path = tmp_path / "policy.yaml"
|
||||
policy_path.write_text(
|
||||
_yaml_with_trailing_newline("""
|
||||
defaults:
|
||||
read: allow
|
||||
write: deny
|
||||
tools:
|
||||
deny:
|
||||
- list_repositories
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
reset_settings()
|
||||
_ = get_settings()
|
||||
engine = PolicyEngine.from_yaml_file(policy_path)
|
||||
|
||||
decision = engine.authorize("list_repositories", is_write=False)
|
||||
assert not decision.allowed
|
||||
assert "denied" in decision.reason
|
||||
|
||||
|
||||
def test_repository_path_restriction(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Repository path allow-list should block unknown paths."""
|
||||
_set_base_env(monkeypatch)
|
||||
policy_path = tmp_path / "policy.yaml"
|
||||
policy_path.write_text(
|
||||
_yaml_with_trailing_newline("""
|
||||
repositories:
|
||||
acme/app:
|
||||
tools:
|
||||
allow:
|
||||
- get_file_contents
|
||||
paths:
|
||||
allow:
|
||||
- src/*
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
reset_settings()
|
||||
_ = get_settings()
|
||||
engine = PolicyEngine.from_yaml_file(policy_path)
|
||||
|
||||
allowed = engine.authorize(
|
||||
"get_file_contents",
|
||||
is_write=False,
|
||||
repository="acme/app",
|
||||
target_path="src/main.py",
|
||||
)
|
||||
denied = engine.authorize(
|
||||
"get_file_contents",
|
||||
is_write=False,
|
||||
repository="acme/app",
|
||||
target_path="docs/readme.md",
|
||||
)
|
||||
|
||||
assert allowed.allowed
|
||||
assert not denied.allowed
|
||||
|
||||
|
||||
def test_invalid_policy_structure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Invalid policy YAML should raise PolicyError."""
|
||||
_set_base_env(monkeypatch)
|
||||
policy_path = tmp_path / "policy.yaml"
|
||||
policy_path.write_text("repositories: []\n", encoding="utf-8")
|
||||
|
||||
reset_settings()
|
||||
_ = get_settings()
|
||||
|
||||
with pytest.raises(PolicyError):
|
||||
PolicyEngine.from_yaml_file(policy_path)
|
||||
|
||||
|
||||
def test_write_mode_repository_whitelist(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Write mode should require repo whitelist and honor configured repository entries."""
|
||||
_set_base_env(monkeypatch)
|
||||
monkeypatch.setenv("WRITE_MODE", "true")
|
||||
monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/app")
|
||||
|
||||
policy_path = tmp_path / "policy.yaml"
|
||||
policy_path.write_text("defaults:\n write: allow\n", encoding="utf-8")
|
||||
|
||||
reset_settings()
|
||||
_ = get_settings()
|
||||
engine = PolicyEngine.from_yaml_file(policy_path)
|
||||
|
||||
allowed = engine.authorize("create_issue", is_write=True, repository="acme/app")
|
||||
denied = engine.authorize("create_issue", is_write=True, repository="acme/other")
|
||||
|
||||
assert allowed.allowed
|
||||
assert denied.allowed is False
|
||||
|
||||
|
||||
def test_write_mode_allow_all_token_repos(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Write mode can be configured to allow all repos accessible to token."""
|
||||
_set_base_env(monkeypatch)
|
||||
monkeypatch.setenv("WRITE_MODE", "true")
|
||||
monkeypatch.setenv("WRITE_ALLOW_ALL_TOKEN_REPOS", "true")
|
||||
|
||||
policy_path = tmp_path / "policy.yaml"
|
||||
policy_path.write_text("defaults:\n write: allow\n", encoding="utf-8")
|
||||
|
||||
reset_settings()
|
||||
_ = get_settings()
|
||||
engine = PolicyEngine.from_yaml_file(policy_path)
|
||||
|
||||
decision = engine.authorize("create_issue", is_write=True, repository="acme/any-repo")
|
||||
assert decision.allowed
|
||||
@@ -1,321 +0,0 @@
|
||||
"""Tests for the generic gitea_request raw API dispatch tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.errors import ToolError
|
||||
from aegis_gitea_mcp.tools.arguments import (
|
||||
extract_repository,
|
||||
extract_target_path,
|
||||
normalize_raw_endpoint,
|
||||
parse_raw_repository,
|
||||
parse_raw_target_path,
|
||||
raw_is_sensitive,
|
||||
raw_top_segment,
|
||||
)
|
||||
from aegis_gitea_mcp.tools.raw_tools import raw_api_request_tool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def raw_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Minimal API-key-mode settings with policy that allows reads, denies writes."""
|
||||
reset_settings()
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-token")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
# Point at a non-existent policy file so the default config applies
|
||||
# (read: allow, write: deny) and tests do not depend on the repo policy.yaml.
|
||||
monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing-policy.yaml"))
|
||||
|
||||
|
||||
class StubRawGitea:
|
||||
"""Stub Gitea client capturing raw_request calls."""
|
||||
|
||||
def __init__(self, response: Any = None) -> None:
|
||||
self._response: Any = {"ok": True} if response is None else response
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
async def raw_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
self.calls.append(
|
||||
{"method": method, "endpoint": endpoint, "params": params, "json_body": json_body}
|
||||
)
|
||||
return self._response
|
||||
|
||||
|
||||
# --- Handler behavior ------------------------------------------------------
|
||||
|
||||
|
||||
async def test_get_repo_endpoint_allowed_and_parses_repository(raw_env: None) -> None:
|
||||
"""A GET on a repo endpoint is allowed and parses owner/repo from the path."""
|
||||
stub = StubRawGitea({"number": 1})
|
||||
result = await raw_api_request_tool(stub, {"method": "GET", "path": "/repos/acme/app/pulls/1"})
|
||||
|
||||
assert result["method"] == "GET"
|
||||
assert result["path"] == "/api/v1/repos/acme/app/pulls/1"
|
||||
assert result["write"] is False
|
||||
assert result["repository"] == "acme/app"
|
||||
assert result["data"] == {"number": 1}
|
||||
assert stub.calls[0]["endpoint"] == "/api/v1/repos/acme/app/pulls/1"
|
||||
|
||||
|
||||
async def test_lowercase_method_is_normalized(raw_env: None) -> None:
|
||||
"""A lowercase method is uppercased and accepted."""
|
||||
stub = StubRawGitea([{"id": 1}])
|
||||
result = await raw_api_request_tool(stub, {"method": "get", "path": "/repos/acme/app/issues"})
|
||||
assert result["method"] == "GET"
|
||||
assert result["count"] == 1
|
||||
|
||||
|
||||
async def test_delete_denied_when_write_mode_off(raw_env: None) -> None:
|
||||
"""A write method is denied (no network call) while write-mode is disabled."""
|
||||
stub = StubRawGitea()
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await raw_api_request_tool(stub, {"method": "DELETE", "path": "/repos/acme/app/issues/1"})
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "write mode is disabled" in str(exc_info.value.detail)
|
||||
assert stub.calls == []
|
||||
|
||||
|
||||
async def test_write_allowed_with_write_mode_and_whitelist(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""A write succeeds only when write-mode is on, the repo is whitelisted, and policy allows."""
|
||||
policy_file = tmp_path / "policy.yaml"
|
||||
policy_file.write_text("defaults:\n read: allow\n write: allow\n", encoding="utf-8")
|
||||
|
||||
reset_settings()
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-token")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("POLICY_FILE_PATH", str(policy_file))
|
||||
monkeypatch.setenv("WRITE_MODE", "true")
|
||||
monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/app")
|
||||
|
||||
stub = StubRawGitea({"merged": True})
|
||||
result = await raw_api_request_tool(
|
||||
stub,
|
||||
{"method": "PUT", "path": "/repos/acme/app/pulls/1/merge", "body": {"Do": "merge"}},
|
||||
)
|
||||
|
||||
assert result["write"] is True
|
||||
assert result["repository"] == "acme/app"
|
||||
assert stub.calls[0]["json_body"] == {"Do": "merge"}
|
||||
|
||||
|
||||
async def test_write_denied_for_repo_outside_whitelist(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""A write on a repo not in the whitelist is denied even with write-mode on."""
|
||||
policy_file = tmp_path / "policy.yaml"
|
||||
policy_file.write_text("defaults:\n read: allow\n write: allow\n", encoding="utf-8")
|
||||
|
||||
reset_settings()
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-token")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("POLICY_FILE_PATH", str(policy_file))
|
||||
monkeypatch.setenv("WRITE_MODE", "true")
|
||||
monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/other")
|
||||
|
||||
stub = StubRawGitea()
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await raw_api_request_tool(stub, {"method": "POST", "path": "/repos/acme/app/issues"})
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "whitelist" in str(exc_info.value.detail)
|
||||
assert stub.calls == []
|
||||
|
||||
|
||||
async def test_non_repository_write_denied(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""A write that targets no repository is denied (secure default)."""
|
||||
policy_file = tmp_path / "policy.yaml"
|
||||
policy_file.write_text("defaults:\n read: allow\n write: allow\n", encoding="utf-8")
|
||||
|
||||
reset_settings()
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-token")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("POLICY_FILE_PATH", str(policy_file))
|
||||
monkeypatch.setenv("WRITE_MODE", "true")
|
||||
monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/app")
|
||||
|
||||
stub = StubRawGitea()
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await raw_api_request_tool(stub, {"method": "POST", "path": "/user/repos"})
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "repository target" in str(exc_info.value.detail)
|
||||
assert stub.calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/admin/users", "/users/bob/tokens", "/repos/acme/app/hooks", "/user/keys"],
|
||||
)
|
||||
async def test_sensitive_paths_denied_on_get(raw_env: None, path: str) -> None:
|
||||
"""Admin/credential surfaces are denied for every method, including GET."""
|
||||
stub = StubRawGitea()
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await raw_api_request_tool(stub, {"method": "GET", "path": path})
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "sensitive-path denylist" in str(exc_info.value.detail)
|
||||
assert stub.calls == []
|
||||
|
||||
|
||||
async def test_sensitive_path_allowed_with_override(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""RAW_API_ALLOW_SENSITIVE bypasses the admin/credential denylist."""
|
||||
reset_settings()
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-token")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing.yaml"))
|
||||
monkeypatch.setenv("RAW_API_ALLOW_SENSITIVE", "true")
|
||||
|
||||
stub = StubRawGitea([{"id": 1}])
|
||||
result = await raw_api_request_tool(stub, {"method": "GET", "path": "/admin/users"})
|
||||
assert result["data"] == [{"id": 1}]
|
||||
assert stub.calls[0]["endpoint"] == "/api/v1/admin/users"
|
||||
|
||||
|
||||
async def test_cross_repo_search_not_treated_as_repository(raw_env: None) -> None:
|
||||
"""/repos/issues/search is a cross-repo endpoint, so repository is None."""
|
||||
stub = StubRawGitea([{"id": 1}])
|
||||
result = await raw_api_request_tool(
|
||||
stub, {"method": "GET", "path": "/repos/issues/search", "query": {"q": "bug"}}
|
||||
)
|
||||
assert result["repository"] is None
|
||||
assert result["count"] == 1
|
||||
assert stub.calls[0]["params"] == {"q": "bug"}
|
||||
|
||||
|
||||
async def test_unknown_method_rejected_before_network(raw_env: None) -> None:
|
||||
"""An unknown HTTP method is rejected during validation before any network call."""
|
||||
stub = StubRawGitea()
|
||||
with pytest.raises(ValidationError):
|
||||
await raw_api_request_tool(stub, {"method": "OPTIONS", "path": "/repos/acme/app"})
|
||||
assert stub.calls == []
|
||||
|
||||
|
||||
async def test_path_traversal_rejected(raw_env: None) -> None:
|
||||
"""A path containing '..' is rejected during validation."""
|
||||
stub = StubRawGitea()
|
||||
with pytest.raises(ValidationError):
|
||||
await raw_api_request_tool(
|
||||
stub, {"method": "GET", "path": "/repos/acme/app/../../admin/users"}
|
||||
)
|
||||
assert stub.calls == []
|
||||
|
||||
|
||||
async def test_full_url_is_reduced_to_path(raw_env: None) -> None:
|
||||
"""A full URL is reduced to just the API path."""
|
||||
stub = StubRawGitea({"name": "app"})
|
||||
result = await raw_api_request_tool(
|
||||
stub,
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "https://gitea.example.com/api/v1/repos/acme/app/contents/src/app.py?ref=main",
|
||||
},
|
||||
)
|
||||
assert result["path"] == "/api/v1/repos/acme/app/contents/src/app.py"
|
||||
assert result["repository"] == "acme/app"
|
||||
|
||||
|
||||
async def test_raw_api_disabled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""The killswitch disables every dispatch."""
|
||||
reset_settings()
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-token")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing.yaml"))
|
||||
monkeypatch.setenv("RAW_API_ENABLED", "false")
|
||||
|
||||
stub = StubRawGitea()
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await raw_api_request_tool(stub, {"method": "GET", "path": "/repos/acme/app"})
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "disabled" in str(exc_info.value.detail)
|
||||
assert stub.calls == []
|
||||
|
||||
|
||||
async def test_large_dict_response_is_truncated(raw_env: None) -> None:
|
||||
"""An oversized object response is returned as a truncated JSON string."""
|
||||
big = {"blob": "x" * 50_000}
|
||||
stub = StubRawGitea(big)
|
||||
result = await raw_api_request_tool(stub, {"method": "GET", "path": "/repos/acme/app"})
|
||||
assert result["truncated"] is True
|
||||
assert isinstance(result["data"], str)
|
||||
|
||||
|
||||
# --- Path parsing helpers --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "expected"),
|
||||
[
|
||||
("/repos/acme/app", "/api/v1/repos/acme/app"),
|
||||
("repos/acme/app", "/api/v1/repos/acme/app"),
|
||||
("/api/v1/repos/acme/app", "/api/v1/repos/acme/app"),
|
||||
("/", "/api/v1"),
|
||||
("", "/api/v1"),
|
||||
],
|
||||
)
|
||||
def test_normalize_raw_endpoint(path: str, expected: str) -> None:
|
||||
assert normalize_raw_endpoint(path) == expected
|
||||
|
||||
|
||||
def test_normalize_raw_endpoint_rejects_traversal() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
normalize_raw_endpoint("/repos/acme/../admin")
|
||||
|
||||
|
||||
def test_parse_raw_repository_variants() -> None:
|
||||
assert parse_raw_repository("/api/v1/repos/acme/app/pulls/1") == "acme/app"
|
||||
assert parse_raw_repository("/api/v1/repos/search") is None
|
||||
assert parse_raw_repository("/api/v1/repos/issues/search") is None
|
||||
assert parse_raw_repository("/api/v1/user/repos") is None
|
||||
|
||||
|
||||
def test_parse_raw_target_path() -> None:
|
||||
assert parse_raw_target_path("/api/v1/repos/acme/app/contents/src/app.py") == "src/app.py"
|
||||
assert parse_raw_target_path("/api/v1/repos/acme/app/raw/README.md") == "README.md"
|
||||
assert parse_raw_target_path("/api/v1/repos/acme/app/pulls/1") is None
|
||||
|
||||
|
||||
def test_raw_top_segment_and_sensitivity() -> None:
|
||||
assert raw_top_segment("/api/v1/repos/acme/app") == "repos"
|
||||
assert raw_top_segment("/api/v1") == ""
|
||||
assert raw_is_sensitive("/api/v1/repos/acme/app/hooks") is True
|
||||
assert raw_is_sensitive("/api/v1/user/applications/oauth2") is True
|
||||
assert raw_is_sensitive("/api/v1/repos/acme/app/pulls") is False
|
||||
|
||||
|
||||
def test_extractors_are_raw_aware() -> None:
|
||||
raw_args = {"method": "GET", "path": "/repos/acme/app/contents/src/app.py"}
|
||||
assert extract_repository(raw_args) == "acme/app"
|
||||
assert extract_target_path(raw_args) == "src/app.py"
|
||||
# Malformed raw path must not raise from the extractors.
|
||||
assert extract_repository({"method": "GET", "path": "/repos/acme/../x"}) is None
|
||||
assert extract_target_path({"method": "GET", "path": "/repos/acme/../x"}) is None
|
||||
@@ -1,172 +0,0 @@
|
||||
"""Tests for repository-focused tool handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.gitea_client import (
|
||||
GiteaAuthenticationError,
|
||||
GiteaAuthorizationError,
|
||||
GiteaError,
|
||||
)
|
||||
from aegis_gitea_mcp.request_context import clear_gitea_auth_context, set_gitea_user_login
|
||||
from aegis_gitea_mcp.tools.repository import (
|
||||
get_file_contents_tool,
|
||||
get_file_tree_tool,
|
||||
get_repository_info_tool,
|
||||
list_repositories_tool,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def repository_tool_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Provide minimal settings needed by response limit helpers."""
|
||||
reset_settings()
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "legacy-token")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
yield
|
||||
reset_settings()
|
||||
|
||||
|
||||
class RepoStub:
|
||||
"""Stub Gitea client for repository tools."""
|
||||
|
||||
async def list_repositories(self):
|
||||
return [{"name": "demo", "owner": {"login": "acme"}, "full_name": "acme/demo"}]
|
||||
|
||||
async def get_repository(self, owner, repo):
|
||||
return {"name": repo, "owner": {"login": owner}, "full_name": f"{owner}/{repo}"}
|
||||
|
||||
async def get_tree(self, owner, repo, ref, recursive):
|
||||
return {"tree": [{"path": "README.md", "type": "blob", "size": 11, "sha": "abc"}]}
|
||||
|
||||
async def get_file_contents(self, owner, repo, filepath, ref):
|
||||
return {
|
||||
"content": "SGVsbG8gV29ybGQ=",
|
||||
"encoding": "base64",
|
||||
"size": 11,
|
||||
"sha": "abc",
|
||||
"html_url": f"https://example/{owner}/{repo}/{filepath}",
|
||||
}
|
||||
|
||||
|
||||
class RepoErrorStub(RepoStub):
|
||||
"""Stub that raises backend errors."""
|
||||
|
||||
async def list_repositories(self):
|
||||
raise GiteaError("backend down")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_repositories_tool_success() -> None:
|
||||
"""Repository listing tool normalizes output shape."""
|
||||
result = await list_repositories_tool(RepoStub(), {})
|
||||
assert result["count"] == 1
|
||||
assert result["repositories"][0]["owner"] == "acme"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_repositories_tool_failure_mode() -> None:
|
||||
"""Repository listing tool wraps backend errors."""
|
||||
with pytest.raises(RuntimeError, match="Failed to list repositories"):
|
||||
await list_repositories_tool(RepoErrorStub(), {})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("auth_error", [GiteaAuthenticationError, GiteaAuthorizationError])
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_propagates_auth_errors_unwrapped(auth_error: type[GiteaError]) -> None:
|
||||
"""Auth/authz failures must surface as-is, not masked behind RuntimeError.
|
||||
|
||||
The server maps these to actionable re-authorization guidance; wrapping them
|
||||
in RuntimeError would hide that and return a generic internal error instead.
|
||||
"""
|
||||
|
||||
class AuthErrorStub(RepoStub):
|
||||
async def list_repositories(self):
|
||||
raise auth_error("token rejected")
|
||||
|
||||
with pytest.raises(auth_error):
|
||||
await list_repositories_tool(AuthErrorStub(), {})
|
||||
|
||||
|
||||
class UserScopedStub(RepoStub):
|
||||
"""Stub exposing the per-user listing path used in service-PAT mode."""
|
||||
|
||||
async def list_user_repositories(self, login):
|
||||
return [{"name": "mine", "owner": {"login": login}, "full_name": f"{login}/mine"}]
|
||||
|
||||
async def list_repositories(self):
|
||||
raise AssertionError("PAT mode with a known user must use the user-scoped listing")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_repositories_tool_scopes_to_user_in_pat_mode() -> None:
|
||||
"""With a service PAT (GITEA_TOKEN) and a known user, listing is user-scoped."""
|
||||
set_gitea_user_login("alice")
|
||||
try:
|
||||
result = await list_repositories_tool(UserScopedStub(), {})
|
||||
finally:
|
||||
clear_gitea_auth_context()
|
||||
assert result["count"] == 1
|
||||
assert result["repositories"][0]["full_name"] == "alice/mine"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_repository_info_tool_success() -> None:
|
||||
"""Repository info tool returns normalized metadata."""
|
||||
result = await get_repository_info_tool(RepoStub(), {"owner": "acme", "repo": "demo"})
|
||||
assert result["full_name"] == "acme/demo"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_tree_tool_success() -> None:
|
||||
"""File tree tool returns bounded tree entries."""
|
||||
result = await get_file_tree_tool(
|
||||
RepoStub(),
|
||||
{"owner": "acme", "repo": "demo", "ref": "main", "recursive": False},
|
||||
)
|
||||
assert result["count"] == 1
|
||||
assert result["tree"][0]["path"] == "README.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_contents_tool_decodes_base64() -> None:
|
||||
"""File contents tool decodes UTF-8 base64 payloads."""
|
||||
result = await get_file_contents_tool(
|
||||
RepoStub(),
|
||||
{"owner": "acme", "repo": "demo", "filepath": "README.md", "ref": "main"},
|
||||
)
|
||||
assert result["content"] == "Hello World"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_contents_tool_handles_invalid_base64() -> None:
|
||||
"""Invalid base64 payloads are returned safely without crashing."""
|
||||
|
||||
class InvalidBase64Stub(RepoStub):
|
||||
async def get_file_contents(self, owner, repo, filepath, ref):
|
||||
return {"content": "%%%not-base64%%%", "encoding": "base64", "size": 4, "sha": "abc"}
|
||||
|
||||
result = await get_file_contents_tool(
|
||||
InvalidBase64Stub(),
|
||||
{"owner": "acme", "repo": "demo", "filepath": "README.md", "ref": "main"},
|
||||
)
|
||||
assert result["content"] == "%%%not-base64%%%"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_contents_tool_failure_mode() -> None:
|
||||
"""File contents tool wraps backend failures."""
|
||||
|
||||
class ErrorFileStub(RepoStub):
|
||||
async def get_file_contents(self, owner, repo, filepath, ref):
|
||||
raise GiteaError("boom")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Failed to get file contents"):
|
||||
await get_file_contents_tool(
|
||||
ErrorFileStub(),
|
||||
{"owner": "acme", "repo": "demo", "filepath": "README.md", "ref": "main"},
|
||||
)
|
||||
@@ -1,862 +0,0 @@
|
||||
"""Tests for MCP server endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.oauth import reset_oauth_validator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_state() -> None:
|
||||
"""Reset global state between tests."""
|
||||
reset_settings()
|
||||
reset_oauth_validator()
|
||||
yield
|
||||
reset_settings()
|
||||
reset_oauth_validator()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oauth_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Set OAuth-first environment for server tests."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("OAUTH_MODE", "true")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
|
||||
monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef")
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
|
||||
monkeypatch.setenv("WRITE_MODE", "false")
|
||||
monkeypatch.setenv("PUBLIC_BASE_URL", "")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_oauth_validation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Mock OAuth validator outcomes by token value."""
|
||||
|
||||
async def _validate(_self, token: str | None, _ip: str, _ua: str):
|
||||
if token == "valid-read":
|
||||
return True, None, {"login": "alice", "scopes": ["read:repository"]}
|
||||
if token == "valid-write":
|
||||
return (
|
||||
True,
|
||||
None,
|
||||
{
|
||||
"login": "alice",
|
||||
"scopes": ["read:repository", "write:repository"],
|
||||
},
|
||||
)
|
||||
return False, "Invalid or expired OAuth token.", None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aegis_gitea_mcp.oauth.GiteaOAuthValidator.validate_oauth_token",
|
||||
_validate,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(oauth_env: None, mock_oauth_validation: None) -> TestClient:
|
||||
"""Create FastAPI test client."""
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_root_endpoint(client: TestClient) -> None:
|
||||
"""Root endpoint returns server metadata."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "AegisGitea MCP Server"
|
||||
assert data["status"] == "running"
|
||||
|
||||
|
||||
def test_health_endpoint(client: TestClient) -> None:
|
||||
"""Health endpoint does not require auth."""
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "healthy"
|
||||
|
||||
|
||||
def test_oauth_protected_resource_metadata(client: TestClient) -> None:
|
||||
"""PRM advertises THIS server's canonical URL as the protected resource."""
|
||||
response = client.get("/.well-known/oauth-protected-resource")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# RFC 9728/8707: the resource identifier is the MCP server's own URL, not Gitea's.
|
||||
assert data["resource"] == "http://testserver"
|
||||
assert data["authorization_servers"] == [
|
||||
"http://testserver",
|
||||
"https://gitea.example.com",
|
||||
]
|
||||
assert data["bearer_methods_supported"] == ["header"]
|
||||
assert data["scopes_supported"] == ["read:repository", "write:repository"]
|
||||
assert "resource_documentation" in data
|
||||
|
||||
|
||||
def test_oauth_authorization_server_metadata(client: TestClient) -> None:
|
||||
"""Auth server metadata advertises this server's proxy OAuth endpoints."""
|
||||
response = client.get("/.well-known/oauth-authorization-server")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
# Claude must be sent to our proxy authorize endpoint (Gitea does not know
|
||||
# Claude's redirect_uri), so the endpoint lives on this server.
|
||||
assert payload["authorization_endpoint"] == "http://testserver/oauth/authorize"
|
||||
assert payload["token_endpoint"] == "http://testserver/oauth/token"
|
||||
assert payload["registration_endpoint"] == "http://testserver/register"
|
||||
assert payload["scopes_supported"] == ["read:repository", "write:repository"]
|
||||
|
||||
|
||||
def test_openid_configuration_metadata(client: TestClient) -> None:
|
||||
"""OpenID metadata is exposed for clients expecting OIDC discovery."""
|
||||
response = client.get("/.well-known/openid-configuration")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["issuer"] == "https://gitea.example.com"
|
||||
assert payload["authorization_endpoint"] == "http://testserver/oauth/authorize"
|
||||
assert payload["token_endpoint"] == "http://testserver/oauth/token"
|
||||
assert payload["userinfo_endpoint"].endswith("/login/oauth/userinfo")
|
||||
assert payload["jwks_uri"].endswith("/login/oauth/keys")
|
||||
assert "read:repository" in payload["scopes_supported"]
|
||||
assert "write:repository" in payload["scopes_supported"]
|
||||
|
||||
|
||||
def test_oauth_metadata_uses_public_base_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Public base URL is used for externally advertised OAuth metadata links."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("OAUTH_MODE", "true")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
|
||||
monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef")
|
||||
monkeypatch.setenv("PUBLIC_BASE_URL", "https://mcp.example.com")
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
client = TestClient(app)
|
||||
metadata_response = client.get("/.well-known/oauth-authorization-server")
|
||||
assert metadata_response.status_code == 200
|
||||
payload = metadata_response.json()
|
||||
assert payload["token_endpoint"] == "https://mcp.example.com/oauth/token"
|
||||
|
||||
openid_response = client.get("/.well-known/openid-configuration")
|
||||
assert openid_response.status_code == 200
|
||||
openid_payload = openid_response.json()
|
||||
assert openid_payload["token_endpoint"] == "https://mcp.example.com/oauth/token"
|
||||
|
||||
protected_response = client.get("/.well-known/oauth-protected-resource")
|
||||
assert protected_response.status_code == 200
|
||||
protected_payload = protected_response.json()
|
||||
# P4: the protected resource identifier must equal this server's public base.
|
||||
assert protected_payload["resource"] == "https://mcp.example.com"
|
||||
assert protected_payload["authorization_servers"] == [
|
||||
"https://mcp.example.com",
|
||||
"https://gitea.example.com",
|
||||
]
|
||||
|
||||
challenge_response = client.post(
|
||||
"/mcp/tool/call",
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
assert challenge_response.status_code == 401
|
||||
challenge = challenge_response.headers["WWW-Authenticate"]
|
||||
assert (
|
||||
'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"'
|
||||
in challenge
|
||||
)
|
||||
|
||||
|
||||
def test_mcp_streamable_http_path_works(client: TestClient) -> None:
|
||||
"""The spec path /mcp exposes the same transport behavior as the SSE alias."""
|
||||
response = client.post(
|
||||
"/mcp",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"jsonrpc": "2.0", "id": "init-1", "method": "initialize"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["result"]["protocolVersion"] == "2024-11-05"
|
||||
|
||||
|
||||
def test_mcp_preflight_allows_same_origin(client: TestClient) -> None:
|
||||
"""Same-origin preflight requests to /mcp return strict CORS headers."""
|
||||
response = client.options(
|
||||
"/mcp",
|
||||
headers={
|
||||
"Origin": "http://testserver",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "authorization,content-type",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert response.headers["Access-Control-Allow-Origin"] == "http://testserver"
|
||||
|
||||
|
||||
def test_mcp_preflight_rejects_cross_origin(client: TestClient) -> None:
|
||||
"""Cross-origin browser requests to /mcp are denied."""
|
||||
response = client.options(
|
||||
"/mcp",
|
||||
headers={
|
||||
"Origin": "https://evil.example.com",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_service_pat_requests_verify_user_repo_access_before_execution(
|
||||
oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Service PAT fallback checks the user's repository permission before executing tools."""
|
||||
from aegis_gitea_mcp import server
|
||||
|
||||
monkeypatch.setenv("GITEA_TOKEN", "service-pat-token")
|
||||
server._api_scope_cache.clear()
|
||||
server.reset_repo_authz_cache()
|
||||
|
||||
probe_response = MagicMock()
|
||||
probe_response.status_code = 200
|
||||
|
||||
repo_response = MagicMock()
|
||||
repo_response.status_code = 403
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(side_effect=[probe_response, repo_response])
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={
|
||||
"tool": "get_repository_info",
|
||||
"arguments": {"owner": "acme", "repo": "demo"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "permission" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_pat_repo_authz_allows_user_with_read_permission(
|
||||
oauth_env: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Read-level collaborator permission allows service PAT execution to proceed."""
|
||||
from aegis_gitea_mcp import server
|
||||
|
||||
monkeypatch.setenv("GITEA_TOKEN", "service-pat-token")
|
||||
server.reset_repo_authz_cache()
|
||||
|
||||
permission_response = MagicMock()
|
||||
permission_response.status_code = 200
|
||||
permission_response.json.return_value = {"permission": "read"}
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=permission_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
await server._verify_user_repository_access(
|
||||
repository="acme/demo",
|
||||
required_scope=server.READ_SCOPE,
|
||||
user_login="alice",
|
||||
correlation_id="corr-1",
|
||||
tool_name="get_repository_info",
|
||||
)
|
||||
|
||||
mock_client.get.assert_awaited_once()
|
||||
requested_url = mock_client.get.await_args.args[0]
|
||||
requested_headers = mock_client.get.await_args.kwargs["headers"]
|
||||
assert requested_url.endswith("/api/v1/repos/acme/demo/collaborators/alice/permission")
|
||||
assert requested_headers["Authorization"] == "token service-pat-token"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_pat_repo_authz_denies_read_user_for_write_tool(
|
||||
oauth_env: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Read permission is insufficient for write tools in service PAT mode."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from aegis_gitea_mcp import server
|
||||
|
||||
monkeypatch.setenv("GITEA_TOKEN", "service-pat-token")
|
||||
server.reset_repo_authz_cache()
|
||||
|
||||
permission_response = MagicMock()
|
||||
permission_response.status_code = 200
|
||||
permission_response.json.return_value = {"permission": "read"}
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=permission_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await server._verify_user_repository_access(
|
||||
repository="acme/demo",
|
||||
required_scope=server.WRITE_SCOPE,
|
||||
user_login="alice",
|
||||
correlation_id="corr-1",
|
||||
tool_name="create_issue",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_pat_repo_authz_cache_hit_and_expiry(
|
||||
oauth_env: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Repository permission decisions are cached briefly and rechecked after expiry."""
|
||||
from aegis_gitea_mcp import cache as cache_module
|
||||
from aegis_gitea_mcp import server
|
||||
|
||||
monkeypatch.setenv("GITEA_TOKEN", "service-pat-token")
|
||||
monkeypatch.setenv("REPO_AUTHZ_CACHE_TTL_SECONDS", "1")
|
||||
server.reset_repo_authz_cache()
|
||||
|
||||
now = 1000.0
|
||||
monkeypatch.setattr(cache_module.time, "monotonic", lambda: now)
|
||||
|
||||
permission_response = MagicMock()
|
||||
permission_response.status_code = 200
|
||||
permission_response.json.return_value = {"permission": "read"}
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=permission_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
for _ in range(2):
|
||||
await server._verify_user_repository_access(
|
||||
repository="acme/demo",
|
||||
required_scope=server.READ_SCOPE,
|
||||
user_login="alice",
|
||||
correlation_id="corr-1",
|
||||
tool_name="get_repository_info",
|
||||
)
|
||||
assert mock_client.get.await_count == 1
|
||||
|
||||
now = 1002.0
|
||||
await server._verify_user_repository_access(
|
||||
repository="acme/demo",
|
||||
required_scope=server.READ_SCOPE,
|
||||
user_login="alice",
|
||||
correlation_id="corr-1",
|
||||
tool_name="get_repository_info",
|
||||
)
|
||||
|
||||
assert mock_client.get.await_count == 2
|
||||
|
||||
|
||||
def test_scope_compatibility_write_implies_read() -> None:
|
||||
"""write:repository grants read-level access for read tools."""
|
||||
from aegis_gitea_mcp.server import READ_SCOPE, _has_required_scope
|
||||
|
||||
assert _has_required_scope(READ_SCOPE, {"write:repository"})
|
||||
|
||||
|
||||
def test_scope_compatibility_repository_aliases() -> None:
|
||||
"""Legacy/broad repository scopes satisfy MCP read/write requirements."""
|
||||
from aegis_gitea_mcp.server import READ_SCOPE, WRITE_SCOPE, _has_required_scope
|
||||
|
||||
assert _has_required_scope(READ_SCOPE, {"repository"})
|
||||
assert _has_required_scope(WRITE_SCOPE, {"repository"})
|
||||
assert _has_required_scope(WRITE_SCOPE, {"repo"})
|
||||
|
||||
|
||||
def test_list_tools_without_auth(client: TestClient) -> None:
|
||||
"""Tool listing remains discoverable without auth."""
|
||||
response = client.get("/mcp/tools")
|
||||
assert response.status_code == 200
|
||||
assert "tools" in response.json()
|
||||
|
||||
|
||||
def test_call_tool_without_auth_returns_challenge(client: TestClient) -> None:
|
||||
"""Tool calls without bearer token return 401 + WWW-Authenticate challenge."""
|
||||
response = client.post("/mcp/tool/call", json={"tool": "list_repositories", "arguments": {}})
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "WWW-Authenticate" in response.headers
|
||||
challenge = response.headers["WWW-Authenticate"]
|
||||
assert 'resource_metadata="http://testserver/.well-known/oauth-protected-resource"' in challenge
|
||||
assert 'scope="read:repository"' in challenge
|
||||
|
||||
|
||||
def test_call_tool_invalid_token_returns_challenge(client: TestClient) -> None:
|
||||
"""Invalid bearer token returns 401 + WWW-Authenticate challenge."""
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer invalid-token"},
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "WWW-Authenticate" in response.headers
|
||||
|
||||
|
||||
def test_sse_tools_list_returns_camel_case_schema(client: TestClient) -> None:
|
||||
"""SSE tools/list returns MCP-compatible camelCase inputSchema."""
|
||||
response = client.post(
|
||||
"/mcp/sse",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"jsonrpc": "2.0", "id": "1", "method": "tools/list"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "result" in data
|
||||
assert "tools" in data["result"]
|
||||
assert "inputSchema" in data["result"]["tools"][0]
|
||||
|
||||
|
||||
def test_sse_initialize_message(client: TestClient) -> None:
|
||||
"""SSE initialize message returns protocol and server metadata."""
|
||||
response = client.post(
|
||||
"/mcp/sse",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"jsonrpc": "2.0", "id": "init-1", "method": "initialize"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["result"]["protocolVersion"] == "2024-11-05"
|
||||
assert payload["result"]["serverInfo"]["name"] == "AegisGitea MCP"
|
||||
|
||||
|
||||
def test_sse_tools_call_success_response(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""SSE tools/call wraps successful tool output in text content."""
|
||||
|
||||
async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict:
|
||||
assert tool_name == "list_repositories"
|
||||
assert isinstance(arguments, dict)
|
||||
assert correlation_id
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
|
||||
|
||||
response = client.post(
|
||||
"/mcp/sse",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": "call-1",
|
||||
"method": "tools/call",
|
||||
"params": {"name": "list_repositories", "arguments": {}},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert '"ok": true' in response.json()["result"]["content"][0]["text"].lower()
|
||||
|
||||
|
||||
def test_sse_tools_call_http_exception(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""SSE tools/call maps HTTPException to JSON-RPC error envelope."""
|
||||
|
||||
async def _fake_execute(_tool_name: str, _arguments: dict, _correlation_id: str) -> dict:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=403, detail="Insufficient scope")
|
||||
|
||||
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
|
||||
|
||||
response = client.post(
|
||||
"/mcp/sse",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": "call-2",
|
||||
"method": "tools/call",
|
||||
"params": {"name": "create_issue", "arguments": {}},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["error"]["code"] == -32000
|
||||
assert "insufficient scope" in body["error"]["message"].lower()
|
||||
|
||||
|
||||
def test_tool_call_not_found_maps_to_404(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A GiteaNotFoundError wrapped in RuntimeError surfaces as a clear 404."""
|
||||
from aegis_gitea_mcp.gitea_client import GiteaNotFoundError
|
||||
|
||||
async def _fake_execute(_tool: str, _args: dict, _cid: str) -> dict:
|
||||
try:
|
||||
raise GiteaNotFoundError("Resource not found")
|
||||
except GiteaNotFoundError as exc:
|
||||
raise RuntimeError("Failed to get issue: Resource not found") from exc
|
||||
|
||||
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
|
||||
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"tool": "get_issue", "arguments": {"owner": "a", "repo": "b", "issue_number": 1}},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["error"].lower()
|
||||
|
||||
|
||||
def test_tool_call_internal_error_includes_exception_type(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Masked internal errors name the exception type (safe) but never the message."""
|
||||
|
||||
async def _fake_execute(_tool: str, _args: dict, _cid: str) -> dict:
|
||||
raise TypeError("'NoneType' object is not iterable")
|
||||
|
||||
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
|
||||
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"tool": "get_issue", "arguments": {"owner": "a", "repo": "b", "issue_number": 1}},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
error = response.json()["error"]
|
||||
assert "TypeError" in error
|
||||
assert "NoneType" not in error
|
||||
|
||||
|
||||
def test_sse_tools_call_not_found_returns_jsonrpc_error(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A wrapped GiteaNotFoundError in the SSE path returns -32000 with a clear message."""
|
||||
from aegis_gitea_mcp.gitea_client import GiteaNotFoundError
|
||||
|
||||
async def _fake_execute(_tool: str, _args: dict, _cid: str) -> dict:
|
||||
try:
|
||||
raise GiteaNotFoundError("Resource not found")
|
||||
except GiteaNotFoundError as exc:
|
||||
raise RuntimeError("Failed to get issue: Resource not found") from exc
|
||||
|
||||
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
|
||||
|
||||
response = client.post(
|
||||
"/mcp/sse",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": "nf-1",
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "get_issue",
|
||||
"arguments": {"owner": "a", "repo": "b", "issue_number": 1},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["error"]["code"] == -32000
|
||||
assert "not found" in body["error"]["message"].lower()
|
||||
|
||||
|
||||
def test_call_nonexistent_tool(client: TestClient) -> None:
|
||||
"""Unknown tools return 404 after successful auth."""
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"tool": "nonexistent_tool", "arguments": {}},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_write_scope_enforced_before_policy(client: TestClient) -> None:
|
||||
"""Write tools require write:repository scope."""
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={
|
||||
"tool": "create_issue",
|
||||
"arguments": {"owner": "acme", "repo": "demo", "title": "test"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "required scope: write:repository" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_write_tool_denied_by_default_policy(client: TestClient) -> None:
|
||||
"""Even with write scope, write mode stays denied by default policy."""
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-write"},
|
||||
json={
|
||||
"tool": "create_issue",
|
||||
"arguments": {"owner": "acme", "repo": "demo", "title": "test"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "write mode is disabled" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_event_fails_when_discovery_unreachable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Startup validation fails with clear guidance if OIDC discovery is unreachable."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("ENVIRONMENT", "production")
|
||||
monkeypatch.setenv("OAUTH_MODE", "true")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
|
||||
monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef")
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "true")
|
||||
|
||||
from aegis_gitea_mcp import server
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(
|
||||
side_effect=httpx.RequestError("connect failed", request=MagicMock())
|
||||
)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="unable to reach Gitea OIDC discovery endpoint",
|
||||
):
|
||||
await server.startup_event()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_event_succeeds_when_discovery_ready(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Startup validation succeeds when OIDC discovery returns HTTP 200."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("ENVIRONMENT", "production")
|
||||
monkeypatch.setenv("OAUTH_MODE", "true")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
|
||||
monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef")
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "true")
|
||||
|
||||
from aegis_gitea_mcp import server
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
await server.startup_event()
|
||||
|
||||
|
||||
def test_probe_scopeless_token_returns_401(
|
||||
oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Middleware returns 401 with re-auth guidance when Gitea probe returns 403."""
|
||||
from aegis_gitea_mcp import server
|
||||
|
||||
server._api_scope_cache.clear()
|
||||
|
||||
mock_probe_response = MagicMock()
|
||||
mock_probe_response.status_code = 403
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_probe_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "WWW-Authenticate" in response.headers
|
||||
|
||||
|
||||
def test_probe_valid_token_proceeds(
|
||||
oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Middleware allows request through when Gitea probe returns 200."""
|
||||
from aegis_gitea_mcp import server
|
||||
|
||||
server._api_scope_cache.clear()
|
||||
|
||||
mock_probe_response = MagicMock()
|
||||
mock_probe_response.status_code = 200
|
||||
|
||||
async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_probe_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
|
||||
|
||||
def test_gitea_authorization_error_returns_403(
|
||||
oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""GiteaAuthorizationError from tool handler returns 403 with re-auth guidance."""
|
||||
from aegis_gitea_mcp import server
|
||||
from aegis_gitea_mcp.gitea_client import GiteaAuthorizationError
|
||||
|
||||
server._api_scope_cache.clear()
|
||||
|
||||
async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict:
|
||||
raise GiteaAuthorizationError("403 Forbidden")
|
||||
|
||||
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
|
||||
|
||||
mock_probe_response = MagicMock()
|
||||
mock_probe_response.status_code = 200
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_probe_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
body = response.json()
|
||||
assert body["success"] is False
|
||||
assert "re-authorize" in body["error"].lower()
|
||||
|
||||
|
||||
def test_gitea_authentication_error_returns_401(
|
||||
oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""GiteaAuthenticationError from tool handler returns 401."""
|
||||
from aegis_gitea_mcp import server
|
||||
from aegis_gitea_mcp.gitea_client import GiteaAuthenticationError
|
||||
|
||||
server._api_scope_cache.clear()
|
||||
|
||||
async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict:
|
||||
raise GiteaAuthenticationError("401 Unauthorized")
|
||||
|
||||
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
|
||||
|
||||
mock_probe_response = MagicMock()
|
||||
mock_probe_response.status_code = 200
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_probe_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
body = response.json()
|
||||
assert body["success"] is False
|
||||
assert "re-authenticate" in body["error"].lower()
|
||||
|
||||
|
||||
def test_sse_gitea_authorization_error_returns_jsonrpc_error(
|
||||
oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""GiteaAuthorizationError in SSE handler returns JSON-RPC -32000 with guidance."""
|
||||
from aegis_gitea_mcp import server
|
||||
from aegis_gitea_mcp.gitea_client import GiteaAuthorizationError
|
||||
|
||||
server._api_scope_cache.clear()
|
||||
|
||||
async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict:
|
||||
raise GiteaAuthorizationError("403 Forbidden")
|
||||
|
||||
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
|
||||
|
||||
mock_probe_response = MagicMock()
|
||||
mock_probe_response.status_code = 200
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_probe_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
response = client.post(
|
||||
"/mcp/sse",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": "err-1",
|
||||
"method": "tools/call",
|
||||
"params": {"name": "list_repositories", "arguments": {}},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["error"]["code"] == -32000
|
||||
assert "re-authorize" in body["error"]["message"].lower()
|
||||
@@ -1,210 +0,0 @@
|
||||
"""Tests for the local stdio transport adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from aegis_gitea_mcp import stdio_app
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.errors import ToolError
|
||||
from aegis_gitea_mcp.request_context import get_gitea_user_login
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stdio_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Local-mode settings: PAT auth, no OAuth, no API-key requirement."""
|
||||
reset_settings()
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "local-pat-token")
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing-policy.yaml"))
|
||||
monkeypatch.setattr(stdio_app, "_owner_login", None)
|
||||
|
||||
|
||||
def _patch_gitea_client(**methods: object) -> object:
|
||||
"""Patch GiteaClient with an async-context-manager mock exposing methods."""
|
||||
patcher = patch("aegis_gitea_mcp.gitea_client.GiteaClient")
|
||||
cls = patcher.start()
|
||||
instance = AsyncMock()
|
||||
for name, value in methods.items():
|
||||
setattr(instance, name, AsyncMock(return_value=value))
|
||||
cls.return_value.__aenter__ = AsyncMock(return_value=instance)
|
||||
cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
return patcher
|
||||
|
||||
|
||||
# --- Environment bootstrap --------------------------------------------------
|
||||
|
||||
|
||||
def test_bootstrap_forces_local_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("OAUTH_MODE", raising=False)
|
||||
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||
monkeypatch.delenv("AUDIT_LOG_PATH", raising=False)
|
||||
stdio_app._bootstrap_env()
|
||||
import os
|
||||
|
||||
assert os.environ["OAUTH_MODE"] == "false"
|
||||
assert os.environ["AUTH_ENABLED"] == "false"
|
||||
assert os.environ["AUDIT_LOG_PATH"].endswith("audit.log")
|
||||
|
||||
|
||||
def test_check_required_env_raises_when_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITEA_URL", raising=False)
|
||||
monkeypatch.delenv("GITEA_TOKEN", raising=False)
|
||||
with pytest.raises(stdio_app.StdioConfigError) as exc_info:
|
||||
stdio_app._check_required_env()
|
||||
assert "GITEA_URL" in str(exc_info.value)
|
||||
assert "GITEA_TOKEN" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_check_required_env_passes_when_present(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "tok")
|
||||
stdio_app._check_required_env() # no raise
|
||||
|
||||
|
||||
def test_default_audit_log_path_is_user_scoped() -> None:
|
||||
path = stdio_app._default_audit_log_path()
|
||||
assert path.name == "audit.log"
|
||||
assert "aegis-gitea-mcp" in str(path)
|
||||
|
||||
|
||||
def test_configure_stderr_logging_keeps_stdout_clean(stdio_env: None) -> None:
|
||||
"""No log handler may target stdout (it is the JSON-RPC channel)."""
|
||||
import logging
|
||||
import sys
|
||||
|
||||
# Simulate a library that attached a stdout handler.
|
||||
root = logging.getLogger()
|
||||
root.addHandler(logging.StreamHandler(sys.stdout))
|
||||
stdio_app._configure_stderr_logging()
|
||||
for handler in logging.getLogger().handlers:
|
||||
assert getattr(handler, "stream", sys.stderr) is not sys.stdout
|
||||
|
||||
|
||||
def test_main_exits_when_env_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITEA_URL", raising=False)
|
||||
monkeypatch.delenv("GITEA_TOKEN", raising=False)
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
stdio_app.main()
|
||||
assert exc_info.value.code == 2
|
||||
|
||||
|
||||
# --- Owner resolution -------------------------------------------------------
|
||||
|
||||
|
||||
async def test_resolve_owner_login(stdio_env: None) -> None:
|
||||
patcher = _patch_gitea_client(get_current_user={"login": "alice"})
|
||||
try:
|
||||
login = await stdio_app._resolve_owner_login()
|
||||
finally:
|
||||
patcher.stop()
|
||||
assert login == "alice"
|
||||
|
||||
|
||||
async def test_resolve_owner_login_empty_raises(stdio_env: None) -> None:
|
||||
patcher = _patch_gitea_client(get_current_user={"login": ""})
|
||||
try:
|
||||
with pytest.raises(stdio_app.StdioConfigError):
|
||||
await stdio_app._resolve_owner_login()
|
||||
finally:
|
||||
patcher.stop()
|
||||
|
||||
|
||||
# --- Dispatch (shared registry + policy + audit) ----------------------------
|
||||
|
||||
|
||||
async def test_dispatch_unknown_tool(stdio_env: None) -> None:
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await stdio_app._dispatch("nope_not_a_tool", {})
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
async def test_dispatch_policy_denies_write_without_write_mode(stdio_env: None) -> None:
|
||||
"""A write tool is denied by policy/WRITE_MODE before any network call."""
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await stdio_app._dispatch("create_issue", {"owner": "acme", "repo": "app", "title": "x"})
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "write mode is disabled" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
async def test_dispatch_pins_owner_login_and_returns(
|
||||
stdio_env: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Dispatch pins request context to the PAT owner and runs the shared handler."""
|
||||
monkeypatch.setattr(stdio_app, "_owner_login", "alice")
|
||||
patcher = _patch_gitea_client(
|
||||
get_repository={
|
||||
"owner": {"login": "acme"},
|
||||
"name": "app",
|
||||
"full_name": "acme/app",
|
||||
}
|
||||
)
|
||||
try:
|
||||
result = await stdio_app._dispatch("get_repository_info", {"owner": "acme", "repo": "app"})
|
||||
finally:
|
||||
patcher.stop()
|
||||
assert result["name"] == "app"
|
||||
# The dispatch pinned the trusted PAT owner onto the request context.
|
||||
assert get_gitea_user_login() == "alice"
|
||||
|
||||
|
||||
# --- End-to-end over the MCP protocol (in-memory transport) -----------------
|
||||
|
||||
|
||||
async def test_build_server_exposes_registry_tools(stdio_env: None) -> None:
|
||||
"""build_server() wires every registry tool, including gitea_request."""
|
||||
from mcp.shared.memory import create_connected_server_and_client_session
|
||||
|
||||
server = stdio_app.build_server()
|
||||
async with create_connected_server_and_client_session(server) as client:
|
||||
await client.initialize()
|
||||
listed = await client.list_tools()
|
||||
names = {t.name for t in listed.tools}
|
||||
assert "get_repository_info" in names
|
||||
assert "gitea_request" in names
|
||||
assert len(names) >= 40
|
||||
|
||||
|
||||
async def test_stdio_tool_call_round_trip(stdio_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A tools/call over the MCP protocol dispatches through the shared core."""
|
||||
from mcp.shared.memory import create_connected_server_and_client_session
|
||||
|
||||
monkeypatch.setattr(stdio_app, "_owner_login", "alice")
|
||||
patcher = _patch_gitea_client(
|
||||
get_repository={"owner": {"login": "acme"}, "name": "app", "full_name": "acme/app"}
|
||||
)
|
||||
try:
|
||||
server = stdio_app.build_server()
|
||||
async with create_connected_server_and_client_session(server) as client:
|
||||
await client.initialize()
|
||||
result = await client.call_tool("get_repository_info", {"owner": "acme", "repo": "app"})
|
||||
finally:
|
||||
patcher.stop()
|
||||
|
||||
assert result.isError is False
|
||||
text = "".join(getattr(block, "text", "") for block in result.content)
|
||||
assert "app" in text
|
||||
|
||||
|
||||
async def test_stdio_tool_call_policy_denial_is_reported(
|
||||
stdio_env: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A write tool denied by WRITE_MODE surfaces as an MCP error, not a crash."""
|
||||
from mcp.shared.memory import create_connected_server_and_client_session
|
||||
|
||||
monkeypatch.setattr(stdio_app, "_owner_login", "alice")
|
||||
server = stdio_app.build_server()
|
||||
async with create_connected_server_and_client_session(server) as client:
|
||||
await client.initialize()
|
||||
result = await client.call_tool(
|
||||
"create_issue", {"owner": "acme", "repo": "app", "title": "x"}
|
||||
)
|
||||
|
||||
assert result.isError is True
|
||||
text = "".join(getattr(block, "text", "") for block in result.content)
|
||||
assert "write mode is disabled" in text
|
||||
@@ -1,542 +0,0 @@
|
||||
"""Tests for expanded read/write MCP tool handlers."""
|
||||
|
||||
import pytest
|
||||
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.gitea_client import GiteaAuthenticationError, GiteaError
|
||||
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.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,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def tool_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Provide minimal settings environment for response limit helpers."""
|
||||
reset_settings()
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-token")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
|
||||
|
||||
class StubGitea:
|
||||
"""Stubbed Gitea client for tool unit tests."""
|
||||
|
||||
async def search_code(self, owner, repo, query, *, ref, page, limit):
|
||||
return {"hits": [{"path": "src/main.py", "snippet": "match text", "score": 1.0}]}
|
||||
|
||||
async def list_commits(self, owner, repo, *, ref, page, limit):
|
||||
return [{"sha": "abc1234", "commit": {"message": "Fix bug", "author": {"date": "now"}}}]
|
||||
|
||||
async def get_commit_diff(self, owner, repo, sha):
|
||||
return {
|
||||
"commit": {"message": "Fix bug"},
|
||||
"files": [{"filename": "a.py", "status": "modified"}],
|
||||
}
|
||||
|
||||
async def compare_refs(self, owner, repo, base, head):
|
||||
return {
|
||||
"commits": [{"sha": "abc", "commit": {"message": "Msg"}}],
|
||||
"files": [{"filename": "a.py", "status": "modified"}],
|
||||
}
|
||||
|
||||
async def list_issues(self, owner, repo, *, state, page, limit, labels=None):
|
||||
return [{"number": 1, "title": "Issue", "state": "open", "labels": []}]
|
||||
|
||||
async def get_issue(self, owner, repo, index):
|
||||
return {"number": index, "title": "Issue", "body": "Body", "state": "open", "labels": []}
|
||||
|
||||
async def list_pull_requests(self, owner, repo, *, state, page, limit):
|
||||
return [{"number": 1, "title": "PR", "state": "open"}]
|
||||
|
||||
async def get_pull_request(self, owner, repo, index):
|
||||
return {"number": index, "title": "PR", "body": "Body", "state": "open"}
|
||||
|
||||
async def list_labels(self, owner, repo, *, page, limit):
|
||||
return [{"id": 1, "name": "bug", "color": "ff0000", "description": "desc"}]
|
||||
|
||||
async def list_tags(self, owner, repo, *, page, limit):
|
||||
return [{"name": "v1.0.0", "commit": {"sha": "abc"}}]
|
||||
|
||||
async def list_releases(self, owner, repo, *, page, limit):
|
||||
return [{"id": 1, "tag_name": "v1.0.0", "name": "release"}]
|
||||
|
||||
async def list_pull_request_files(self, owner, repo, index, *, page, limit):
|
||||
return [{"filename": "a.py", "status": "modified", "additions": 1, "deletions": 0}]
|
||||
|
||||
async def list_pull_request_commits(self, owner, repo, index, *, page, limit):
|
||||
return [{"sha": "abc", "commit": {"message": "m"}, "author": {"login": "alice"}}]
|
||||
|
||||
async def list_issue_comments(self, owner, repo, index, *, page, limit):
|
||||
return [{"id": 1, "body": "hi", "user": {"login": "alice"}}]
|
||||
|
||||
async def list_branches(self, owner, repo, *, page, limit):
|
||||
return [{"name": "main", "protected": True, "commit": {"id": "abc"}}]
|
||||
|
||||
async def get_branch(self, owner, repo, branch):
|
||||
return {"name": branch, "protected": False, "commit": {"id": "abc"}}
|
||||
|
||||
async def get_release(self, owner, repo, release_id):
|
||||
return {"id": release_id, "tag_name": "v1", "name": "rel"}
|
||||
|
||||
async def get_latest_release(self, owner, repo):
|
||||
return {"id": 1, "tag_name": "v1", "name": "rel"}
|
||||
|
||||
async def list_milestones(self, owner, repo, *, state, page, limit):
|
||||
return [{"id": 1, "title": "M", "state": state}]
|
||||
|
||||
async def get_commit_status(self, owner, repo, sha):
|
||||
return {"state": "success", "statuses": [{"context": "ci", "status": "success"}]}
|
||||
|
||||
async def list_org_repositories(self, org, *, page, limit):
|
||||
return [{"name": "r", "owner": {"login": org}, "full_name": f"{org}/r"}]
|
||||
|
||||
async def list_organizations(self, *, page, limit):
|
||||
return [{"id": 1, "username": "acme", "description": "d"}]
|
||||
|
||||
async def get_repo_languages(self, owner, repo):
|
||||
return {"Python": 100, "HTML": 5}
|
||||
|
||||
async def list_repo_topics(self, owner, repo):
|
||||
return ["python", "mcp"]
|
||||
|
||||
async def create_issue(
|
||||
self, owner, repo, *, title, body, labels=None, assignees=None, milestone=None
|
||||
):
|
||||
result = {"number": 1, "title": title, "state": "open"}
|
||||
if milestone is not None:
|
||||
result["milestone"] = {"id": 4, "title": str(milestone)}
|
||||
return result
|
||||
|
||||
async def update_issue(
|
||||
self, owner, repo, index, *, title=None, body=None, state=None, milestone=None
|
||||
):
|
||||
result = {"number": index, "title": title or "Issue", "state": state or "open"}
|
||||
if milestone is not None:
|
||||
result["milestone"] = {"id": 4, "title": str(milestone)}
|
||||
return result
|
||||
|
||||
async def create_issue_comment(self, owner, repo, index, body):
|
||||
return {"id": 1, "body": body}
|
||||
|
||||
async def create_pr_comment(self, owner, repo, index, body):
|
||||
return {"id": 2, "body": body}
|
||||
|
||||
async def add_labels(self, owner, repo, index, labels):
|
||||
return {"labels": [{"name": label} for label in labels]}
|
||||
|
||||
async def assign_issue(self, owner, repo, index, assignees):
|
||||
return {"assignees": [{"login": user} for user in assignees]}
|
||||
|
||||
async def create_label(self, owner, repo, *, name, color, description="", exclusive=False):
|
||||
return {"id": 5, "name": name, "color": color, "description": description, "url": "u"}
|
||||
|
||||
async def update_label(self, owner, repo, *, name, new_name=None, color=None, description=None):
|
||||
return {
|
||||
"id": 5,
|
||||
"name": new_name or name,
|
||||
"color": color or "#ffffff",
|
||||
"description": description or "",
|
||||
}
|
||||
|
||||
async def remove_labels(self, owner, repo, index, labels):
|
||||
return []
|
||||
|
||||
async def create_pull_request(self, owner, repo, *, title, head, base, body=""):
|
||||
return {"number": 7, "title": title, "state": "open"}
|
||||
|
||||
async def create_release(
|
||||
self, owner, repo, *, tag_name, name="", body="", draft=False, prerelease=False, target=None
|
||||
):
|
||||
return {"id": 3, "tag_name": tag_name, "name": name or tag_name}
|
||||
|
||||
async def edit_release(
|
||||
self, owner, repo, release_id, *, name=None, body=None, draft=None, prerelease=None
|
||||
):
|
||||
return {"id": release_id, "tag_name": "v1", "name": name or "rel"}
|
||||
|
||||
async def create_branch(self, owner, repo, *, new_branch_name, old_branch_name=None):
|
||||
return {"name": new_branch_name, "commit": {"id": "abc"}}
|
||||
|
||||
async def create_milestone(self, owner, repo, *, title, description="", due_on=None):
|
||||
return {"id": 4, "title": title, "state": "open"}
|
||||
|
||||
async def edit_issue_comment(self, owner, repo, comment_id, body):
|
||||
return {"id": comment_id, "body": body}
|
||||
|
||||
|
||||
class ErrorGitea(StubGitea):
|
||||
"""Stub that raises backend errors for failure-mode coverage."""
|
||||
|
||||
async def list_commits(self, owner, repo, *, ref, page, limit):
|
||||
raise GiteaError("backend failure")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"tool,args,expected_key",
|
||||
[
|
||||
(search_code_tool, {"owner": "acme", "repo": "app", "query": "foo"}, "results"),
|
||||
(list_commits_tool, {"owner": "acme", "repo": "app"}, "commits"),
|
||||
(get_commit_diff_tool, {"owner": "acme", "repo": "app", "sha": "abc1234"}, "files"),
|
||||
(
|
||||
compare_refs_tool,
|
||||
{"owner": "acme", "repo": "app", "base": "main", "head": "feature"},
|
||||
"commits",
|
||||
),
|
||||
(list_issues_tool, {"owner": "acme", "repo": "app"}, "issues"),
|
||||
(get_issue_tool, {"owner": "acme", "repo": "app", "issue_number": 1}, "title"),
|
||||
(list_pull_requests_tool, {"owner": "acme", "repo": "app"}, "pull_requests"),
|
||||
(get_pull_request_tool, {"owner": "acme", "repo": "app", "pull_number": 1}, "title"),
|
||||
(list_labels_tool, {"owner": "acme", "repo": "app"}, "labels"),
|
||||
(list_tags_tool, {"owner": "acme", "repo": "app"}, "tags"),
|
||||
(list_releases_tool, {"owner": "acme", "repo": "app"}, "releases"),
|
||||
(
|
||||
list_pull_request_files_tool,
|
||||
{"owner": "acme", "repo": "app", "pull_number": 1},
|
||||
"files",
|
||||
),
|
||||
(
|
||||
list_pull_request_commits_tool,
|
||||
{"owner": "acme", "repo": "app", "pull_number": 1},
|
||||
"commits",
|
||||
),
|
||||
(
|
||||
list_issue_comments_tool,
|
||||
{"owner": "acme", "repo": "app", "issue_number": 1},
|
||||
"comments",
|
||||
),
|
||||
(list_branches_tool, {"owner": "acme", "repo": "app"}, "branches"),
|
||||
(get_branch_tool, {"owner": "acme", "repo": "app", "branch": "main"}, "name"),
|
||||
(get_release_tool, {"owner": "acme", "repo": "app", "release_id": 1}, "tag_name"),
|
||||
(get_latest_release_tool, {"owner": "acme", "repo": "app"}, "tag_name"),
|
||||
(list_milestones_tool, {"owner": "acme", "repo": "app"}, "milestones"),
|
||||
(
|
||||
get_commit_status_tool,
|
||||
{"owner": "acme", "repo": "app", "sha": "abc1234"},
|
||||
"state",
|
||||
),
|
||||
(list_org_repositories_tool, {"org": "acme"}, "repositories"),
|
||||
(list_organizations_tool, {}, "organizations"),
|
||||
(get_repo_languages_tool, {"owner": "acme", "repo": "app"}, "languages"),
|
||||
(list_repo_topics_tool, {"owner": "acme", "repo": "app"}, "topics"),
|
||||
],
|
||||
)
|
||||
async def test_extended_read_tools_success(tool, args, expected_key):
|
||||
"""Each expanded read tool should return expected top-level keys."""
|
||||
result = await tool(StubGitea(), args)
|
||||
assert expected_key in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extended_read_tools_failure_mode() -> None:
|
||||
"""Expanded read tools should wrap backend failures."""
|
||||
with pytest.raises(RuntimeError):
|
||||
await list_commits_tool(ErrorGitea(), {"owner": "acme", "repo": "app"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_issue_tolerates_null_collections() -> None:
|
||||
"""Regression for #13: Gitea may return null for labels/assignees/user.
|
||||
|
||||
`.get(key, [])` returns None when the key is present with a null value, so
|
||||
iterating the result raised `'NoneType' object is not iterable`.
|
||||
"""
|
||||
|
||||
class NullFieldsGitea(StubGitea):
|
||||
async def get_issue(self, owner, repo, index):
|
||||
return {
|
||||
"number": index,
|
||||
"title": "Issue",
|
||||
"body": "Body",
|
||||
"state": "open",
|
||||
"user": None,
|
||||
"labels": None,
|
||||
"assignees": None,
|
||||
}
|
||||
|
||||
result = await get_issue_tool(
|
||||
NullFieldsGitea(), {"owner": "acme", "repo": "app", "issue_number": 1}
|
||||
)
|
||||
assert result["author"] == ""
|
||||
assert result["labels"] == []
|
||||
assert result["assignees"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_issue_skips_non_dict_collection_elements() -> None:
|
||||
"""Defense-in-depth for #27: tolerate non-dict entries inside labels/assignees.
|
||||
|
||||
A stray null/non-object element would otherwise raise AttributeError when
|
||||
`.get()` is called on it, surfacing as an opaque internal error.
|
||||
"""
|
||||
|
||||
class MalformedElementsGitea(StubGitea):
|
||||
async def get_issue(self, owner, repo, index):
|
||||
return {
|
||||
"number": index,
|
||||
"title": "Issue",
|
||||
"body": "Body",
|
||||
"state": "open",
|
||||
"user": {"login": "alice"},
|
||||
"labels": [{"name": "bug"}, None, "weird"],
|
||||
"assignees": [{"login": "bob"}, None, 42],
|
||||
}
|
||||
|
||||
result = await get_issue_tool(
|
||||
MalformedElementsGitea(), {"owner": "acme", "repo": "app", "issue_number": 1}
|
||||
)
|
||||
assert result["labels"] == ["bug"]
|
||||
assert result["assignees"] == ["bob"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"tool,args,expected_key",
|
||||
[
|
||||
(create_issue_tool, {"owner": "acme", "repo": "app", "title": "Issue"}, "number"),
|
||||
(
|
||||
update_issue_tool,
|
||||
{"owner": "acme", "repo": "app", "issue_number": 1, "title": "Updated"},
|
||||
"number",
|
||||
),
|
||||
(
|
||||
create_issue_comment_tool,
|
||||
{"owner": "acme", "repo": "app", "issue_number": 1, "body": "comment"},
|
||||
"id",
|
||||
),
|
||||
(
|
||||
create_pr_comment_tool,
|
||||
{"owner": "acme", "repo": "app", "pull_number": 1, "body": "comment"},
|
||||
"id",
|
||||
),
|
||||
(
|
||||
add_labels_tool,
|
||||
{"owner": "acme", "repo": "app", "issue_number": 1, "labels": ["bug"]},
|
||||
"labels",
|
||||
),
|
||||
(
|
||||
assign_issue_tool,
|
||||
{"owner": "acme", "repo": "app", "issue_number": 1, "assignees": ["alice"]},
|
||||
"assignees",
|
||||
),
|
||||
(
|
||||
create_label_tool,
|
||||
{"owner": "acme", "repo": "app", "name": "bug", "color": "#ff0000"},
|
||||
"id",
|
||||
),
|
||||
(
|
||||
update_label_tool,
|
||||
{"owner": "acme", "repo": "app", "name": "bug", "new_name": "defect"},
|
||||
"id",
|
||||
),
|
||||
(
|
||||
remove_labels_tool,
|
||||
{"owner": "acme", "repo": "app", "issue_number": 1, "labels": ["bug"]},
|
||||
"removed",
|
||||
),
|
||||
(
|
||||
create_pull_request_tool,
|
||||
{"owner": "acme", "repo": "app", "title": "PR", "head": "feature", "base": "main"},
|
||||
"number",
|
||||
),
|
||||
(
|
||||
create_release_tool,
|
||||
{"owner": "acme", "repo": "app", "tag_name": "v1.0.0"},
|
||||
"id",
|
||||
),
|
||||
(
|
||||
edit_release_tool,
|
||||
{"owner": "acme", "repo": "app", "release_id": 3, "name": "x"},
|
||||
"id",
|
||||
),
|
||||
(
|
||||
create_branch_tool,
|
||||
{"owner": "acme", "repo": "app", "new_branch_name": "feature/x"},
|
||||
"name",
|
||||
),
|
||||
(
|
||||
create_milestone_tool,
|
||||
{"owner": "acme", "repo": "app", "title": "M1"},
|
||||
"id",
|
||||
),
|
||||
(
|
||||
edit_issue_comment_tool,
|
||||
{"owner": "acme", "repo": "app", "comment_id": 5, "body": "edited"},
|
||||
"id",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_write_tools_success(tool, args, expected_key):
|
||||
"""Write tools should normalize successful backend responses."""
|
||||
result = await tool(StubGitea(), args)
|
||||
assert expected_key in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_label_normalizes_color_without_hash() -> None:
|
||||
"""A hex color without a leading '#' is normalized before hitting Gitea."""
|
||||
captured: dict = {}
|
||||
|
||||
class CaptureStub(StubGitea):
|
||||
async def create_label(self, owner, repo, *, name, color, description="", exclusive=False):
|
||||
captured["color"] = color
|
||||
return {"id": 7, "name": name, "color": color}
|
||||
|
||||
result = await create_label_tool(
|
||||
CaptureStub(),
|
||||
{"owner": "acme", "repo": "app", "name": "bug", "color": "ff0000"},
|
||||
)
|
||||
assert captured["color"] == "#ff0000"
|
||||
assert result["id"] == 7
|
||||
|
||||
|
||||
def test_create_label_args_reject_invalid_color() -> None:
|
||||
"""Non-hex color values are rejected at the argument layer."""
|
||||
import pydantic
|
||||
|
||||
from aegis_gitea_mcp.tools.arguments import CreateLabelArgs
|
||||
|
||||
with pytest.raises(pydantic.ValidationError):
|
||||
CreateLabelArgs(owner="o", repo="r", name="bug", color="red")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_issue_returns_assigned_milestone_title() -> None:
|
||||
"""create_issue surfaces the assigned milestone title in its response."""
|
||||
result = await create_issue_tool(
|
||||
StubGitea(),
|
||||
{"owner": "acme", "repo": "app", "title": "Issue", "milestone": "Sprint 1"},
|
||||
)
|
||||
assert result["milestone"] == "Sprint 1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_issue_accepts_milestone_only() -> None:
|
||||
"""update_issue may change only the milestone (no title/body/state needed)."""
|
||||
result = await update_issue_tool(
|
||||
StubGitea(),
|
||||
{"owner": "acme", "repo": "app", "issue_number": 1, "milestone": 4},
|
||||
)
|
||||
assert result["milestone"] == "4"
|
||||
|
||||
|
||||
def test_update_issue_args_require_a_changed_field() -> None:
|
||||
"""An update with no mutable field (incl. milestone) is rejected."""
|
||||
import pydantic
|
||||
|
||||
from aegis_gitea_mcp.tools.arguments import UpdateIssueArgs
|
||||
|
||||
with pytest.raises(pydantic.ValidationError):
|
||||
UpdateIssueArgs(owner="o", repo="r", issue_number=1)
|
||||
# Supplying only a milestone satisfies the change requirement.
|
||||
assert UpdateIssueArgs(owner="o", repo="r", issue_number=1, milestone=0).milestone == 0
|
||||
|
||||
|
||||
def test_issue_args_reject_boolean_milestone() -> None:
|
||||
"""A boolean is rejected as a milestone reference (it subclasses int)."""
|
||||
import pydantic
|
||||
|
||||
from aegis_gitea_mcp.tools.arguments import CreateIssueArgs
|
||||
|
||||
with pytest.raises(pydantic.ValidationError):
|
||||
CreateIssueArgs(owner="o", repo="r", title="x", milestone=True)
|
||||
|
||||
|
||||
# (tool, valid_args) for every write tool, used to exercise error branches.
|
||||
WRITE_TOOL_ERROR_CASES = [
|
||||
(create_issue_tool, {"owner": "acme", "repo": "app", "title": "Issue"}),
|
||||
(update_issue_tool, {"owner": "acme", "repo": "app", "issue_number": 1, "title": "x"}),
|
||||
(create_issue_comment_tool, {"owner": "acme", "repo": "app", "issue_number": 1, "body": "c"}),
|
||||
(create_pr_comment_tool, {"owner": "acme", "repo": "app", "pull_number": 1, "body": "c"}),
|
||||
(add_labels_tool, {"owner": "acme", "repo": "app", "issue_number": 1, "labels": ["bug"]}),
|
||||
(assign_issue_tool, {"owner": "acme", "repo": "app", "issue_number": 1, "assignees": ["al"]}),
|
||||
(create_label_tool, {"owner": "acme", "repo": "app", "name": "bug", "color": "#ff0000"}),
|
||||
(update_label_tool, {"owner": "acme", "repo": "app", "name": "bug", "new_name": "defect"}),
|
||||
(remove_labels_tool, {"owner": "acme", "repo": "app", "issue_number": 1, "labels": ["bug"]}),
|
||||
(
|
||||
create_pull_request_tool,
|
||||
{"owner": "acme", "repo": "app", "title": "PR", "head": "feature", "base": "main"},
|
||||
),
|
||||
(create_release_tool, {"owner": "acme", "repo": "app", "tag_name": "v1.0.0"}),
|
||||
(edit_release_tool, {"owner": "acme", "repo": "app", "release_id": 3, "name": "x"}),
|
||||
(create_branch_tool, {"owner": "acme", "repo": "app", "new_branch_name": "feature/x"}),
|
||||
(create_milestone_tool, {"owner": "acme", "repo": "app", "title": "M1"}),
|
||||
(edit_issue_comment_tool, {"owner": "acme", "repo": "app", "comment_id": 5, "body": "e"}),
|
||||
]
|
||||
|
||||
|
||||
class _WriteBackendErrorGitea:
|
||||
"""Stub whose every method raises a generic Gitea backend error."""
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
async def _raise(*args: object, **kwargs: object) -> object:
|
||||
raise GiteaError("backend failure")
|
||||
|
||||
return _raise
|
||||
|
||||
|
||||
class _WriteAuthErrorGitea:
|
||||
"""Stub whose every method raises an authentication error."""
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
async def _raise(*args: object, **kwargs: object) -> object:
|
||||
raise GiteaAuthenticationError("token expired")
|
||||
|
||||
return _raise
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("tool,args", WRITE_TOOL_ERROR_CASES)
|
||||
async def test_write_tools_wrap_backend_errors(tool, args) -> None:
|
||||
"""Every write tool wraps a backend GiteaError as a RuntimeError."""
|
||||
with pytest.raises(RuntimeError):
|
||||
await tool(_WriteBackendErrorGitea(), args)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("tool,args", WRITE_TOOL_ERROR_CASES)
|
||||
async def test_write_tools_propagate_auth_errors(tool, args) -> None:
|
||||
"""Every write tool lets auth failures surface for re-authorization."""
|
||||
with pytest.raises(GiteaAuthenticationError):
|
||||
await tool(_WriteAuthErrorGitea(), args)
|
||||
Reference in new issue
Block a user