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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
This commit is contained in:
jenkins 2026-08-24 13:33:48 -03:00
parent aa6465afa8
commit d1225bcab4
9 changed files with 388 additions and 2 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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:

View File

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

View File

@ -0,0 +1,4 @@
name: cluster-read
version: "1"
description: Read-only Atlas cluster visibility for chat; Secrets are structurally excluded.
kind: backend

View File

@ -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 = (

View File

@ -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",

View File

@ -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