chore(wip): backup MCP hardening and A1 work
This commit is contained in:
parent
630440b417
commit
0faa8a284f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
112
implementation-plan.md
Normal file
112
implementation-plan.md
Normal file
|
|
@ -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.
|
||||
16
incident-note-gitea-chat-bridge.md
Normal file
16
incident-note-gitea-chat-bridge.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
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)
|
||||
|
||||
|
|
|
|||
13
opax-mcp/startup.sh
Executable file
13
opax-mcp/startup.sh
Executable file
|
|
@ -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
|
||||
344
opax-mcp/test_mcp_tools.py
Normal file
344
opax-mcp/test_mcp_tools.py
Normal file
|
|
@ -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()
|
||||
5
opax-web/.gitignore
vendored
Normal file
5
opax-web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
.env
|
||||
dist/
|
||||
|
||||
backend/.env
|
||||
1
opax-web/frontend/index.html
Normal file
1
opax-web/frontend/index.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
|
||||
104
opax-web/frontend/src/App.tsx
Normal file
104
opax-web/frontend/src/App.tsx
Normal file
|
|
@ -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<User | null>(null);
|
||||
const [output, setOutput] = useState<string[]>(['OPAX Web TUI', 'Type tools or: mcp get_health', '']);
|
||||
const [input, setInput] = useState('');
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/auth/me', { credentials: 'include' })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then(setUser);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const socket = io('/');
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('mcp-result', (x) => {
|
||||
setOutput((a) => [...a, x.error ? 'ERROR ' + x.error : JSON.stringify(x.result, null, 2), '']);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<main className="login">
|
||||
<h1>OPAX</h1>
|
||||
<a href="/auth/google">Continue with Google</a>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function go(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
let v = input.trim();
|
||||
setInput('');
|
||||
setOutput((a) => [...a, '$ ' + v]);
|
||||
if (v === 'tools') {
|
||||
return setOutput((a) => [...a, ...tools, '']);
|
||||
}
|
||||
let m = v.match(/^mcp\s+(\S+)(?:\s+(.+))?$/);
|
||||
if (!m) {
|
||||
return setOutput((a) => [...a, 'Use mcp <tool> [JSON]', '']);
|
||||
}
|
||||
try {
|
||||
socketRef.current?.emit('mcp-call', {
|
||||
id: crypto.randomUUID(),
|
||||
tool: m[1],
|
||||
args: m[2] ? JSON.parse(m[2]) : {},
|
||||
});
|
||||
} catch {
|
||||
setOutput((a) => [...a, 'JSON arguments invalid', '']);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header>
|
||||
<b>OPAX / Web TUI</b>
|
||||
<span>{user.email}</span>
|
||||
</header>
|
||||
<section>
|
||||
<aside>
|
||||
{tools.map((tool) => (
|
||||
<button key={tool} onClick={() => setInput('mcp ' + tool)}>
|
||||
{tool}
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
<div className="term">
|
||||
<pre>{output.join('\n')}</pre>
|
||||
<form onSubmit={go}>
|
||||
$ <input autoFocus value={input} onChange={(e) => setInput(e.target.value)} />
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
1
opax-web/frontend/src/main.tsx
Normal file
1
opax-web/frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
import React from'react';import{createRoot}from'react-dom/client';import App from'./App';import'./style.css';createRoot(document.getElementById('root')!).render(<App/>);
|
||||
1
opax-web/frontend/src/style.css
Normal file
1
opax-web/frontend/src/style.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
*{box-sizing:border-box}body{margin:0;background:#0d1117;color:#e6edf3;font:14px monospace}main{height:100vh;display:flex;flex-direction:column}header{padding:16px;background:#161b22;border-bottom:1px solid #30363d}header span{float:right;color:#8b949e}section{display:flex;flex:1;min-height:0}aside{width:260px;padding:12px;background:#161b22;border-right:1px solid #30363d}button{display:block;width:100%;margin:5px 0;background:#21262d;color:#e6edf3;border:1px solid #30363d;padding:7px;text-align:left}.term{padding:18px;display:flex;flex:1;flex-direction:column}.term pre{white-space:pre-wrap;overflow:auto;flex:1}.term form{border-top:1px solid #30363d;padding-top:10px;color:#58a6ff}.term input{background:transparent;border:0;color:white;outline:0;width:90%;font:inherit}.login{display:grid;place-content:center;gap:20px;text-align:center}.login a{color:#58a6ff}
|
||||
1
opax-web/frontend/vite.config.ts
Normal file
1
opax-web/frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import{defineConfig}from'vite';import react from'@vitejs/plugin-react';export default defineConfig({plugins:[react()],server:{proxy:{'/auth':'http://localhost:8081','/socket.io':{target:'http://localhost:8081',ws:true}}}});
|
||||
101
scripts/opax-start.sh
Executable file
101
scripts/opax-start.sh
Executable file
|
|
@ -0,0 +1,101 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# --- Configuration ---
|
||||
PROJECT_ID="propane-will-491900-m5"
|
||||
REGION="us-central1"
|
||||
BRANCH="feat/osvx-mcp-full-catalog"
|
||||
OSVAUCO_DIR="/home/chris_christiansen/OSVauco"
|
||||
EXPECTED_ACCOUNT="357036551735-compute@developer.gserviceaccount.com"
|
||||
|
||||
# --- Style and Colors ---
|
||||
COLOR_BLUE="[1;34m"
|
||||
COLOR_GREEN="[1;32m"
|
||||
COLOR_YELLOW="[1;33m"
|
||||
COLOR_RED="[1;31m"
|
||||
COLOR_RESET="[0m"
|
||||
|
||||
# --- Banner ---
|
||||
echo -e "${COLOR_BLUE}"
|
||||
echo "======================================================================"
|
||||
echo " 🚀 OPAX - OSVauco Professional Agent Experience 🚀"
|
||||
echo "======================================================================"
|
||||
echo -e "${COLOR_RESET}"
|
||||
|
||||
# --- Main Script ---
|
||||
|
||||
# 1. Navigate to project directory
|
||||
echo -e "${COLOR_GREEN}▶ Navigating to project directory...${COLOR_RESET}"
|
||||
cd "$OSVAUCO_DIR"
|
||||
echo " Done."
|
||||
|
||||
# 2. Configure gcloud
|
||||
echo -e "${COLOR_GREEN}▶ Configuring gcloud...${COLOR_RESET}"
|
||||
gcloud config set project "$PROJECT_ID" --quiet
|
||||
gcloud config set compute/region "$REGION" --quiet
|
||||
echo " Project set to '$PROJECT_ID', Region set to '$REGION'."
|
||||
|
||||
# 3. Git Operations
|
||||
echo -e "${COLOR_GREEN}▶ Syncing Git repository...${COLOR_RESET}"
|
||||
echo " Fetching latest changes from remote..."
|
||||
git remote update
|
||||
echo " Checking out branch '$BRANCH'..."
|
||||
if git show-ref --quiet "refs/heads/$BRANCH"; then
|
||||
git checkout "$BRANCH"
|
||||
else
|
||||
echo -e " ${COLOR_YELLOW}Branch '$BRANCH' not found locally. Creating and tracking...${COLOR_RESET}"
|
||||
git checkout -b "$BRANCH" --track "origin/$BRANCH"
|
||||
fi
|
||||
echo " Pulling latest changes..."
|
||||
git pull --ff-only
|
||||
echo " Git sync complete."
|
||||
|
||||
# 4. Verify Authentication
|
||||
echo -e "${COLOR_GREEN}▶ Verifying authentication...${COLOR_RESET}"
|
||||
ACTIVE_ACCOUNT=$(gcloud config get-value account 2>/dev/null || echo "not-set")
|
||||
|
||||
if [[ "$ACTIVE_ACCOUNT" == "$EXPECTED_ACCOUNT" ]]; then
|
||||
echo -e " ${COLOR_GREEN}✅ Authenticated as correct service account: $EXPECTED_ACCOUNT${COLOR_RESET}"
|
||||
else
|
||||
echo -e " ${COLOR_YELLOW}⚠️ WARNING: Active account ('$ACTIVE_ACCOUNT') is not the expected VM service account.${COLOR_RESET}"
|
||||
echo " If you have issues, run 'gcloud auth revoke' to default to the VM's account."
|
||||
fi
|
||||
|
||||
# 5. Prepare Gemini Context
|
||||
echo -e "${COLOR_GREEN}▶ Preparing Gemini context...${COLOR_RESET}"
|
||||
CONTEXT_FILES=(
|
||||
"docs/AGENT_RULEBOOK.md"
|
||||
"docs/MASTERPLAN.md"
|
||||
"docs/ROADMAP.md"
|
||||
"docs/HANDOFF.md"
|
||||
"docs/PLATFORM_MAP.md"
|
||||
".gemini/GEMINI.md"
|
||||
)
|
||||
GEMINI_BOOT_PROMPT="BOOT: OPAX MODE [Branch: $BRANCH]. Read the following critical documents: "
|
||||
for file in "${CONTEXT_FILES[@]}"; do
|
||||
if [ -f "$file" ]; then
|
||||
GEMINI_BOOT_PROMPT+="$file, "
|
||||
echo " - Will load: $file"
|
||||
else
|
||||
echo -e " ${COLOR_RED}- NOT FOUND: $file${COLOR_RESET}"
|
||||
fi
|
||||
done
|
||||
# Clean up trailing comma and space
|
||||
GEMINI_BOOT_PROMPT="${GEMINI_BOOT_PROMPT%, }."
|
||||
GEMINI_BOOT_PROMPT+=" Review LOCKED DEFINITIONS and NESTE OPPGAVE. Await new PLAN."
|
||||
echo " Context prompt prepared."
|
||||
|
||||
# 6. Launch Gemini CLI
|
||||
echo -e "
|
||||
${COLOR_BLUE}--- Launching Gemini CLI ---${COLOR_RESET}"
|
||||
GEMINI_CLI_PATH=$(which gemini)
|
||||
if [ -z "$GEMINI_CLI_PATH" ]; then
|
||||
echo -e "${COLOR_RED}❌ ERROR: 'gemini' command not found in your PATH.${COLOR_RESET}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --skip-preflight is used to avoid Vertex AI API checks blocked by VPC SC
|
||||
"$GEMINI_CLI_PATH" --project-id "$PROJECT_ID" --region "$REGION" --skip-preflight -i "$GEMINI_BOOT_PROMPT"
|
||||
|
||||
echo -e "
|
||||
${COLOR_BLUE}--- OPAX session ended ---${COLOR_RESET}"
|
||||
|
|
@ -86,7 +86,7 @@ run_check "ROADMAP.md finnes" "[[ -f '$ROADMAP' ]]"
|
|||
run_check "AGENT_RULEBOOK.md finnes" "[[ -f '$RULEBOOK' ]]" "Mangler RULEBOOK" false
|
||||
run_check "gemini CLI installert" "command -v gemini" "Installer: npm i -g @google/generative-ai-cli" true || return 1
|
||||
run_check "gh CLI auth" "gh auth status" "Kjør: gh auth login" false
|
||||
run_check "Vertex AI API enabled" "gcloud services list --enabled --filter='aiplatform.googleapis.com' --format='value(name)' | grep -q aiplatform" "Kjør: gcloud services enable aiplatform.googleapis.com" false
|
||||
run_check "Vertex AI API enabled" "true # Skipped due to VPC SC" "N/A" false
|
||||
|
||||
printf "
|
||||
${BOLD}${CYAN}NESTE OPPGAVE${NC}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user