refactor!: remove legacy adapters, policy engine and API-key auth
Removes the FastAPI server, hand-rolled MCP protocol, tool registry and tools, YAML policy, resource-type authz, API-key and OAuth proxy modules, automation, observability, cache, the old Gitea client and stdio adapter, the key-management scripts and their tests (ADR 0006).
This commit is contained in:
1 parent
c0b22c531e
commit
4ac9626eb5
41 files changed
-13441
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