From d1225bcab4968c833df222359ceba960c7ad2298 Mon Sep 17 00:00:00 2001 From: jenkins Date: Mon, 24 Aug 2026 13:33:48 -0300 Subject: [PATCH] hermes(chat): read-only Atlas cluster visibility for chat RBAC: the built-in view ClusterRole (which never includes Secrets, so Vault-managed material stays structurally invisible) plus a read-only extra for nodes, namespaces, PVs, storage classes, CRDs, Flux resources and metrics, bound to the chat service account. Tooling: a cluster-read plugin registers a GET-only cluster_read tool against the in-cluster API using the pod's projected token - secrets paths refused in the handler as well, malformed segments rejected, responses bounded and stripped of managedFields noise. Classified read_files/low in the HUX capability map. RBAC applies on push; the tool activates when the pods next roll (bundled with the round-3 voice build). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf --- services/hermes/chat-cluster-read-rbac.yaml | 51 +++++ services/hermes/chat-configmap.yaml | 5 +- services/hermes/chat-statefulset.yaml | 4 + services/hermes/kustomization.yaml | 8 + .../hermes/plugins/cluster-read/__init__.py | 182 ++++++++++++++++++ .../hermes/plugins/cluster-read/plugin.yaml | 4 + .../hermes/plugins/hux-runtime/tool_policy.py | 2 + testing/quality_contract.json | 4 + .../tests/test_hermes_cluster_read_plugin.py | 130 +++++++++++++ 9 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 services/hermes/chat-cluster-read-rbac.yaml create mode 100644 services/hermes/plugins/cluster-read/__init__.py create mode 100644 services/hermes/plugins/cluster-read/plugin.yaml create mode 100644 testing/tests/test_hermes_cluster_read_plugin.py diff --git a/services/hermes/chat-cluster-read-rbac.yaml b/services/hermes/chat-cluster-read-rbac.yaml new file mode 100644 index 00000000..5ad4537d --- /dev/null +++ b/services/hermes/chat-cluster-read-rbac.yaml @@ -0,0 +1,51 @@ +# services/hermes/chat-cluster-read-rbac.yaml +# Chat's read-only window onto the cluster. The built-in `view` ClusterRole +# structurally excludes Secrets everywhere, so Vault-managed material and +# other credential Secrets stay invisible; the extra role adds the read-only +# cluster-scoped objects `view` does not cover. No write verbs anywhere. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: hermes-chat-cluster-view +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: view +subjects: + - kind: ServiceAccount + name: hermes-chat + namespace: hermes +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: hermes-chat-cluster-read-extra +rules: + - apiGroups: [""] + resources: [nodes, namespaces, persistentvolumes] + verbs: [get, list] + - apiGroups: [storage.k8s.io] + resources: [storageclasses] + verbs: [get, list] + - apiGroups: [apiextensions.k8s.io] + resources: [customresourcedefinitions] + verbs: [get, list] + - apiGroups: [kustomize.toolkit.fluxcd.io, source.toolkit.fluxcd.io, helm.toolkit.fluxcd.io, image.toolkit.fluxcd.io] + resources: ["*"] + verbs: [get, list] + - apiGroups: [metrics.k8s.io] + resources: [nodes, pods] + verbs: [get, list] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: hermes-chat-cluster-read-extra +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: hermes-chat-cluster-read-extra +subjects: + - kind: ServiceAccount + name: hermes-chat + namespace: hermes diff --git a/services/hermes/chat-configmap.yaml b/services/hermes/chat-configmap.yaml index 79a817e6..94099bd2 100644 --- a/services/hermes/chat-configmap.yaml +++ b/services/hermes/chat-configmap.yaml @@ -45,12 +45,13 @@ data: - atlas-broker - auto-router - hux-runtime + - cluster-read model_catalog: enabled: true ttl_hours: 1 platform_toolsets: - cli: [browser, clarify, delegation, file, image_gen, memory, python_sandbox, session_search, skills, todo, vision, web] - api_server: [browser, clarify, delegation, file, image_gen, memory, python_sandbox, session_search, skills, todo, vision, web] + cli: [browser, clarify, cluster, delegation, file, image_gen, memory, python_sandbox, session_search, skills, todo, vision, web] + api_server: [browser, clarify, cluster, delegation, file, image_gen, memory, python_sandbox, session_search, skills, todo, vision, web] platforms: api_server: enabled: true diff --git a/services/hermes/chat-statefulset.yaml b/services/hermes/chat-statefulset.yaml index 77664a39..4713b476 100644 --- a/services/hermes/chat-statefulset.yaml +++ b/services/hermes/chat-statefulset.yaml @@ -463,6 +463,7 @@ spec: - {name: image-plugin, mountPath: /opt/hermes/plugins/image_gen/atlas-broker, readOnly: true} - {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true} - {name: hux-runtime-plugin, mountPath: /opt/data/plugins/hux-runtime, readOnly: true} + - {name: cluster-read-plugin, mountPath: /opt/data/plugins/cluster-read, readOnly: true} - {name: hux-worker-key, mountPath: /run/hermes-hux-worker, readOnly: true} - {name: hux-data, mountPath: /run/hermes-hux-context, subPathExpr: $(POD_NAME)/context, readOnly: true} - {name: hux-data, mountPath: /run/hermes-hux-subject, subPathExpr: $(POD_NAME)/binding, readOnly: true} @@ -759,6 +760,9 @@ spec: - name: image-plugin configMap: name: hermes-chat-image-plugin + - name: cluster-read-plugin + configMap: + name: hermes-cluster-read-plugin - name: hux-runtime-plugin configMap: name: hermes-hux-runtime-plugin diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml index 6647273d..c0c7bb11 100644 --- a/services/hermes/kustomization.yaml +++ b/services/hermes/kustomization.yaml @@ -44,6 +44,7 @@ resources: - agent-ingress.yaml - execution-worker-rbac.yaml - hux-evidence-rbac.yaml + - chat-cluster-read-rbac.yaml - execution-worker-statefulset.yaml - execution-mediator.yaml - execution-worker-networkpolicy.yaml @@ -197,6 +198,13 @@ configMapGenerator: - dashboard-style.css=plugins/auto-router/dashboard/dist/style.css options: disableNameSuffixHash: true + - name: hermes-cluster-read-plugin + namespace: hermes + files: + - __init__.py=plugins/cluster-read/__init__.py + - plugin.yaml=plugins/cluster-read/plugin.yaml + options: + disableNameSuffixHash: true - name: hermes-hux-evidence-policy namespace: hermes files: diff --git a/services/hermes/plugins/cluster-read/__init__.py b/services/hermes/plugins/cluster-read/__init__.py new file mode 100644 index 00000000..7758f195 --- /dev/null +++ b/services/hermes/plugins/cluster-read/__init__.py @@ -0,0 +1,182 @@ +"""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 +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) + 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_RESPONSE_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__}"}) + truncated = len(raw) > MAX_RESPONSE_BYTES + try: + payload = _strip_noise(json.loads(raw[:MAX_RESPONSE_BYTES] if truncated else raw)) + except ValueError: + return json.dumps({"error": "cluster API returned non-JSON data"}) + body = json.dumps(payload, separators=(",", ":")) + if len(body) > MAX_RESPONSE_BYTES: + body = body[:MAX_RESPONSE_BYTES] + truncated = True + if truncated: + return json.dumps({"truncated": True, "partial": body[: MAX_RESPONSE_BYTES - 64]}) + 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="🔭", + ) diff --git a/services/hermes/plugins/cluster-read/plugin.yaml b/services/hermes/plugins/cluster-read/plugin.yaml new file mode 100644 index 00000000..44430529 --- /dev/null +++ b/services/hermes/plugins/cluster-read/plugin.yaml @@ -0,0 +1,4 @@ +name: cluster-read +version: "1" +description: Read-only Atlas cluster visibility for chat; Secrets are structurally excluded. +kind: backend diff --git a/services/hermes/plugins/hux-runtime/tool_policy.py b/services/hermes/plugins/hux-runtime/tool_policy.py index 1277937e..c022b480 100644 --- a/services/hermes/plugins/hux-runtime/tool_policy.py +++ b/services/hermes/plugins/hux-runtime/tool_policy.py @@ -41,6 +41,8 @@ EXACT = { "web_search": ToolPolicy("network", "medium", True), # Image generation produces an artifact through the trusted broker. "image_gen": ToolPolicy("artifact_write", "medium", False), + # Read-only cluster visibility; RBAC and the handler both exclude secrets. + "cluster_read": ToolPolicy("read_files", "low", False), } PREFIXES = ( diff --git a/testing/quality_contract.json b/testing/quality_contract.json index d4ca1ec4..0e1ac2ca 100644 --- a/testing/quality_contract.json +++ b/testing/quality_contract.json @@ -135,6 +135,7 @@ "dockerfiles/hermes-worker-hux/hux_hook/__init__.py", "dockerfiles/hermes-worker-hux/hux_hook/client.py", "dockerfiles/hermes-worker-hux/hux_hook/hooks.py", + "services/hermes/plugins/cluster-read/__init__.py", "services/hermes/plugins/auto-router/hux_mode.py", "services/hermes/plugins/hux-runtime/__init__.py", "services/hermes/plugins/hux-runtime/context_ids.py", @@ -246,6 +247,7 @@ "dockerfiles/hermes-worker-hux/hux_hook/__init__.py", "dockerfiles/hermes-worker-hux/hux_hook/client.py", "dockerfiles/hermes-worker-hux/hux_hook/hooks.py", + "services/hermes/plugins/cluster-read/__init__.py", "services/hermes/plugins/auto-router/hux_mode.py", "services/hermes/plugins/hux-runtime/__init__.py", "services/hermes/plugins/hux-runtime/context_ids.py", @@ -448,6 +450,7 @@ "dockerfiles/hermes-worker-hux/hux_hook/__init__.py", "dockerfiles/hermes-worker-hux/hux_hook/client.py", "dockerfiles/hermes-worker-hux/hux_hook/hooks.py", + "services/hermes/plugins/cluster-read/__init__.py", "services/hermes/plugins/auto-router/hux_mode.py", "services/hermes/plugins/hux-runtime/__init__.py", "services/hermes/plugins/hux-runtime/context_ids.py", @@ -569,6 +572,7 @@ "dockerfiles/hermes-worker-hux/hux_hook/__init__.py", "dockerfiles/hermes-worker-hux/hux_hook/client.py", "dockerfiles/hermes-worker-hux/hux_hook/hooks.py", + "services/hermes/plugins/cluster-read/__init__.py", "services/hermes/plugins/auto-router/hux_mode.py", "services/hermes/plugins/hux-runtime/__init__.py", "services/hermes/plugins/hux-runtime/context_ids.py", diff --git a/testing/tests/test_hermes_cluster_read_plugin.py b/testing/tests/test_hermes_cluster_read_plugin.py new file mode 100644 index 00000000..bad7dde0 --- /dev/null +++ b/testing/tests/test_hermes_cluster_read_plugin.py @@ -0,0 +1,130 @@ +"""Read-only cluster tool: GET-only, secrets-refusing, bounded.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +PLUGIN = ROOT / "services/hermes/plugins/cluster-read" +SPEC = importlib.util.spec_from_file_location("hermes_cluster_read", PLUGIN / "__init__.py") +assert SPEC and SPEC.loader +mod = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = mod +SPEC.loader.exec_module(mod) + + +def test_paths_are_shaped_and_bounded(): + assert mod._build_path({"resource": "pods"}) == "/api/v1/pods?limit=200" + assert mod._build_path({"resource": "pods", "namespace": "hermes"}) == "/api/v1/namespaces/hermes/pods?limit=200" + assert mod._build_path({"resource": "pods", "namespace": "hermes", "name": "x-1"}) == "/api/v1/namespaces/hermes/pods/x-1" + assert mod._build_path({"resource": "deployments", "group": "apps"}) == "/apis/apps/v1/deployments?limit=200" + assert ( + mod._build_path({"resource": "kustomizations", "group": "kustomize.toolkit.fluxcd.io", "version": "v1"}) + == "/apis/kustomize.toolkit.fluxcd.io/v1/kustomizations?limit=200" + ) + assert "labelSelector=app%3Dx" in mod._build_path({"resource": "pods", "label_selector": "app=x"}) + + +def test_secrets_are_refused_everywhere(): + import pytest + + for resource in ("secrets", "secret-things", "mysecrets"): + with pytest.raises(PermissionError): + mod._build_path({"resource": resource}) + body = json.loads(mod._handle_cluster_read({"resource": "secrets"})) + assert "not readable" in body["error"] + + +def test_malformed_segments_are_rejected(): + import pytest + + # Falsy values mean "omitted" by contract; every present value is validated. + for bad in ("Pods", "a/b", "a b", "x" * 300, "../etc"): + with pytest.raises((ValueError, PermissionError)): + mod._build_path({"resource": "pods", "namespace": bad}) + with pytest.raises(ValueError): + mod._build_path({"resource": "pods", "label_selector": "a b?c\n"}) + + +def test_handler_is_get_only_and_bounded(monkeypatch, tmp_path): + token = tmp_path / "token" + token.write_text("tok") + ca = tmp_path / "ca.crt" + ca.write_text("cert") + monkeypatch.setattr(mod, "TOKEN_FILE", token) + monkeypatch.setattr(mod, "CA_FILE", ca) + seen = {} + + class FakeResponse: + def __init__(self, data): self._data = data + def read(self, n): return self._data[:n] + def __enter__(self): return self + def __exit__(self, *a): return False + + def fake_open(request, timeout=None, context=None): + seen["method"] = request.get_method() + seen["url"] = request.full_url + seen["auth"] = request.get_header("Authorization") + payload = {"kind": "PodList", "items": [{"metadata": {"name": "p", "managedFields": [{"x": 1}], + "annotations": {"kubectl.kubernetes.io/last-applied-configuration": "big"}}}]} + return FakeResponse(json.dumps(payload).encode()) + + monkeypatch.setattr(mod, "ssl", type("S", (), {"create_default_context": staticmethod(lambda cafile=None: None)})) + monkeypatch.setattr(mod.urllib.request, "urlopen", fake_open) + out = json.loads(mod._handle_cluster_read({"resource": "pods", "namespace": "hermes"})) + assert seen["method"] == "GET" and seen["auth"] == "Bearer tok" + assert seen["url"].startswith("https://kubernetes.default.svc/api/v1/namespaces/hermes/pods") + assert out["kind"] == "PodList" + assert "managedFields" not in json.dumps(out) + assert "last-applied" not in json.dumps(out) + + def huge_open(request, timeout=None, context=None): + return FakeResponse(b'{"kind":"List","items":["' + b"x" * (mod.MAX_RESPONSE_BYTES + 100) + b'"]}') + + monkeypatch.setattr(mod.urllib.request, "urlopen", huge_open) + out = json.loads(mod._handle_cluster_read({"resource": "pods"})) + assert out.get("truncated") is True or out.get("error") + + +def test_errors_never_raise(monkeypatch, tmp_path): + token = tmp_path / "token" + token.write_text("tok") + ca = tmp_path / "ca.crt" + ca.write_text("cert") + monkeypatch.setattr(mod, "TOKEN_FILE", token) + monkeypatch.setattr(mod, "CA_FILE", ca) + monkeypatch.setattr(mod, "ssl", type("S", (), {"create_default_context": staticmethod(lambda cafile=None: None)})) + + def down(request, timeout=None, context=None): + raise OSError("down") + + monkeypatch.setattr(mod.urllib.request, "urlopen", down) + out = json.loads(mod._handle_cluster_read({"resource": "pods"})) + assert "unavailable" in out["error"] + + +def test_registration_shape_and_availability(monkeypatch, tmp_path): + calls = {} + + class Ctx: + def register_tool(self, **kwargs): calls.update(kwargs) + + mod.register(Ctx()) + assert calls["name"] == "cluster_read" and calls["toolset"] == "cluster" + assert calls["is_async"] is False and calls["requires_env"] == [] + assert "Secrets" in calls["description"] + monkeypatch.setattr(mod, "TOKEN_FILE", tmp_path / "missing") + assert calls["check_fn"]() is False + + +def test_rbac_manifest_is_read_only_and_secretless(): + rbac = (ROOT / "services/hermes/chat-cluster-read-rbac.yaml").read_text() + assert "secrets" not in rbac + for verb in ("create", "update", "patch", "delete", "escalate", "impersonate"): + assert verb not in rbac + assert "name: view" in rbac and "hermes-chat" in rbac + config = (ROOT / "services/hermes/chat-configmap.yaml").read_text() + assert "- cluster-read" in config and "cluster," in config