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:
parent
aa6465afa8
commit
d1225bcab4
51
services/hermes/chat-cluster-read-rbac.yaml
Normal file
51
services/hermes/chat-cluster-read-rbac.yaml
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
182
services/hermes/plugins/cluster-read/__init__.py
Normal file
182
services/hermes/plugins/cluster-read/__init__.py
Normal 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="🔭",
|
||||
)
|
||||
4
services/hermes/plugins/cluster-read/plugin.yaml
Normal file
4
services/hermes/plugins/cluster-read/plugin.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
name: cluster-read
|
||||
version: "1"
|
||||
description: Read-only Atlas cluster visibility for chat; Secrets are structurally excluded.
|
||||
kind: backend
|
||||
@ -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 = (
|
||||
|
||||
@ -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",
|
||||
|
||||
130
testing/tests/test_hermes_cluster_read_plugin.py
Normal file
130
testing/tests/test_hermes_cluster_read_plugin.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user