diff --git a/services/cassandra/hermes-agent-rbac.yaml b/services/cassandra/hermes-agent-rbac.yaml new file mode 100644 index 000000000..8046a5a41 --- /dev/null +++ b/services/cassandra/hermes-agent-rbac.yaml @@ -0,0 +1,52 @@ +# services/cassandra/hermes-agent-rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: hermes-agent-readonly + namespace: cassandra +rules: + - apiGroups: [""] + resources: + - configmaps + - endpoints + - events + - persistentvolumeclaims + - pods + - pods/log + - replicationcontrollers + - services + verbs: ["get", "list", "watch"] + - apiGroups: ["events.k8s.io"] + resources: ["events"] + verbs: ["get", "list", "watch"] + - apiGroups: ["apps"] + resources: + - daemonsets + - deployments + - replicasets + - statefulsets + verbs: ["get", "list", "watch"] + - apiGroups: ["batch"] + resources: + - cronjobs + - jobs + verbs: ["get", "list", "watch"] + - apiGroups: ["networking.k8s.io"] + resources: + - ingresses + - networkpolicies + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: hermes-agent-readonly + namespace: cassandra +subjects: + - kind: ServiceAccount + name: hermes-agent + namespace: hermes +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: hermes-agent-readonly diff --git a/services/cassandra/kustomization.yaml b/services/cassandra/kustomization.yaml index 61c33ff86..13a8df0cb 100644 --- a/services/cassandra/kustomization.yaml +++ b/services/cassandra/kustomization.yaml @@ -11,6 +11,7 @@ resources: - limitrange.yaml - configmap.yaml - rbac.yaml + - hermes-agent-rbac.yaml - artifacts-pvc.yaml - postgres-service.yaml - postgres-statefulset.yaml diff --git a/services/hermes/agent-configmap.yaml b/services/hermes/agent-configmap.yaml index 4f9cb8934..138da181c 100644 --- a/services/hermes/agent-configmap.yaml +++ b/services/hermes/agent-configmap.yaml @@ -215,9 +215,11 @@ data: objective's difficulty warrants it; otherwise synthesize at the original effort. - This pod has no Kubernetes RBAC. Do not use it for automated test intake or - cluster mutation. Triage belongs at triage.hermes.bstein.dev and changes to - Atlas are delivered through the titan-iac Git/Flux workflow. + This pod has Cassandra-scoped, read-only Kubernetes access for workloads, + logs, events, networking, and rollout diagnosis. It cannot read Secrets, + exec into pods, or mutate the cluster. Triage belongs at + triage.hermes.bstein.dev and all Atlas changes are delivered through the + titan-iac Git/Flux workflow. START-HERE.md: | # Agent Hermes diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index 10c62a412..147d54d0e 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -24,7 +24,7 @@ spec: ai.bstein.dev/execution: Herdr-supervised Codex and Claude Code ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available - ai.bstein.dev/config-rev: "20260809-browser-runtime-complete-resume" + ai.bstein.dev/config-rev: "20260809-cassandra-readonly" vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/role: hermes-agent vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens @@ -88,6 +88,7 @@ spec: /opt/data/home/.claude \ /opt/data/home/.codex \ /opt/data/home/.config/herdr \ + /opt/data/home/.kube \ /opt/data/herdr \ /opt/data/logs \ /opt/data/tools/bin \ @@ -172,6 +173,14 @@ spec: @anthropic-ai/claude-code@2.1.226 touch "${tools}/.cli-versions-0.147.0-2.1.226" fi + kubectl_version="$("${tools}/bin/kubectl" version --client --output=json 2>/dev/null || true)" + case "${kubectl_version}" in *\"gitVersion\":\"v1.33.3\"*) kubectl_ready=1 ;; *) kubectl_ready=0 ;; esac + if [ "${kubectl_ready}" != "1" ]; then + curl -fsSL -o "${tools}/bin/kubectl.tmp" https://dl.k8s.io/release/v1.33.3/bin/linux/arm64/kubectl + printf '%s %s\n' 3d514dbae5dc8c09f773df0ef0f5d449dfad05b3aca5c96b13565f886df345fd "${tools}/bin/kubectl.tmp" | sha256sum -c - + chmod 0755 "${tools}/bin/kubectl.tmp" + mv "${tools}/bin/kubectl.tmp" "${tools}/bin/kubectl" + fi securityContext: allowPrivilegeEscalation: false runAsUser: 10000 @@ -276,6 +285,7 @@ spec: - {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude} - {name: HERDR_CONFIG_PATH, value: /opt/data/home/.config/herdr/config.toml} - {name: HERDR_SOCKET_PATH, value: /opt/data/herdr/herdr.sock} + - {name: KUBECONFIG, value: /opt/data/home/.kube/config} - {name: PATH, value: /opt/data/tools/bin:/opt/data/home/.local/bin:/opt/hermes/.venv/bin:/opt/hermes/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin} - {name: PLAYWRIGHT_BROWSERS_PATH, value: /opt/hermes/.playwright} - {name: AGENT_BROWSER_EXECUTABLE_PATH, value: /opt/hermes/.playwright/chromium_headless_shell-1228/chrome-linux/headless_shell} @@ -292,6 +302,7 @@ spec: - {name: coordinator, mountPath: /opt/coordinator, readOnly: true} - {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py} - {name: coordinator, mountPath: /opt/data/home/.local/bin/herdr-dispatch, subPath: herdr_dispatch.py, readOnly: true} + - {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true} - {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true} startupProbe: tcpSocket: {port: api} @@ -480,6 +491,7 @@ spec: --env HERMES_HOME=/opt/data \ --env HERMES_AUTH_FILE=/shared-auth/auth.json \ --env HOME=/opt/data/home \ + --env KUBECONFIG=/opt/data/home/.kube/config \ --env PYTHONPATH=/opt/hermes \ --env PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright \ --env AGENT_BROWSER_EXECUTABLE_PATH=/opt/hermes/.playwright/chromium_headless_shell-1228/chrome-linux/headless_shell \ @@ -519,6 +531,7 @@ spec: - {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude} - {name: HERDR_CONFIG_PATH, value: /opt/data/home/.config/herdr/config.toml} - {name: HERDR_SOCKET_PATH, value: /opt/data/herdr/herdr.sock} + - {name: KUBECONFIG, value: /opt/data/home/.kube/config} - {name: PYTHONPATH, value: /opt/hermes} - {name: PATH, value: /opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin} - {name: PLAYWRIGHT_BROWSERS_PATH, value: /opt/hermes/.playwright} @@ -536,6 +549,7 @@ spec: - {name: coordinator, mountPath: /opt/coordinator, readOnly: true} - {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py} - {name: coordinator, mountPath: /opt/data/home/.local/bin/herdr-dispatch, subPath: herdr_dispatch.py, readOnly: true} + - {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true} - {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true} resources: requests: {cpu: 50m, memory: 128Mi} @@ -577,6 +591,10 @@ spec: configMap: name: hermes-coordinator defaultMode: 0555 + - name: kubeconfig + configMap: + name: hermes-agent-kubeconfig + defaultMode: 0444 - name: auth-patch emptyDir: {} - name: auto-router-plugin diff --git a/services/hermes/agent-kubeconfig.yaml b/services/hermes/agent-kubeconfig.yaml new file mode 100644 index 000000000..c1b13684f --- /dev/null +++ b/services/hermes/agent-kubeconfig.yaml @@ -0,0 +1,19 @@ +# services/hermes/agent-kubeconfig.yaml +apiVersion: v1 +kind: Config +clusters: + - name: atlas + cluster: + server: https://kubernetes.default.svc + certificate-authority: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt +users: + - name: hermes-agent + user: + tokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token +contexts: + - name: cassandra-readonly + context: + cluster: atlas + user: hermes-agent + namespace: cassandra +current-context: cassandra-readonly diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml index 4e70d9483..64740d87d 100644 --- a/services/hermes/kustomization.yaml +++ b/services/hermes/kustomization.yaml @@ -52,6 +52,12 @@ configMapGenerator: - patch_hermes_auth.py=scripts/patch_hermes_auth.py options: disableNameSuffixHash: true + - name: hermes-agent-kubeconfig + namespace: hermes + files: + - config=agent-kubeconfig.yaml + options: + disableNameSuffixHash: true - name: hermes-auto-router-plugin namespace: hermes files: diff --git a/services/hermes/networkpolicy.yaml b/services/hermes/networkpolicy.yaml index 6eed18bb0..d1bb2c106 100644 --- a/services/hermes/networkpolicy.yaml +++ b/services/hermes/networkpolicy.yaml @@ -108,6 +108,20 @@ spec: app: hermes-model-gate ports: - {protocol: TCP, port: 8080} + - to: + - ipBlock: + cidr: 10.43.0.1/32 + ports: + - {protocol: TCP, port: 443} + - to: + - ipBlock: + cidr: 192.168.22.11/32 + - ipBlock: + cidr: 192.168.22.12/32 + - ipBlock: + cidr: 192.168.22.13/32 + ports: + - {protocol: TCP, port: 6443} - to: - namespaceSelector: matchLabels: diff --git a/testing/tests/test_hermes_herdr.py b/testing/tests/test_hermes_herdr.py index c1427b43f..ad9564e27 100644 --- a/testing/tests/test_hermes_herdr.py +++ b/testing/tests/test_hermes_herdr.py @@ -343,6 +343,53 @@ def test_agent_mounts_auto_router_into_both_hermes_runtimes(): assert volume["configMap"]["name"] == "hermes-auto-router-plugin" +def test_agent_has_cassandra_readonly_kubernetes_context(): + deployment = yaml.safe_load((HERMES / "agent-deployment.yaml").read_text()) + pod = deployment["spec"]["template"]["spec"] + containers = {container["name"]: container for container in pod["containers"]} + + installer = next( + container + for container in pod["initContainers"] + if container["name"] == "install-agent-tools" + ) + install_command = installer["command"][-1] + assert "v1.33.3/bin/linux/arm64/kubectl" in install_command + assert "3d514dbae5dc8c09f773df0ef0f5d449" in install_command + + for name in ("hermes", "herdr-server"): + env = {item["name"]: item["value"] for item in containers[name]["env"]} + assert env["KUBECONFIG"] == "/opt/data/home/.kube/config" + mounts = { + mount["mountPath"]: mount for mount in containers[name]["volumeMounts"] + } + assert mounts["/opt/data/home/.kube/config"]["name"] == "kubeconfig" + assert mounts["/opt/data/home/.kube/config"]["readOnly"] is True + + config = yaml.safe_load((HERMES / "agent-kubeconfig.yaml").read_text()) + assert config["current-context"] == "cassandra-readonly" + assert config["contexts"][0]["context"]["namespace"] == "cassandra" + assert config["users"][0]["user"] == { + "tokenFile": "/var/run/secrets/kubernetes.io/serviceaccount/token" + } + + rbac_path = HERMES.parent / "cassandra" / "hermes-agent-rbac.yaml" + documents = [document for document in yaml.safe_load_all(rbac_path.read_text()) if document] + role = next(document for document in documents if document["kind"] == "Role") + binding = next(document for document in documents if document["kind"] == "RoleBinding") + assert role["metadata"]["namespace"] == "cassandra" + assert binding["metadata"]["namespace"] == "cassandra" + assert binding["subjects"] == [ + {"kind": "ServiceAccount", "name": "hermes-agent", "namespace": "hermes"} + ] + assert "secrets" not in { + resource for rule in role["rules"] for resource in rule["resources"] + } + assert { + verb for rule in role["rules"] for verb in rule["verbs"] + } == {"get", "list", "watch"} + + def test_agent_reconnect_renders_complete_conversation_history(): documents = [ document