diff --git a/cloudbuild.opax-mcp.yaml b/cloudbuild.opax-mcp.yaml index e62f8bd..5227da4 100644 --- a/cloudbuild.opax-mcp.yaml +++ b/cloudbuild.opax-mcp.yaml @@ -1,13 +1,24 @@ # Definitive CI/CD pipeline for the OPAX-MCP service. -# Uses YAML anchors for readable and reusable steps. -# --- Reusable Step Definitions (YAML Anchors) --- -x-reusable-steps: - # Anchor for Tyr Policy Check - - &tyr-policy-check - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' +# --- Pipeline Steps --- +steps: + # 0. Clone the specific branch from the repository + - name: 'gcr.io/cloud-builders/git' + id: 'Clone Repository' + args: + - 'clone' + - '--branch' + - '${_BRANCH_NAME}' + - '--single-branch' + - 'http://34.59.131.162:3000/chris/osvauco.git' + - '.' # Clone into the current directory (/workspace) + waitFor: ['-'] # Run first + + # 1. Run Tyr Policy Check + - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' id: 'Tyr Policy Check' entrypoint: 'bash' + waitFor: ['Clone Repository'] args: - '-c' - | @@ -17,17 +28,18 @@ x-reusable-steps: # A non-zero exit code here will fail the build. echo "SUCCESS: Tyr Policy Check passed." - # Anchor for Building and Pushing the Docker image - - &docker-build-and-push - name: 'gcr.io/cloud-builders/docker' + # 2. Build and Push the image + - name: 'gcr.io/cloud-builders/docker' id: 'Build and Push Image' entrypoint: 'bash' + waitFor: ['Tyr Policy Check'] args: - '-c' - | set -e echo "INFO: Building Docker image for service [${_SERVICE_NAME}]." docker build \ + --no-cache \ -t "${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/${_SERVICE_NAME}:${_SHORT_SHA}" \ -t "${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/${_SERVICE_NAME}:${_BRANCH_NAME}" \ --build-arg "COMMIT_SHA=${_SHORT_SHA}" \ @@ -36,11 +48,11 @@ x-reusable-steps: echo "INFO: Pushing tags [${_SHORT_SHA}, ${_BRANCH_NAME}] to Artifact Registry." docker push --all "${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/${_SERVICE_NAME}" - # Anchor for Conditional Deployment to Cloud Run - - &deploy-to-cloud-run - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' + # 3. Conditionally deploy to Cloud Run + - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' id: 'Deploy to Cloud Run' entrypoint: 'bash' + waitFor: ['Build and Push Image'] args: - '-c' - | @@ -62,19 +74,6 @@ x-reusable-steps: echo "INFO: Branch [${_BRANCH_NAME}] is not a deployable branch. Skipping deployment." fi -# --- Pipeline Steps --- -steps: - # 1. Run Tyr Policy Check - - <<: *tyr-policy-check - - # 2. Build and Push the image - - <<: *docker-build-and-push - waitFor: ['Tyr Policy Check'] - - # 3. Conditionally deploy to Cloud Run - - <<: *deploy-to-cloud-run - waitFor: ['Build and Push Image'] - # --- Images created by this build --- images: - '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/${_SERVICE_NAME}:${_SHORT_SHA}' @@ -89,8 +88,8 @@ substitutions: _DOCKERFILE_PATH: 'opax-mcp/Dockerfile' _MCP_SA: 'jason-vauger@propane-will-491900-m5.iam.gserviceaccount.com' # These are automatically populated by Cloud Build - _BRANCH_NAME: ${BRANCH_NAME} - _SHORT_SHA: ${SHORT_SHA} + _BRANCH_NAME: 'main' # Default for manual runs, will be overridden + _SHORT_SHA: 'manual' options: logging: CLOUD_LOGGING_ONLY diff --git a/deploy_mcp.sh b/deploy_mcp.sh index 0e8ea21..14c6e57 100755 --- a/deploy_mcp.sh +++ b/deploy_mcp.sh @@ -12,7 +12,7 @@ echo "=== 1. Configure Docker auth ===" gcloud auth configure-docker "${REGION}-docker.pkg.dev" echo "=== 2. Build locally ===" -docker build --no-cache -t "${FULL_IMAGE}" -f opax-mcp/Dockerfile ./opax-mcp +docker build --no-cache --pull -t "${FULL_IMAGE}" -f opax-mcp/Dockerfile ./opax-mcp echo "=== 3. Push image ===" docker push "${FULL_IMAGE}" diff --git a/implementation-plan.md b/implementation-plan.md new file mode 100644 index 0000000..8be05f8 --- /dev/null +++ b/implementation-plan.md @@ -0,0 +1,112 @@ +# Implementeringsplan: www.opax.work + +## Fase 0 — Manglende beslutningsgrunnlag + +**Forutsetninger:** Tilgang til Gitea-repo og Google Cloud-prosjektet. + +**Endringer:** + +1. **Lokaliser `opax-hub` kildekode:** + * Identifiser Gitea-repo og commit/tag som matcher det aktive `opax-hub` container-imaget. +2. **Verifiser `osvauco-agent` internt API:** + * Gjennomgå kildekoden for `osvauco-agent` for å verifisere dets interne API og om det har MCP-rutingslogikk. + +**Beslutningskriterier:** + +* Basert på kildekodetilgang og -kvalitet for `opax-hub`, avgjør om den skal videreføres og herdes, eller om `opax-web` skal bygges som en ny tjeneste. + +**Risiko:** Lav. Read-only operasjoner. + +**Godkjenningspunkt:** Presentasjon av funn og anbefaling for Fase 2. + +## Fase 1 — Sikkerhetsopprydding (separat fra webterminalen) + +**Forutsetninger:** Eierskap og avhengigheter for de usikre tjenestene er kjent. + +**Endringer:** + +1. **Roter `gitea-chat-bridge` webhook:** + * Opprett ny webhook. + * Oppdater `gitea-chat-bridge` distribusjonskonfigurasjon til å bruke den nye webhooken fra Secret Manager. + * Verifiser at den nye webhooken fungerer. + * Tilbakekall den gamle webhooken. +2. **Håndter `osvx-mcp` tjenester:** + * Lag en plan for å enten avvikle, stramme inn IAM, eller migrere funksjonaliteten til en sikker tjeneste. +3. **Oppdater incident-notat:** + * Tildel eier og sett tidsfrister for opprydding. + +**Risiko:** Medium–høy. Rotasjon av en brukt webhook og endring av Cloud Run-konfigurasjon kan bryte varslinger eller drift dersom avhengigheter ikke er kjent. + +**Rollback:** Behold fungerende erstatter verifisert før gammel webhook tilbakekalles, og dokumenter påvirkede integrasjoner. + +**Godkjenningspunkt:** Godkjenning av planen for hver av de tre endringene. + +## Fase 2 — Web-lag + +**Forutsetninger:** Beslutning fra Fase 0 er tatt. + +**Endringer:** + +1. **Harden `opax-hub` eller bygg `opax-web`:** + * Implementer nødvendige endringer for å sikre applikasjonen. +2. **Konfigurer OAuth for produksjon:** + * Sett OAuth callback til `https://www.opax.work/auth/callback`. +3. **Stram inn CORS:** + * Sett `ALLOWED_ORIGINS` til `https://www.opax.work`. +4. **Dedikert Service Account:** + * Opprett en dedikert service account for weblaget med minimalt med rettigheter. + +**Risiko:** Medium. Ny offentlig OAuth-webapp kan påvirke callback-registrering, cookie-domene, CORS, brukerinnlogging og domeneruting. + +**Godkjenningspunkt:** En eksplisitt pre-go-live sikkerhetstest. + +## Fase 3 — Intern kjede + +**Forutsetninger:** Kildekodegjennomgang har bekreftet call graphen. + +**Endringer (betinget av kodegjennomgang):** + +1. **Implementer `opax-web/hub` → `osvauco-agent` kall:** + * Hvis kodegjennomgang bekrefter dette, implementer sikker service-to-service kall med ID-token. +2. **Implementer `osvauco-agent` → MCP-gateway kall:** + * Hvis kodegjennomgang bekrefter dette, implementer kall til den herdede MCP-gatewayen. +3. **IAM-herding:** + * Forbered og gjennomgå en eksakt, reverserbar IAM-diff som fjerner `allUsers` først etter at navngitte service accounts er verifisert med minst nødvendige invoker-rettigheter og call path er testet. + +**Risiko:** Høy. Endringer i IAM kan påvirke eksisterende integrasjoner. + +**Rollback:** Gjenopprett den forrige policyen fra en lagret policy-eksport. + +**Godkjenningspunkt:** Godkjenning av IAM-diff før anvendelse. + +## Fase 4.0 / pre-go-live sjekkliste + +- [ ] Riktig OAuth redirect URI er registrert +- [ ] Allowlist og autorisasjon er testet server-side +- [ ] Cookies bruker Secure, HttpOnly og bevisst SameSite-policy +- [ ] CORS/origin-regler er begrenset til godkjente origins +- [ ] Ingen secretverdier finnes i klientbundle, Git, Cloud Run literal env eller logger +- [ ] IAM-policyer er eksportert og gjennomgått før og etter endring +- [ ] Rollback-eier, DNS TTL og verifisert rollback-prosedyre er fastsatt +- [ ] Observability, feilhåndtering og health checks er på plass + +## Fase 4 — Domene og deploy + +**Forutsetninger:** Alle tidligere faser er fullført og godkjent. + +**Endringer:** + +1. **Undersøk domene/sertifikat-mekanisme:** + * Avgjør den beste måten å håndtere domene og sertifikat basert på eksisterende DNS, Cloud Run domain mapping, eller en eksisterende global HTTPS load balancer. +2. **Deploy til produksjon:** + * Deploy den nye/herdede webtjenesten til Cloud Run. +3. **Konfigurer DNS:** + * Konfigurer DNS for `www.opax.work` til å peke til den nye tjenesten. + +**Risiko:** Høy. Feilkonfigurering kan føre til nedetid. + +**Testing:** + +* Verifiser først mot godkjent preview-/staging-endepunkt eller Cloud Run-URL; etter eksplisitt go-live-godkjenning verifiseres `www.opax.work`. + +**Godkjenningspunkt:** Godkjenning av DNS-endringer. \ No newline at end of file diff --git a/incident-note-gitea-chat-bridge.md b/incident-note-gitea-chat-bridge.md new file mode 100644 index 0000000..08377a8 --- /dev/null +++ b/incident-note-gitea-chat-bridge.md @@ -0,0 +1,16 @@ +# Incident Note: Gitea Chat Bridge Credential Exposure + +**Affected Service:** `gitea-chat-bridge` in `us-central1` + +**Exposure:** A public Cloud Run service was discovered to have a credential configured as a literal environment variable. + +**Risk:** The exposed credential could allow unauthorized posting to an integrated chat destination. The credential itself is compromised. + +**Immediate Decision:** The `gitea-chat-bridge` service will not be used in the new OPAX web terminal architecture. + +**Remediation:** + +1. The exposed webhook should be revoked and replaced. +2. The new credential should be stored in Secret Manager. +3. The service's deployment configuration must be updated to reference the new secret from Secret Manager. +4. The Cloud Run service's ingress should be reviewed and potentially restricted if public access is not required. diff --git a/opax-mcp/Dockerfile b/opax-mcp/Dockerfile index ce4acb2..6fd505e 100644 --- a/opax-mcp/Dockerfile +++ b/opax-mcp/Dockerfile @@ -2,13 +2,18 @@ FROM python:3.12-slim WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . +COPY startup.sh /app/startup.sh +RUN chmod +x /app/startup.sh + ENV PORT=8080 EXPOSE 8080 -CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8080"] +CMD ["/app/startup.sh"] diff --git a/opax-mcp/requirements.txt b/opax-mcp/requirements.txt index e8bc5f9..4793e10 100644 --- a/opax-mcp/requirements.txt +++ b/opax-mcp/requirements.txt @@ -6,3 +6,5 @@ google-api-python-client>=2.120.0 google-cloud-firestore>=2.16.0 google-cloud-secret-manager>=2.18.0 google-cloud-build>=2.0.0 +pytest>=8.0.0 +pytest-asyncio>=0.23.0 diff --git a/opax-mcp/server.py b/opax-mcp/server.py index f3edff3..559275d 100644 --- a/opax-mcp/server.py +++ b/opax-mcp/server.py @@ -21,6 +21,44 @@ from datetime import datetime, timezone, timedelta from typing import Any, Optional import logging from pydantic import BaseModel +import subprocess +import shlex +import re +from pathlib import Path + +# This allowlist defines the strict, minimum-privilege toolset exposed +# to the web-agent integration route. +WEB_AGENT_ALLOWED_TOOLS = frozenset({ + "get_health", + "get_build_status", + "get_state", + "get_telemetry", + "get_git_status", + "get_git_branch", + "get_git_log", + "get_git_diff", + "list_repo_files", + "read_repo_file", +}) + +# --- Sikkerhets- og konfigurasjonskonstanter for Git-verktøy --- +REPO_ROOT = Path("/app/OSVauco").resolve() +ALLOWED_REMOTE_HOSTS = {"git.vauco.no"} +ALLOWED_REMOTE_PATH = "chris/OSVauco" +GIT_TIMEOUT_READ = 30 +GIT_TIMEOUT_WRITE = 60 + +GENERIC_SECRET_PATTERNS = [ + re.compile(r"gh[pousr]_[A-Za-z0-9_]{20,}"), + re.compile(r"glpat-[A-Za-z0-9_-]{20,}"), + re.compile(r"gitea_[A-Za-z0-9_-]{10,}"), + re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"), +] +SECRET_MASK_PATTERNS = [] +for var in ("MCP_SECRET", "GITEA_TOKEN", "INTERNAL_API_KEY", "GITHUB_TOKEN"): + val = os.environ.get(var) + if val: + SECRET_MASK_PATTERNS.append(re.compile(re.escape(val))) class ConfirmationRequired(BaseModel): @@ -113,9 +151,246 @@ async def _agent_post(path: str, body: dict) -> Any: """POST-kall til osvauco-agent.""" url = f"{OSVAUCO_AGENT_URL}{path}" async with httpx.AsyncClient(timeout=45) as c: - r = await c.post(url, json=body, headers=_agent_headers()) - r.raise_for_status() - return r.json() + try: + r = await c.post(url, json=body, headers=_agent_headers()) + r.raise_for_status() + return r.json() + except httpx.HTTPStatusError as e: + logger.error(f"HTTP Status Error for {e.request.url}: {e.response.status_code}") + logger.error(f"Response body: {e.response.text}") + raise + +# --------------------------------------------------------------------------- +# Hjelpefunksjoner for sikkerhet og sub-prosesser +# --------------------------------------------------------------------------- +def _is_allowed_remote(url: str) -> bool: + """ + Validerer at en remote URL peker til det tillatte Gitea-repoet, + uavhengig av protokoll (ssh, https). + """ + try: + if url.startswith("git@"): + host_part, path_part = url[4:].split(":", 1) + host = host_part + path = path_part.replace(".git", "") + elif url.startswith(("https://", "http://")): + from urllib.parse import urlparse + parsed = urlparse(url) + host = parsed.netloc + path = parsed.path.lstrip("/").replace(".git", "") + else: + return False + return host in ALLOWED_REMOTE_HOSTS and path == ALLOWED_REMOTE_PATH + except Exception: + return False + +def _mask_secrets(text: str) -> str: + """Maskerer kjente secrets og generelle token-mønstre i en tekststreng.""" + masked_text = text + for pattern in GENERIC_SECRET_PATTERNS: + masked_text = pattern.sub("[MASKED_TOKEN]", masked_text) + for pattern in SECRET_MASK_PATTERNS: + masked_text = pattern.sub("[MASKED_SECRET]", masked_text) + return masked_text + +def _validate_path_inside_repo(path_str: str) -> Path: + """ + Validates that a safe relative path is within REPO_ROOT. + """ + # 1. Type and content validation + if not isinstance(path_str, str) or not path_str.strip(): + raise ValueError("Path must be a non-empty string.") + + # 2. Disallowed characters + if any(char in path_str for char in ('\x00', '\r', '\n')): + raise ValueError("Path cannot contain control characters.") + + if '\\' in path_str: + raise ValueError("Path cannot contain backslashes.") + + # 3. Disallow absolute paths (Unix and Windows-like) + if path_str.startswith('/') or re.match(r"^[a-zA-Z]:", path_str): + raise ValueError("Path cannot be absolute.") + + # 4. Segment validation on the original path string + segments = path_str.split('/') + if any(segment in {'.', '..', '.git'} for segment in segments): + raise ValueError("Path contains a disallowed segment ('.', '..', or '.git').") + + # 5. Final resolution and boundary check. + prospective_path = (REPO_ROOT / path_str).resolve() + + if not prospective_path.is_relative_to(REPO_ROOT): + raise ValueError("Path resolves outside the repository root.") + + return prospective_path + +def _run_git(args: list[str], timeout: int, check: bool = True) -> subprocess.CompletedProcess: + for arg in args: + if "\n" in arg or "\r" in arg or "\x00" in arg: + raise ValueError(f"Ugyldig argument (inneholder newline/null): {arg!r}") + try: + command = ["git"] + args + result = subprocess.run( + command, cwd=REPO_ROOT, capture_output=True, text=True, timeout=timeout, check=check + ) + result.stdout = _mask_secrets(result.stdout) + result.stderr = _mask_secrets(result.stderr) + return result + except FileNotFoundError: + raise RuntimeError("Git-kommandoen ble ikke funnet. Er Git installert og i PATH?") + except subprocess.TimeoutExpired as e: + raise RuntimeError(f"Git-kommandoen timet ut etter {timeout} sekunder.") + except subprocess.CalledProcessError as e: + e.stdout = _mask_secrets(e.stdout) + e.stderr = _mask_secrets(e.stderr) + raise RuntimeError(f"Git-kommando feilet med exit code {e.returncode}:\n{e.stderr}") + except Exception as e: + raise RuntimeError(f"En uventet feil oppstod under kjøring av Git: {e}") + +# --------------------------------------------------------------------------- +# Read-only Git Tools +# --------------------------------------------------------------------------- +async def get_git_status(p: dict) -> dict: + """Henter status for Git-repoet (porcelain-format).""" + result = _run_git(["status", "--porcelain"], timeout=GIT_TIMEOUT_READ) + return {"status": "clean" if not result.stdout else "dirty", "details": result.stdout} + +async def get_git_branch(p: dict) -> dict: + """Lister alle lokale og remote branches og viser den nåværende branchen.""" + current_branch_res = _run_git(["branch", "--show-current"], timeout=GIT_TIMEOUT_READ) + all_branches_res = _run_git(["branch", "-a"], timeout=GIT_TIMEOUT_READ) + return { + "current_branch": current_branch_res.stdout.strip(), + "all_branches": all_branches_res.stdout.strip().split("\n") + } + +async def get_git_log(p: dict) -> dict: + """Henter Git-loggen (oneline-format).""" + limit = p.get("limit", 10) + if not isinstance(limit, int) or not 1 <= limit <= 100: + limit = 10 + result = _run_git(["log", "--oneline", "-n", str(limit)], timeout=GIT_TIMEOUT_READ) + return {"log": result.stdout.strip().split("\n")} + +async def get_git_diff(p: dict) -> dict: + """Viser diff for spesifiserte filer, eller for alle endringer hvis ingen filer er angitt.""" + paths = p.get("paths") + args = ["diff"] + if paths: + validated_paths = [_validate_path_inside_repo(path).relative_to(REPO_ROOT) for path in paths] + args.extend([str(p) for p in validated_paths]) + result = _run_git(args, timeout=GIT_TIMEOUT_READ, check=False) + return {"diff": result.stdout} + +async def read_repo_file(p: dict) -> dict: + """Leser innholdet i en spesifikk fil i repoet, med masking av secrets.""" + path_str = p.get("path") + if not path_str: raise ValueError("Parameter 'path' er påkrevd.") + file_path = _validate_path_inside_repo(path_str) + if not file_path.is_file(): + raise FileNotFoundError(f"Filen ble ikke funnet: {path_str}") + content = file_path.read_text(encoding="utf-8") + content = _mask_secrets(content) + start = p.get("start_line") + end = p.get("end_line") + if start and end and isinstance(start, int) and isinstance(end, int) and start > 0 and end >= start: + lines = content.splitlines() + return {"path": path_str, "content": "\n".join(lines[start-1:end])} + return {"path": path_str, "content": content} + +async def list_repo_files(p: dict) -> dict: + """Lister filer og mapper i en gitt sti i repoet.""" + recursive = p.get("recursive", False) + + if "path" in p: + path_str = p["path"] + # An explicitly provided path must be strictly validated. + start_path = _validate_path_inside_repo(path_str) + else: + # If the 'path' key is missing entirely, default to the repo root. + start_path = REPO_ROOT + path_str = "." # Keep original behavior for the return value + + if recursive: + files = [str(f.relative_to(REPO_ROOT)) for f in start_path.rglob('*')] + else: + files = [str(f.relative_to(REPO_ROOT)) for f in start_path.iterdir()] + return {"path": path_str, "files": files} + +# --------------------------------------------------------------------------- +# Write Git Tools +# --------------------------------------------------------------------------- +async def preview_git_change(p: dict) -> dict: + """Forhåndsviser en Git-endring (status, diff, HEAD) før en eventuell commit.""" + paths = p.get("paths") + expected_head_sha = p.get("expected_head_sha") + if not paths or not isinstance(paths, list): + raise ValueError("'paths' må være en liste med filstier.") + if not expected_head_sha: + raise ValueError("'expected_head_sha' er påkrevd for å unngå race conditions.") + + validated_paths = [str(_validate_path_inside_repo(path).relative_to(REPO_ROOT)) for path in paths] + + head_res = _run_git(["rev-parse", "HEAD"], timeout=GIT_TIMEOUT_READ) + current_head_sha = head_res.stdout.strip() + if current_head_sha != expected_head_sha: + raise ValueError(f"HEAD mismatch. Forventet: {expected_head_sha}, Faktisk: {current_head_sha}") + + status_res = _run_git(["status", "--porcelain"], timeout=GIT_TIMEOUT_READ) + untracked_changes = [] + for line in status_res.stdout.strip().splitlines(): + changed_file = line[3:] + if changed_file not in validated_paths: + untracked_changes.append(line) + if untracked_changes: + raise RuntimeError(f"Repoet har endringer utenfor de angitte stiene: {untracked_changes}") + + diff_res = _run_git(["diff"] + validated_paths, timeout=GIT_TIMEOUT_READ, check=False) + + return { + "status": "ok", + "current_head_sha": current_head_sha, + "diff": diff_res.stdout, + "message": "Forhåndsvisning generert. Klar for commit_and_push_files." + } + +async def commit_and_push_files(p: dict) -> dict: + """Commiter og pusher filer etter validering. Krever eksplisitt godkjenning i klienten.""" + branch, paths, msg, head_sha = p.get("branch"), p.get("paths"), p.get("commit_message"), p.get("expected_head_sha") + if not all([branch, paths, msg, head_sha]): + raise ValueError("Alle parametere (branch, paths, commit_message, expected_head_sha) er påkrevde.") + + validated_paths = [str(_validate_path_inside_repo(path).relative_to(REPO_ROOT)) for path in paths] + + await preview_git_change({"paths": validated_paths, "expected_head_sha": head_sha}) + + if not re.match(r"^[a-zA-Z0-9/_-]+$", branch): + raise ValueError(f"Ugyldig branch-navn: {branch}") + + remote_url = _run_git(["remote", "get-url", "origin"], timeout=GIT_TIMEOUT_READ).stdout.strip() + if not _is_allowed_remote(remote_url): + raise RuntimeError(f"Push nektet: Remote URL '{remote_url}' er ikke tillatt.") + + _run_git(["add"] + validated_paths, timeout=GIT_TIMEOUT_WRITE) + + diff_cached_res = _run_git(["diff", "--cached", "--quiet"], timeout=GIT_TIMEOUT_WRITE, check=False) + if diff_cached_res.returncode == 0: + raise RuntimeError("Ingen endringer å committe. Avbryter.") + + commit_res = _run_git(["commit", "-m", msg], timeout=GIT_TIMEOUT_WRITE) + new_commit_sha = _run_git(["rev-parse", "HEAD"], timeout=GIT_TIMEOUT_READ).stdout.strip() + + push_refspec = f"HEAD:refs/heads/{branch}" + push_res = _run_git(["push", "origin", push_refspec], timeout=GIT_TIMEOUT_WRITE) + + return { + "status": "success", + "commit_sha": new_commit_sha, + "old_head_sha": head_sha, + "push_output": push_res.stdout, + "message": f"Filer committet og pushet til branch '{branch}'." + } # --------------------------------------------------------------------------- # Google Workspace Helpers @@ -360,7 +635,6 @@ async def get_health(p): return await _agent_get("/health") async def get_build_status(p): return await _agent_get("/opax/build-status") async def get_state(p): return await _agent_get("/state") async def get_telemetry(p): return await _agent_get("/telemetry/history") -async def run_terminal(p): return await _agent_post("/terminal/exec", {"cmd": p.get("command", p.get("cmd", "help"))}) async def tui_command(p): return await _agent_post("/tui-command", p) # --- Uendrede funksjoner --- @@ -443,6 +717,18 @@ async def push_file(p): # --------------------------------------------------------------------------- TOOLS = { + # --- Read-only Git Tools --- + "get_git_status": (get_git_status, "Henter status for Git-repoet (porcelain-format)", {}), + "get_git_branch": (get_git_branch, "Lister branches og viser nåværende branch", {}), + "get_git_log": (get_git_log, "Henter Git-loggen", {"type":"object", "properties": {"limit": {"type": "integer"}}}), + "get_git_diff": (get_git_diff, "Viser diff for endringer", {"type":"object", "properties": {"paths": {"type": "array", "items": {"type": "string"}}}}), + "read_repo_file": (read_repo_file, "Leser en fil fra repoet", {"type":"object", "properties": {"path": {"type": "string"}, "start_line": {"type": "integer"}, "end_line": {"type": "integer"}}, "required": ["path"]}), + "list_repo_files": (list_repo_files, "Lister filer og mapper i repoet", {"type":"object", "properties": {"path": {"type": "string"}, "recursive": {"type": "boolean"}}}), + + # --- Write Git Tools (krever godkjenning) --- + "preview_git_change": (preview_git_change, "Forhåndsviser en Git-endring før commit", {"type":"object", "properties": {"paths": {"type": "array", "items": {"type": "string"}}, "expected_head_sha": {"type": "string"}}, "required": ["paths", "expected_head_sha"]}), + "commit_and_push_files": (commit_and_push_files, "Commiter og pusher filer til Git", {"type":"object", "properties": {"branch": {"type": "string"}, "paths": {"type": "array", "items": {"type": "string"}}, "commit_message": {"type": "string"}, "expected_head_sha": {"type": "string"}}, "required": ["branch", "paths", "commit_message", "expected_head_sha"]}), + # Billing "get_billing_summary": (get_billing_summary, "Hent billing-sammendrag for OPAX", {}), "get_billing_forecast": ( @@ -504,7 +790,7 @@ TOOLS = { "trigger_build": (trigger_build, "Trigger en Cloud Build manuelt", {"type":"object","properties":{"repo":{"type":"string"},"branch":{"type":"string"},"config":{"type":"string"}}}), "get_state": (get_state, "Hent platform-tilstand", {}), "list_customers": (list_customers, "List alle kunder (alias for get_state)", {}), - "run_terminal": (run_terminal, "Kjør terminalkommando på VM", {"type":"object","properties":{"command":{"type":"string"}}}), + # Gitea / VCS "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"}},"required":["path"]}), @@ -535,6 +821,13 @@ TOOLS = { ), } +# --- Validate allowlist on startup --- +# Fail closed if the configured web agent allowlist contains tools that +# do not exist in the main tool registry. +for tool in WEB_AGENT_ALLOWED_TOOLS: + if tool not in TOOLS: + raise RuntimeError("Configuration error: Invalid web-agent tool policy.") + # --------------------------------------------------------------------------- # JSON-RPC Endpoint # --------------------------------------------------------------------------- @@ -559,16 +852,34 @@ async def mcp_handler(request: Request): for name, (_, desc, schema) in TOOLS.items() ]})) if method == "tools/call": - tool_name, tool_args = params.get("name") or params.get("tool"), params.get("arguments", params.get("params", {})) + tool_name = params.get("name") or params.get("tool") + + if not tool_name or tool_name not in WEB_AGENT_ALLOWED_TOOLS: + return JSONResponse( + _jsonrpc_err(req_id, -32601, "Tool not allowed or not found."), + ) + entry = TOOLS.get(tool_name) - if not entry: return JSONResponse(_jsonrpc_err(req_id, -32601, f"Unknown tool: {tool_name}")) + if not entry: return JSONResponse(_jsonrpc_err(req_id, -32601, "Tool not allowed or not found.")) handler, _, _ = entry try: + tool_args = params.get("arguments", params.get("params", {})) result = await handler(tool_args) return JSONResponse(_jsonrpc_ok(req_id, {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}]})) except Exception as e: - logger.error(f"[MCP HANDLER ERROR] tool={tool_name} {type(e).__name__}: {e}", exc_info=True) - return JSONResponse(_jsonrpc_err(req_id, -32000, str(e))) + logger.error( + "[MCP HANDLER ERROR] tool=%s error=%s", + tool_name, + type(e).__name__, + exc_info=True, + ) + return JSONResponse( + _jsonrpc_err( + req_id, + -32000, + "An internal server error occurred.", + ), + ) if method.startswith("notifications/"): return JSONResponse(status_code=202, content={}) return JSONResponse(_jsonrpc_err(req_id, -32601, f"Method not found: {method}"), status_code=404) diff --git a/opax-mcp/startup.sh b/opax-mcp/startup.sh new file mode 100755 index 0000000..555d197 --- /dev/null +++ b/opax-mcp/startup.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -e + +GIT_REPO_URL="http://oauth2:${GITEA_TOKEN}@git.vauco.no/chris/OSVauco.git" +CLONE_DIR="/app/OSVauco" + +echo "Cloning repository into ${CLONE_DIR}..." +git clone "${GIT_REPO_URL}" "${CLONE_DIR}" + +cd "${CLONE_DIR}" + +echo "Starting Uvicorn server..." +exec uvicorn server:app --host 0.0.0.0 --port 8080 --app-dir /app diff --git a/opax-mcp/test_mcp_tools.py b/opax-mcp/test_mcp_tools.py new file mode 100644 index 0000000..1cda403 --- /dev/null +++ b/opax-mcp/test_mcp_tools.py @@ -0,0 +1,344 @@ +import os +import re +import pytest +import subprocess +import tempfile +from pathlib import Path +from unittest.mock import patch, MagicMock, ANY, AsyncMock + +import json +from httpx import ASGITransport, AsyncClient + +# Importer alt som skal testes +from server import app, TOOLS, WEB_AGENT_ALLOWED_TOOLS +from server import ( + get_git_status, + get_git_branch, + get_git_log, + get_git_diff, + read_repo_file, + list_repo_files, + preview_git_change, + commit_and_push_files, + _validate_path_inside_repo, + _is_allowed_remote, + _mask_secrets, + _run_git +) + +# --- Fixtures --- + +@pytest.fixture +def mock_repo_root(monkeypatch): + """Setter REPO_ROOT til en trygg, midlertidig mappe for unit-tester.""" + with tempfile.TemporaryDirectory() as tmpdir: + monkeypatch.setattr("server.REPO_ROOT", Path(tmpdir)) + yield Path(tmpdir) + +@pytest.fixture +def isolated_git_repo(monkeypatch): + """Setter opp et isolert Git-miljø og patcher REPO_ROOT for integrasjonstester.""" + with tempfile.TemporaryDirectory() as tmpdir_str: + tmpdir = Path(tmpdir_str) + remote_repo_path = tmpdir / "remote.git" + local_repo_path = tmpdir / "local" + + subprocess.run(["git", "init", "--bare", str(remote_repo_path)], check=True) + subprocess.run(["git", "clone", str(remote_repo_path), str(local_repo_path)], check=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=local_repo_path, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=local_repo_path, check=True) + # Add an initial commit so the repo is not empty + (local_repo_path / ".gitkeep").touch() + subprocess.run(["git", "add", ".gitkeep"], cwd=local_repo_path, check=True) + subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=local_repo_path, check=True) + branch_name = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=local_repo_path, check=True, capture_output=True, text=True).stdout.strip() + subprocess.run(["git", "push", "origin", branch_name], cwd=local_repo_path, check=True) + + monkeypatch.setattr("server.REPO_ROOT", local_repo_path) + yield local_repo_path, remote_repo_path + +@pytest.fixture +def mcp_secret(monkeypatch): + """Sets the MCP_SECRET environment variable for testing.""" + secret = "test-secret-for-dispatch" + monkeypatch.setenv("MCP_SECRET", secret) + yield secret + +# --- Unit-tester for Hjelpefunksjoner --- + +def test_validate_path_inside_repo_success(mock_repo_root): + (mock_repo_root / "testdir").mkdir() + (mock_repo_root / "testdir" / "test.txt").touch() + assert _validate_path_inside_repo("testdir/test.txt") == (mock_repo_root / "testdir" / "test.txt").resolve() + +@pytest.mark.parametrize( + "invalid_path, match_error", + [ + (None, "Path must be a non-empty string."), + (123, "Path must be a non-empty string."), + ("", "Path must be a non-empty string."), + (" ", "Path must be a non-empty string."), + ('a\x00b', "Path cannot contain control characters."), + ('a\rb', "Path cannot contain control characters."), + ('a\nb', "Path cannot contain control characters."), + ('a\\b', "Path cannot contain backslashes."), + ("/etc/passwd", "Path cannot be absolute."), + ("C:boot.ini", "Path cannot be absolute."), + ("//server/share", "Path cannot be absolute."), + ("./a", "Path contains a disallowed segment"), + ("a/./b", "Path contains a disallowed segment"), + ("../a", "Path contains a disallowed segment"), + ("a/../b", "Path contains a disallowed segment"), + (".git/config", "Path contains a disallowed segment"), + ("a/.git/b", "Path contains a disallowed segment"), + ], + ids=[ + "non-string", "non-string-int", "empty", "whitespace", + "nul-char", "cr-char", "lf-char", "backslash", + "absolute-unix", "windows-drive-prefix", "unc", + "dot-segment", "dot-segment-internal", "traversal", "traversal-internal", + "dot-git-segment", "dot-git-segment-internal", + ] +) +def test_validate_path_rejects_invalid_inputs(mock_repo_root, invalid_path, match_error): + with pytest.raises(ValueError, match=re.escape(match_error)): + _validate_path_inside_repo(invalid_path) + + +def test_validate_path_rejects_symlink_escape(mock_repo_root): + """Tests that a symlink pointing outside the repository root is rejected.""" + outside_file = mock_repo_root.parent / "sensitive_file.txt" + outside_file.write_text("secret") + + symlink_in_repo = mock_repo_root / "link_to_secret" + + try: + os.symlink(outside_file, symlink_in_repo) + except (OSError, NotImplementedError) as e: + pytest.skip(f"Symlink creation skipped: {e}") + + with pytest.raises(ValueError, match=re.escape("Path resolves outside the repository root.")): + _validate_path_inside_repo("link_to_secret") + +@pytest.mark.parametrize("url, expected", [ + ("https://git.vauco.no/chris/OSVauco.git", True), + ("git@git.vauco.no:chris/OSVauco.git", True), + ("https://github.com/evil/repo.git", False), +]) +def test_is_allowed_remote(url, expected): + assert _is_allowed_remote(url) is expected + +def test_mask_secrets(mock_repo_root): + from server import SECRET_MASK_PATTERNS, _mask_secrets + test_secret = "TEST_SECRET_VALUE_123" + test_pattern = re.compile(re.escape(test_secret)) + SECRET_MASK_PATTERNS.append(test_pattern) + try: + text = f"Token: gitea_abc123def456, Secret: {test_secret}" + masked = _mask_secrets(text) + assert "[MASKED_TOKEN]" in masked + assert "[MASKED_SECRET]" in masked + assert test_secret not in masked + finally: + SECRET_MASK_PATTERNS.remove(test_pattern) + +@patch("server.subprocess.run") +def test_run_git_success(mock_run, mock_repo_root): + mock_run.return_value = MagicMock(stdout="OK", stderr="", returncode=0) + _run_git(["status", "--short"], timeout=30) + mock_run.assert_called_once_with( + ["git", "status", "--short"], + cwd=mock_repo_root, + capture_output=True, text=True, timeout=30, check=True + ) + +def test_run_git_invalid_args(): + with pytest.raises(ValueError): + _run_git(["status\nls"], timeout=30) + +# --- Unit-tester for Read-only Verktøy --- + +@pytest.mark.asyncio +@patch("server._run_git") +async def test_get_git_status_clean(mock_run_git): + mock_run_git.return_value = MagicMock(stdout="") + result = await get_git_status({}) + assert result == {"status": "clean", "details": ""} + +@pytest.mark.asyncio +@patch("server._run_git") +async def test_get_git_branch(mock_run_git): + mock_run_git.side_effect = [MagicMock(stdout="main\n"), MagicMock(stdout="* main\n")] + result = await get_git_branch({}) + assert result["current_branch"] == "main" + +@pytest.mark.asyncio +async def test_list_repo_files_no_path_uses_root(mock_repo_root): + """Tests that calling list_repo_files with no 'path' argument lists the root.""" + (mock_repo_root / "dir1").mkdir() + (mock_repo_root / "file.txt").touch() + + result = await list_repo_files({}) + + assert result["path"] == "." + assert set(result["files"]) == {"dir1", "file.txt"} + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "invalid_path_value", + [".", "", None], + ids=["explicit-dot", "empty-string", "none-value"], +) +async def test_list_repo_files_rejects_explicit_invalid_paths( + mock_repo_root, + invalid_path_value, +): + """Tests that list_repo_files rejects invalid explicit path values.""" + with pytest.raises(ValueError): + await list_repo_files({"path": invalid_path_value}) + +@pytest.mark.asyncio +@patch("server._mask_secrets") +async def test_read_repo_file(mock_mask_secrets, mock_repo_root): + (mock_repo_root / "my_file.txt").write_text("secret content") + mock_mask_secrets.return_value = "masked" + result = await read_repo_file({"path": "my_file.txt"}) + assert result["content"] == "masked" + mock_mask_secrets.assert_called_once_with("secret content") + +@pytest.mark.asyncio +async def test_read_repo_file_not_found(mock_repo_root): + with pytest.raises(FileNotFoundError): + await read_repo_file({"path": "non_existent.txt"}) + +# --- Integrasjonstester for Skrive-flyt --- + +@pytest.mark.asyncio +async def test_e2e_commit_flow_success(isolated_git_repo, monkeypatch): + local_repo_path, remote_repo_path = isolated_git_repo + + remote_url = subprocess.run( + ["git", "remote", "get-url", "origin"], + cwd=local_repo_path, check=True, capture_output=True, text=True + ).stdout.strip() + monkeypatch.setattr("server._is_allowed_remote", lambda url: url == remote_url) + + test_file = local_repo_path / "test.txt" + test_file.write_text("Hello, Git!") + head_sha_before = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=local_repo_path, check=True, capture_output=True, text=True + ).stdout.strip() + + preview_result = await preview_git_change({"paths": ["test.txt"], "expected_head_sha": head_sha_before}) + assert preview_result["status"] == "ok" + + branch_name = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=local_repo_path, check=True, capture_output=True, text=True).stdout.strip() + commit_result = await commit_and_push_files({ + "branch": branch_name, "paths": ["test.txt"], + "commit_message": "Test commit", "expected_head_sha": head_sha_before + }) + assert commit_result["status"] == "success" + + with tempfile.TemporaryDirectory() as clone_dir: + subprocess.run(["git", "clone", str(remote_repo_path), clone_dir], check=True) + assert (Path(clone_dir) / "test.txt").read_text() == "Hello, Git!" + +@pytest.mark.asyncio +async def test_commit_flow_unrelated_changes_fails(isolated_git_repo): + """Tester at preview feiler hvis det finnes urelaterte endringer.""" + local_repo_path, _ = isolated_git_repo + (local_repo_path / "file_a.txt").write_text("a") + (local_repo_path / "file_b.txt").write_text("b") + + # Stage one file to make the repo dirty + subprocess.run(["git", "add", "file_a.txt"], cwd=local_repo_path, check=True) + + head_sha = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=local_repo_path, check=True, capture_output=True, text=True + ).stdout.strip() + + with pytest.raises(RuntimeError, match="Repoet har endringer utenfor de angitte stiene"): + await preview_git_change({"paths": ["file_a.txt"], "expected_head_sha": head_sha}) + +# --- Tests for MCP Handler Dispatch Policy --- + +@pytest.mark.asyncio +class TestMcpHandlerDispatch: + + async def make_rpc_call(self, tool_name, token, params=None): + headers = {"Authorization": f"Bearer {token}"} + request_body = { + "jsonrpc": "2.0", + "id": "test-id-123", + "method": "tools/call", + "params": {"name": tool_name, "arguments": params or {}} + } + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + response = await client.post("/", json=request_body, headers=headers) + return response + + @pytest.mark.parametrize( + "blocked_tool", + [ + "commit_and_push_files", + "send_email", + "trigger_build", + "set_billing_budget", + "create_email_alias", + "get_emails", + "preview_git_change", + ] + ) + async def test_rejects_existing_but_blocked_tools(self, monkeypatch, mcp_secret, blocked_tool): + assert blocked_tool in TOOLS + assert blocked_tool not in WEB_AGENT_ALLOWED_TOOLS + + mock_tool_func = AsyncMock() + _, description, schema = TOOLS[blocked_tool] + monkeypatch.setitem(TOOLS, blocked_tool, (mock_tool_func, description, schema)) + + response = await self.make_rpc_call(blocked_tool, mcp_secret) + + assert response.status_code == 200 + json_res = response.json() + assert json_res["error"]["code"] == -32601 + assert json_res["error"]["message"] == "Tool not allowed or not found." + mock_tool_func.assert_not_awaited() + + async def test_rejects_unknown_tool(self, mcp_secret): + response = await self.make_rpc_call("non_existent_tool", mcp_secret) + + assert response.status_code == 200 + json_res = response.json() + assert json_res["error"]["code"] == -32601 + assert json_res["error"]["message"] == "Tool not allowed or not found." + + @pytest.mark.parametrize( + "allowed_tool", sorted(WEB_AGENT_ALLOWED_TOOLS) + ) + async def test_allows_and_dispatches_web_agent_tools(self, monkeypatch, mcp_secret, allowed_tool): + mock_tool_func = AsyncMock(return_value={"status": "mocked_success"}) + _, description, schema = TOOLS[allowed_tool] + monkeypatch.setitem(TOOLS, allowed_tool, (mock_tool_func, description, schema)) + + test_args = {"param": "value"} + response = await self.make_rpc_call(allowed_tool, mcp_secret, params=test_args) + + assert response.status_code == 200 + json_res = response.json() + assert "result" in json_res + inner_result = json.loads(json_res["result"]["content"][0]["text"]) + assert inner_result["status"] == "mocked_success" + + mock_tool_func.assert_awaited_once_with(test_args) + + async def test_auth_is_checked_before_policy(self, monkeypatch, mcp_secret): + mock_tool_func = AsyncMock() + allowed_tool = "get_health" + monkeypatch.setitem(TOOLS, allowed_tool, (mock_tool_func, "", {})) + + response = await self.make_rpc_call(allowed_tool, "wrong-secret") + + assert response.status_code == 401 + mock_tool_func.assert_not_awaited() diff --git a/opax-web/.gitignore b/opax-web/.gitignore new file mode 100644 index 0000000..ddfa183 --- /dev/null +++ b/opax-web/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +dist/ + +backend/.env diff --git a/opax-web/frontend/index.html b/opax-web/frontend/index.html new file mode 100644 index 0000000..c41b5d7 --- /dev/null +++ b/opax-web/frontend/index.html @@ -0,0 +1 @@ +
diff --git a/opax-web/frontend/src/App.tsx b/opax-web/frontend/src/App.tsx new file mode 100644 index 0000000..7b485f2 --- /dev/null +++ b/opax-web/frontend/src/App.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef, useState } from 'react'; +import { io, Socket } from 'socket.io-client'; + +const tools: string[] = [ + 'get_health', + 'get_build_status', + 'get_state', + 'get_telemetry', + 'get_git_status', + 'get_git_branch', + 'get_git_log', + 'get_git_diff', + 'list_repo_files', + 'read_repo_file', +]; + +interface User { + email: string; + name?: string; +} + +export default function App() { + const [user, setUser] = useState{output.join('\n')}
+
+