diff --git a/services/hermes/agent-configmap.yaml b/services/hermes/agent-configmap.yaml index 5719a163..a5281bf5 100644 --- a/services/hermes/agent-configmap.yaml +++ b/services/hermes/agent-configmap.yaml @@ -274,6 +274,36 @@ data: recovery, and verification, and must be followed by a matching source-of- truth change when they alter desired state. Never expose credentials in chat or logs. Triage belongs at triage.hermes.bstein.dev. + + ## Atlas engineering access + + Atlas repositories are private and canonical at + `https://scm.bstein.dev/atlas/.git`. HTTPS Git authentication is + already supplied through `GIT_ASKPASS`. Verify the remote and cleanly + separate pre-existing changes, create a task branch, run the repository's + tests, use `git push --dry-run` when proving access, and push a real branch + only when the requested implementation is review-ready. Never force-push. + + The terminal PATH contains the pinned operator tools. Start cluster work + with `kubectl config current-context`, read-only status/events/logs, and the + relevant `titan-iac` manifests. Put durable desired-state changes on a + reviewable `titan-iac` branch, validate Kustomize and client dry-run, then + use Flux reconciliation after the tracked change is published. Direct + `kubectl` mutations are for explicit incident recovery or ephemeral + verification, not ordinary delivery. + + Node SSH uses a dedicated audited Hermes identity: `ssh titan-04` (or any + current Kubernetes node name). Host keys are pinned and password fallback + is disabled. Use SSH only for host-level evidence or repairs that cannot be + performed through Kubernetes. Read first, identify the exact node and + impact, avoid fleet-wide destructive commands, and reflect persistent host + configuration in the appropriate tracked provisioning source. + + After a failed tool attempt, failing test, contradicted claim, rejected + review, or incomplete evidence, do not repeat the same low-capability plan. + Reassess the objective and raise provider capability or effort by at least + one tier, up to xhigh. Consequential independent final review is always + xhigh and must be separated from the implementing worker. START-HERE.md: | # Agent Hermes diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index 009b9ba4..7270354b 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -43,6 +43,11 @@ spec: {{- with secret "kv/data/atlas/hermes/developer-gitea" -}} {{ .Data.data.username }} {{- end }} + vault.hashicorp.com/agent-inject-secret-node-ssh-private-key: kv/data/atlas/hermes/developer-ssh + vault.hashicorp.com/agent-inject-template-node-ssh-private-key: | + {{- with secret "kv/data/atlas/hermes/developer-ssh" -}} + {{ .Data.data.private_key }} + {{- end }} vault.hashicorp.com/agent-inject-secret-chat-relay-key: kv/data/atlas/hermes/chat-telegram vault.hashicorp.com/agent-inject-template-chat-relay-key: | {{- with secret "kv/data/atlas/hermes/chat-telegram" -}} @@ -104,6 +109,7 @@ spec: /opt/data/home/.claude \ /opt/data/home/.codex \ /opt/data/home/.kube \ + /opt/data/home/.ssh \ /opt/data/cli-lanes \ /opt/data/logs \ /opt/data/provider-health \ @@ -118,6 +124,15 @@ spec: cp /config/SOUL.md /opt/data/SOUL.md cp /config/AGENTS.md /opt/data/workspace/AGENTS.md cp /config/START-HERE.md /opt/data/workspace/START-HERE.md + cp /config/ssh_config /opt/data/home/.ssh/config + cp /config/ssh_known_hosts /opt/data/home/.ssh/known_hosts + if [ -s /vault/secrets/node-ssh-private-key ]; then + cp /vault/secrets/node-ssh-private-key /opt/data/home/.ssh/id_ed25519_atlas_nodes + chmod 0600 /opt/data/home/.ssh/id_ed25519_atlas_nodes + fi + chmod 0600 \ + /opt/data/home/.ssh/config \ + /opt/data/home/.ssh/known_hosts touch "${env_file}" upsert_env() { key="$1" @@ -158,6 +173,9 @@ spec: /opt/data/home/.claude \ /opt/data/home/.codex \ /opt/data/home/.kube \ + /opt/data/home/.ssh \ + /opt/data/home/.ssh/config \ + /opt/data/home/.ssh/known_hosts \ /opt/data/cli-lanes \ /opt/data/logs \ /opt/data/provider-health \ @@ -172,6 +190,9 @@ spec: /opt/data/workspace/AGENTS.md \ /opt/data/workspace/START-HERE.md \ "${env_file}" + if [ -f /opt/data/home/.ssh/id_ed25519_atlas_nodes ]; then + chown 10000:10000 /opt/data/home/.ssh/id_ed25519_atlas_nodes + fi securityContext: allowPrivilegeEscalation: false runAsUser: 0 diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml index ce561ab6..06ee22c2 100644 --- a/services/hermes/kustomization.yaml +++ b/services/hermes/kustomization.yaml @@ -14,6 +14,7 @@ resources: - switchyard-configmap.yaml - rbac.yaml - agent-rbac.yaml + - node-ssh-access.yaml - pvc.yaml - switchyard-pvc.yaml - routing-catalog-pvc.yaml diff --git a/services/hermes/node-ssh-access.yaml b/services/hermes/node-ssh-access.yaml new file mode 100644 index 00000000..c1e3f1b1 --- /dev/null +++ b/services/hermes/node-ssh-access.yaml @@ -0,0 +1,89 @@ +# services/hermes/node-ssh-access.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: hermes-node-ssh-access + namespace: hermes + labels: + app: hermes-node-ssh-access +data: + authorized_key: >- +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: hermes-node-ssh-access + namespace: hermes + labels: + app: hermes-node-ssh-access +spec: + selector: + matchLabels: + app: hermes-node-ssh-access + template: + metadata: + labels: + app: hermes-node-ssh-access + spec: + automountServiceAccountToken: false + tolerations: + - operator: Exists + containers: + - name: key-reconciler + image: busybox:1.37 + imagePullPolicy: IfNotPresent + command: [/bin/sh, -ec] + args: + - | + reconcile() { + key="$(cat /config/authorized_key)" + found=0 + for user in atlas oceanus; do + home="/host-home/${user}" + [ -d "${home}" ] || continue + found=1 + install -d -m 0700 -o "$(stat -c %u "${home}")" -g "$(stat -c %g "${home}")" "${home}/.ssh" + touch "${home}/.ssh/authorized_keys" + grep -qxF "${key}" "${home}/.ssh/authorized_keys" || printf '%s\n' "${key}" >> "${home}/.ssh/authorized_keys" + chown "$(stat -c %u "${home}"):$(stat -c %g "${home}")" "${home}/.ssh/authorized_keys" + chmod 0600 "${home}/.ssh/authorized_keys" + done + [ "${found}" = 1 ] || { echo "no supported node SSH account found" >&2; return 1; } + } + while true; do + reconcile + sleep 300 + done + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsUser: 0 + runAsGroup: 0 + seccompProfile: + type: RuntimeDefault + volumeMounts: + - name: access-config + mountPath: /config + readOnly: true + - name: host-home + mountPath: /host-home + - name: tmp + mountPath: /tmp + resources: + requests: + cpu: 5m + memory: 8Mi + limits: + cpu: 50m + memory: 32Mi + volumes: + - name: access-config + configMap: + name: hermes-node-ssh-access + - name: host-home + hostPath: + path: /home + type: Directory + - name: tmp + emptyDir: + sizeLimit: 8Mi diff --git a/services/hermes/switchyard-configmap.yaml b/services/hermes/switchyard-configmap.yaml index 659b68ef..ae3d6436 100644 --- a/services/hermes/switchyard-configmap.yaml +++ b/services/hermes/switchyard-configmap.yaml @@ -376,6 +376,11 @@ data: medium even when the user's wording is short. Filesystem, shell, repository, cluster, browser, and image-generation/edit operations are tool work; never route those boundaries to Luna, Haiku, or local-low. + A failing test, failed tool plan, contradicted result, rejected review, or + incomplete evidence means the previous quality mark was missed. Raise the + next boundary by at least one effort tier and prefer a stronger family or + the other hosted provider; repeated misses require xhigh. Never repeat a + failed lower-capability plan unchanged. 2. Treat "think hard", "deeply", "carefully", and equivalent intent as a request to raise capability by at least one tier when the safety floor is @@ -450,6 +455,11 @@ data: medium even when the user's wording is short. Filesystem, shell, repository, cluster, browser, and image-generation/edit operations are tool work; never route those boundaries to Luna, Haiku, or local-low. + A failing test, failed tool plan, contradicted result, rejected review, or + incomplete evidence means the previous quality mark was missed. Raise the + next boundary by at least one effort tier and prefer a stronger family or + the other hosted provider; repeated misses require xhigh. Never repeat a + failed lower-capability plan unchanged. 2. Treat "think hard", "deeply", "carefully", and equivalent intent as a request to raise capability by at least one tier when the safety floor is @@ -524,6 +534,11 @@ data: medium even when the user's wording is short. Filesystem, shell, repository, cluster, browser, and image-generation/edit operations are tool work; never route those boundaries to Luna or Haiku. + A failing test, failed tool plan, contradicted result, rejected review, or + incomplete evidence means the previous quality mark was missed. Raise the + next boundary by at least one effort tier and prefer a stronger family or + the other hosted provider; repeated misses require xhigh. Never repeat a + failed lower-capability plan unchanged. 2. Treat "think hard", "deeply", "carefully", and equivalent intent as a request to raise capability by at least one tier when the safety floor is @@ -583,6 +598,10 @@ data: Those xhigh triggers are mandatory: never answer one with a high target. Medium and low targets are intentionally unavailable on this route. Never choose below the floor or above xhigh. + A failing test, failed tool plan, contradicted result, rejected review, or + incomplete evidence raises the next boundary to xhigh and should switch to + the strongest suitable family or other hosted provider. Never repeat a + failed high-capability plan unchanged. 2. Treat "think hard", "deeply", "carefully", and equivalent intent as a request to raise capability by at least one tier when the safety floor is @@ -643,6 +662,11 @@ data: - low: only mechanical, reversible, tightly bounded work such as a typo, formatting, or a simple lookup. Never choose below the floor and never exceed xhigh. + If the objective records a failing test, failed attempt, contradicted + result, rejected review, or incomplete evidence from a previous worker, + raise effort by at least one tier and prefer a stronger family or the other + hosted provider. Repeated quality misses require xhigh; never launch the + same failed lower-capability plan unchanged. 2. Determine provider availability before provider preference: - Anthropic and Claude name the same provider. If either is failed, diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py index 2a7e4a0d..c4ab80f3 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -462,7 +462,8 @@ def test_chat_image_generation_uses_private_owner_broker(): assert ( '"hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram ' 'hermes/developer-keycloak hermes/developer-gitea ' - 'hermes/developer-harbor hermes/developer-jenkins"' + 'hermes/developer-harbor hermes/developer-jenkins ' + 'hermes/developer-ssh"' in vault_policy ) @@ -1488,6 +1489,11 @@ def test_local_flux_runtime_and_gpu_handoff_are_flux_managed(): assert not any(target.endswith("_medium") for target in maximum_targets) assert "_medium" not in maximum_selector_targets assert "absolute high effort floor" in routes["auto_maximum"]["prompt"] + assert "quality mark was missed" in routes["auto_balanced"]["prompt"] + assert "raises the next boundary to xhigh" in routes["auto_maximum"]["prompt"] + assert "Repeated quality misses require xhigh" in routes[ + "worker_auto_maximum" + ]["prompt"] assert any( target.startswith("local_") for target in routes["manual_local_qwen"]["targets"] diff --git a/testing/tests/test_hermes_cli_lanes.py b/testing/tests/test_hermes_cli_lanes.py index d2ce3c64..e82fd874 100644 --- a/testing/tests/test_hermes_cli_lanes.py +++ b/testing/tests/test_hermes_cli_lanes.py @@ -913,6 +913,54 @@ def test_owner_agent_has_cluster_admin_kubernetes_context(): ] +def test_owner_agent_has_pinned_dedicated_node_ssh_access(): + deployment = _agent_deployment() + annotations = deployment["spec"]["template"]["metadata"]["annotations"] + assert annotations[ + "vault.hashicorp.com/agent-inject-secret-node-ssh-private-key" + ] == "kv/data/atlas/hermes/developer-ssh" + + init = next( + item + for item in deployment["spec"]["template"]["spec"]["initContainers"] + if item["name"] == "init-config" + ) + command = init["command"][2] + assert "cp /config/ssh_config /opt/data/home/.ssh/config" in command + assert "chmod 0600 /opt/data/home/.ssh/id_ed25519_atlas_nodes" in command + + config = yaml.safe_load((HERMES / "agent-configmap.yaml").read_text())["data"] + assert "IdentityFile /opt/data/home/.ssh/id_ed25519_atlas_nodes" in config[ + "ssh_config" + ] + assert "StrictHostKeyChecking yes" in config["ssh_config"] + assert "PasswordAuthentication no" in config["ssh_config"] + pinned_hosts = [ + line for line in config["ssh_known_hosts"].splitlines() if line.strip() + ] + assert len(pinned_hosts) == 21 + assert all(" ssh-ed25519 " in line for line in pinned_hosts) + assert any(line.startswith("[titan-23]:2277 ") for line in pinned_hosts) + + resources = yaml.safe_load((HERMES / "kustomization.yaml").read_text())[ + "resources" + ] + assert "node-ssh-access.yaml" in resources + access = [ + item + for item in yaml.safe_load_all((HERMES / "node-ssh-access.yaml").read_text()) + if item + ] + daemonset = next(item for item in access if item["kind"] == "DaemonSet") + pod = daemonset["spec"]["template"]["spec"] + assert pod["automountServiceAccountToken"] is False + host_home = next(item for item in pod["volumes"] if item["name"] == "host-home") + assert host_home["hostPath"] == {"path": "/home", "type": "Directory"} + reconciler = pod["containers"][0]["args"][0] + assert "grep -qxF" in reconciler + assert "for user in atlas oceanus" in reconciler + + def test_switchyard_has_a_dedicated_non_owner_identity_and_read_only_catalog(): """Routing must not inherit the owner agent's cluster-admin capability.""" service_accounts = [