feat(opax-mcp): add guarded gitea change proposals
This commit is contained in:
parent
b519871a48
commit
7d98df7055
|
|
@ -54,7 +54,7 @@ spec:
|
||||||
key: latest
|
key: latest
|
||||||
name: gitea-token
|
name: gitea-token
|
||||||
- name: GITEA_URL
|
- name: GITEA_URL
|
||||||
value: "http://34.67.252.59:3000"
|
value: "https://git.vauco.no"
|
||||||
- name: INTERNAL_API_KEY
|
- name: INTERNAL_API_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import json
|
||||||
import uuid
|
import uuid
|
||||||
import httpx
|
import httpx
|
||||||
import base64
|
import base64
|
||||||
|
from urllib.parse import quote
|
||||||
|
import difflib
|
||||||
import google.auth
|
import google.auth
|
||||||
import google.auth.transport.requests
|
import google.auth.transport.requests
|
||||||
import google.oauth2.id_token
|
import google.oauth2.id_token
|
||||||
|
|
@ -76,8 +78,224 @@ import re
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
# --- A2H2A Pydantic Models ---
|
MAX_PROPOSE_CONTENT_BYTES = 1_048_576
|
||||||
|
_REPO_ID_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||||
|
|
||||||
|
# --- A2H2A Pydantic Models ---
|
||||||
|
class GiteaChangePlan(BaseModel):
|
||||||
|
"""Immutable admin-approved one-file Gitea change plan."""
|
||||||
|
|
||||||
|
plan_id: str = Field(
|
||||||
|
default_factory=lambda: f"gitea-change-{uuid.uuid4().hex}"
|
||||||
|
)
|
||||||
|
status: str = "PENDING"
|
||||||
|
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
expires_at: datetime = Field(
|
||||||
|
default_factory=lambda: datetime.utcnow() + timedelta(minutes=15)
|
||||||
|
)
|
||||||
|
approved_at: Optional[datetime] = None
|
||||||
|
approved_by: Optional[str] = None
|
||||||
|
applied_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
repo: str
|
||||||
|
branch: str
|
||||||
|
path: str
|
||||||
|
base_sha: str
|
||||||
|
new_content: str
|
||||||
|
content_hash: str
|
||||||
|
commit_message: str
|
||||||
|
unified_diff: str
|
||||||
|
|
||||||
|
result_commit_sha: Optional[str] = None
|
||||||
|
existing_file_sha: Optional[str] = None
|
||||||
|
apply_idempotency_key: Optional[str] = None
|
||||||
|
apply_status: Optional[str] = None
|
||||||
|
apply_error_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_gitea_change_plan(plan: GiteaChangePlan) -> dict:
|
||||||
|
"""Persist a pending one-file Gitea change plan in Firestore."""
|
||||||
|
from google.cloud import firestore
|
||||||
|
|
||||||
|
db = firestore.AsyncClient(project=GOOGLE_CLOUD_PROJECT)
|
||||||
|
plan_data = plan.model_dump(mode="json", exclude={"new_content"})
|
||||||
|
plan_data["status"] = "PENDING"
|
||||||
|
|
||||||
|
plan_ref = db.collection("gitea_change_plans").document(plan.plan_id)
|
||||||
|
await plan_ref.set(plan_data)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Created Gitea change plan plan_id=%s repo=%s branch=%s path=%s",
|
||||||
|
plan.plan_id,
|
||||||
|
plan.repo,
|
||||||
|
plan.branch,
|
||||||
|
plan.path,
|
||||||
|
)
|
||||||
|
return plan_data
|
||||||
|
|
||||||
|
|
||||||
|
async def get_gitea_change_plan(
|
||||||
|
plan_id: str,
|
||||||
|
) -> Optional[GiteaChangePlan]:
|
||||||
|
"""Return a persisted Gitea change plan, or None when it does not exist."""
|
||||||
|
from google.cloud import firestore
|
||||||
|
|
||||||
|
db = firestore.AsyncClient(project=GOOGLE_CLOUD_PROJECT)
|
||||||
|
plan_ref = db.collection("gitea_change_plans").document(plan_id)
|
||||||
|
document = await plan_ref.get()
|
||||||
|
|
||||||
|
if not document.exists:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return GiteaChangePlan(**document.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_admin_gitea_path(path: str) -> None:
|
||||||
|
"""Validate a repository-relative admin file path."""
|
||||||
|
if not isinstance(path, str) or not path or path.isspace():
|
||||||
|
raise ValueError("Path cannot be empty.")
|
||||||
|
if path.startswith("/") or "\\" in path or "\x00" in path:
|
||||||
|
raise ValueError("Path contains unsupported characters.")
|
||||||
|
segments = path.split("/")
|
||||||
|
if any(segment in {".", ".."} for segment in segments):
|
||||||
|
raise ValueError("Path traversal is not allowed.")
|
||||||
|
|
||||||
|
lowered_path = path.lower()
|
||||||
|
sensitive_markers = (
|
||||||
|
".env",
|
||||||
|
".pem",
|
||||||
|
".key",
|
||||||
|
"credentials",
|
||||||
|
"service_account",
|
||||||
|
"private_key",
|
||||||
|
)
|
||||||
|
if any(marker in lowered_path for marker in sensitive_markers):
|
||||||
|
raise ValueError("Path targets a sensitive file.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_gitea_repo_format(repo: str) -> None:
|
||||||
|
"""Validate the canonical owner/repository identifier format."""
|
||||||
|
if not isinstance(repo, str) or not _REPO_ID_RE.fullmatch(repo):
|
||||||
|
raise ValueError("Repository must use the owner/repository format.")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_gitea_file_details(
|
||||||
|
repo: str,
|
||||||
|
path: str,
|
||||||
|
ref: str,
|
||||||
|
) -> tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Return decoded file content and file SHA; return (None, None) on 404."""
|
||||||
|
api_path = f"/repos/{repo}/contents/{quote(path, safe='/')}?ref={ref}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await _gitea_get(api_path)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
if exc.response.status_code == 404:
|
||||||
|
return None, None
|
||||||
|
logger.warning(
|
||||||
|
"Gitea file detail lookup failed repo=%s path=%s ref=%s status=%s",
|
||||||
|
repo,
|
||||||
|
path,
|
||||||
|
ref,
|
||||||
|
exc.response.status_code,
|
||||||
|
)
|
||||||
|
raise ValueError("Gitea API error while reading file details.") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
encoded_content = payload["content"]
|
||||||
|
content = base64.b64decode(encoded_content).decode("utf-8")
|
||||||
|
except (KeyError, TypeError, ValueError, UnicodeDecodeError) as exc:
|
||||||
|
raise ValueError("Gitea returned invalid file content.") from exc
|
||||||
|
|
||||||
|
return content, payload.get("sha")
|
||||||
|
|
||||||
|
|
||||||
|
async def propose_gitea_change(p: dict) -> dict:
|
||||||
|
"""Create and persist a read-only, immutable one-file Gitea change plan."""
|
||||||
|
repo = p.get("repo")
|
||||||
|
branch = p.get("branch")
|
||||||
|
path = p.get("path")
|
||||||
|
new_content = p.get("new_content")
|
||||||
|
base_sha = p.get("base_sha")
|
||||||
|
commit_message = p.get("commit_message")
|
||||||
|
|
||||||
|
if not all((repo, branch, path, base_sha, commit_message)):
|
||||||
|
raise ValueError(
|
||||||
|
"Missing required fields: repo, branch, path, base_sha, commit_message."
|
||||||
|
)
|
||||||
|
if new_content is None:
|
||||||
|
raise ValueError("Missing required field: new_content.")
|
||||||
|
if not isinstance(new_content, str):
|
||||||
|
raise ValueError("new_content must be a string.")
|
||||||
|
if len(new_content.encode("utf-8")) > MAX_PROPOSE_CONTENT_BYTES:
|
||||||
|
raise ValueError("new_content exceeds the maximum allowed size.")
|
||||||
|
|
||||||
|
_validate_gitea_repo_format(repo)
|
||||||
|
if repo != GITEA_REPO:
|
||||||
|
raise ValueError(
|
||||||
|
"Repository change proposal is not allowed for this repository."
|
||||||
|
)
|
||||||
|
_validate_admin_gitea_path(path)
|
||||||
|
|
||||||
|
live_sha = await resolve_branch_to_commit_sha(
|
||||||
|
branch_name=branch,
|
||||||
|
repo_id=repo,
|
||||||
|
gitea_url=GITEA_URL,
|
||||||
|
)
|
||||||
|
if live_sha != base_sha:
|
||||||
|
raise ValueError(
|
||||||
|
"Branch head does not match the supplied base_sha. Refresh and retry."
|
||||||
|
)
|
||||||
|
|
||||||
|
old_content, existing_file_sha = await _get_gitea_file_details(
|
||||||
|
repo=repo,
|
||||||
|
path=path,
|
||||||
|
ref=branch,
|
||||||
|
)
|
||||||
|
old_lines = (
|
||||||
|
old_content.splitlines(keepends=True)
|
||||||
|
if old_content is not None
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
new_lines = new_content.splitlines(keepends=True)
|
||||||
|
unified_diff = "".join(
|
||||||
|
difflib.unified_diff(
|
||||||
|
old_lines,
|
||||||
|
new_lines,
|
||||||
|
fromfile=f"a/{path}",
|
||||||
|
tofile=f"b/{path}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not unified_diff:
|
||||||
|
raise ValueError("Proposed content produces no file change.")
|
||||||
|
|
||||||
|
content_hash = hashlib.sha256(new_content.encode("utf-8")).hexdigest()
|
||||||
|
plan = GiteaChangePlan(
|
||||||
|
repo=repo,
|
||||||
|
branch=branch,
|
||||||
|
path=path,
|
||||||
|
base_sha=base_sha,
|
||||||
|
new_content=new_content,
|
||||||
|
content_hash=content_hash,
|
||||||
|
commit_message=commit_message,
|
||||||
|
unified_diff=unified_diff,
|
||||||
|
existing_file_sha=existing_file_sha,
|
||||||
|
)
|
||||||
|
await create_gitea_change_plan(plan)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"plan_id": plan.plan_id,
|
||||||
|
"status": "PENDING",
|
||||||
|
"expires_at": plan.expires_at.isoformat(),
|
||||||
|
"unified_diff": unified_diff,
|
||||||
|
"content_hash": content_hash,
|
||||||
|
"repo": repo,
|
||||||
|
"branch": branch,
|
||||||
|
"path": path,
|
||||||
|
"base_sha": base_sha,
|
||||||
|
"commit_message": commit_message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class TicketSource(BaseModel):
|
class TicketSource(BaseModel):
|
||||||
|
|
@ -1332,6 +1550,22 @@ TOOLS = {
|
||||||
"list_commits": (list_commits, "List siste commits i Gitea-repo", {}),
|
"list_commits": (list_commits, "List siste commits i Gitea-repo", {}),
|
||||||
"get_file": (get_file, "Hent fil fra Gitea-repo", {"type":"object","properties":{"path":{"type":"string"}, "ref":{"type":"string"}, "repo":{"type":"string"}},"required":["path", "ref"]}),
|
"get_file": (get_file, "Hent fil fra Gitea-repo", {"type":"object","properties":{"path":{"type":"string"}, "ref":{"type":"string"}, "repo":{"type":"string"}},"required":["path", "ref"]}),
|
||||||
"list_repo_files": (list_repo_files, "Lists files and directories in a Gitea repository path.", {"type": "object", "properties": {"path": {"type": "string"}}}),
|
"list_repo_files": (list_repo_files, "Lists files and directories in a Gitea repository path.", {"type": "object", "properties": {"path": {"type": "string"}}}),
|
||||||
|
"propose_gitea_change": (
|
||||||
|
propose_gitea_change,
|
||||||
|
"Create a pending, one-file Gitea change plan for review; does not apply or commit changes.",
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"repo": {"type": "string"},
|
||||||
|
"branch": {"type": "string"},
|
||||||
|
"path": {"type": "string"},
|
||||||
|
"new_content": {"type": "string"},
|
||||||
|
"base_sha": {"type": "string"},
|
||||||
|
"commit_message": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["repo", "branch", "path", "new_content", "base_sha", "commit_message"]
|
||||||
|
},
|
||||||
|
),
|
||||||
# Google Workspace
|
# Google Workspace
|
||||||
"create_email_alias": (create_email_alias, "Opprett et nytt e-postalias", {"type":"object","properties":{"user_key":{"type":"string"},"alias":{"type":"string"}},"required":["user_key","alias"]}),
|
"create_email_alias": (create_email_alias, "Opprett et nytt e-postalias", {"type":"object","properties":{"user_key":{"type":"string"},"alias":{"type":"string"}},"required":["user_key","alias"]}),
|
||||||
"list_user_aliases": (list_user_aliases, "List en brukers e-postaliaser", {"type":"object","properties":{"user_key":{"type":"string"}},"required":["user_key"]}),
|
"list_user_aliases": (list_user_aliases, "List en brukers e-postaliaser", {"type":"object","properties":{"user_key":{"type":"string"}},"required":["user_key"]}),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user