jenkins f7b3506a88 hermes(chat): parse large cluster reads before capping the output
The size cap was applied to the raw wire body, so a nodes list (huge
because of status.images) truncated before the image-stripping ran and
came back as a truncation notice. Accept up to 6 MiB on the wire to
parse and clean, then enforce the 384 KiB model-facing cap on the
stripped result - nodes now returns real data. Delivered via the
cluster-read ConfigMap; picked up on the next pod roll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 16:25:21 -03:00

193 lines
7.9 KiB
Python

"""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="🔭",
)