"""Read-only Atlas cluster visibility for chat tenants. One GET-only tool against the in-cluster Kubernetes API using the pod's projected service account. The real security boundary is RBAC (the ``view`` ClusterRole never includes Secrets, so Vault-managed material is structurally invisible); this handler additionally refuses any path that names the secrets resource, refuses every non-read shape, and bounds the response so a huge list cannot flood the model context. """ from __future__ import annotations import json import re import ssl import urllib.error import urllib.parse import urllib.request from pathlib import Path from typing import Any TOKEN_FILE = Path("/var/run/secrets/kubernetes.io/serviceaccount/token") CA_FILE = Path("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt") API_BASE = "https://kubernetes.default.svc" MAX_RESPONSE_BYTES = 384 * 1024 MAX_WIRE_BYTES = 6 * 1024 * 1024 # accept a large response to clean, then cap the model-facing output TIMEOUT_SECONDS = 8 SEGMENT = re.compile(r"^[a-z0-9][a-z0-9.\-]{0,252}$") DENIED_RESOURCES = frozenset({"secrets"}) CLUSTER_READ_SCHEMA = { "name": "cluster_read", "description": ( "Read-only view of the Atlas Kubernetes cluster. List or get " "resources such as pods, deployments, statefulsets, services, nodes, " "namespaces, events, or Flux kustomizations/helmreleases. Read " "access only: nothing can be created, changed, or deleted, and " "Secrets (including everything Vault manages) are not accessible. " "Use group='' for core resources (pods, services, nodes, events), " "group='apps' for deployments/statefulsets, " "group='kustomize.toolkit.fluxcd.io' for kustomizations. Omit " "namespace for cluster-scoped or all-namespace lists; omit name to " "list. Answers come back as compact JSON." ), "parameters": { "type": "object", "properties": { "resource": { "type": "string", "description": "Lowercase plural resource, e.g. pods, deployments, kustomizations", }, "group": { "type": "string", "description": "API group; empty string for core resources", }, "version": { "type": "string", "description": "API version, default v1", }, "namespace": { "type": "string", "description": "Namespace; omit for cluster-scoped or all namespaces", }, "name": { "type": "string", "description": "Object name for a single get; omit to list", }, "label_selector": { "type": "string", "description": "Optional labelSelector for lists", }, }, "required": ["resource"], }, } def _segment(value: Any, what: str) -> str: if not isinstance(value, str) or not SEGMENT.fullmatch(value): raise ValueError(f"{what} is not a valid lowercase Kubernetes name") return value def _build_path(args: dict[str, Any]) -> str: resource = _segment(args.get("resource"), "resource") if resource in DENIED_RESOURCES or "secret" in resource: raise PermissionError("secrets are not readable from chat") group = args.get("group") or "" version = args.get("version") or "v1" if group: base = f"/apis/{_segment(group, 'group')}/{_segment(version, 'version')}" else: base = f"/api/{_segment(version, 'version')}" namespace = args.get("namespace") path = base if namespace: path += f"/namespaces/{_segment(namespace, 'namespace')}" path += f"/{resource}" name = args.get("name") if name: path += f"/{_segment(name, 'name')}" selector = args.get("label_selector") if selector: if not isinstance(selector, str) or len(selector) > 200 or any(c in selector for c in " \n\r?#"): raise ValueError("label_selector is malformed") path += "?" + urllib.parse.urlencode({"labelSelector": selector, "limit": 200}) elif not name: path += "?limit=200" return path def _strip_noise(payload: Any) -> Any: """Drop managedFields and annotations blobs that waste model context.""" if isinstance(payload, dict): payload.pop("managedFields", None) metadata = payload.get("metadata") if isinstance(metadata, dict): metadata.pop("managedFields", None) annotations = metadata.get("annotations") if isinstance(annotations, dict): for key in [k for k in annotations if "last-applied" in k]: annotations.pop(key, None) status = payload.get("status") if isinstance(status, dict) and isinstance(status.get("images"), list): # Node .status.images is a huge blob of every cached image; it # never helps the model and routinely overflows the size cap. status["images"] = f"[{len(status['images'])} cached images omitted]" for value in payload.values(): _strip_noise(value) elif isinstance(payload, list): for value in payload: _strip_noise(value) return payload def _handle_cluster_read(args: dict[str, Any] | None = None, **kwargs: Any) -> str: arguments = dict(args or {}) arguments.update({k: v for k, v in kwargs.items() if k in CLUSTER_READ_SCHEMA["parameters"]["properties"]}) try: path = _build_path(arguments) except (ValueError, PermissionError) as error: return json.dumps({"error": str(error)}) try: token = TOKEN_FILE.read_text().strip() context = ssl.create_default_context(cafile=str(CA_FILE)) request = urllib.request.Request( API_BASE + path, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, method="GET", ) with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS, context=context) as response: raw = response.read(MAX_WIRE_BYTES + 1) except urllib.error.HTTPError as error: detail = error.read(2048).decode("utf-8", "replace") return json.dumps({"error": f"cluster API returned {error.code}", "detail": detail[:400]}) except Exception as error: # noqa: BLE001 - tool result, never an exception into the loop return json.dumps({"error": f"cluster API unavailable: {type(error).__name__}"}) if len(raw) > MAX_WIRE_BYTES: # Genuinely enormous even before cleanup: a truncated wire body is not # parseable, so hand back the readable prefix and ask to narrow. return json.dumps({"truncated": True, "reason": "response exceeded size cap; narrow with namespace/name/label_selector", "partial": raw[: MAX_RESPONSE_BYTES - 96].decode("utf-8", "replace")}) try: # Parse the full response first, then strip heavy fields (e.g. node # image lists) so the model-facing output cap applies to clean data. payload = _strip_noise(json.loads(raw)) except ValueError: return json.dumps({"error": "cluster API returned non-JSON data"}) body = json.dumps(payload, separators=(",", ":")) if len(body) > MAX_RESPONSE_BYTES: return json.dumps({"truncated": True, "reason": "result exceeded size cap after cleanup; narrow the query", "partial": body[: MAX_RESPONSE_BYTES - 96]}) return body def _available() -> bool: return TOKEN_FILE.is_file() and CA_FILE.is_file() def register(ctx: Any) -> None: """Expose the read-only cluster tool to platforms that enable it.""" ctx.register_tool( name="cluster_read", toolset="cluster", schema=CLUSTER_READ_SCHEMA, handler=_handle_cluster_read, check_fn=_available, requires_env=[], is_async=False, description=CLUSTER_READ_SCHEMA["description"], emoji="🔭", )