From bd60715f328d6cb8810c8ccb43ce644966197d89 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 10 Nov 2026 14:40:24 +0100 Subject: [PATCH 1/4] feat(agentic-engineer): give the kagent lane an in-cluster store Each scheduled run starts in a fresh sandbox, so the lane keeps its memory between runs, and leaves each run's transcript for the Agent Improver, in an in-cluster store (devantler-tech/monorepo#4869, #4870). An rclone WebDAV server on a Longhorn volume takes the agent's traffic only from Substrate's egress gateway, which swaps the agent's placeholder for a credential generated in-cluster. A read-only listener on the same volume takes traffic only from the API server, and the maintainer alone may read it through the reader Service's proxy. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yaml | 10 ++ .../cilium-network-policy-egress-gateway.yaml | 11 +- .../apps/agentic-engineer/agent-template.yaml | 16 +- .../hetzner/apps/agentic-engineer/agent.yaml | 1 + .../cilium-network-policy-agents.yaml | 9 +- .../cilium-network-policy-lane-store.yaml | 38 +++++ .../deployment-lane-store.yaml | 143 ++++++++++++++++++ .../egress-policy-lane-store.yaml | 17 +++ .../external-secret-lane-store.yaml | 25 +++ .../apps/agentic-engineer/kustomization.yaml | 12 +- .../agentic-engineer/password-lane-store.yaml | 12 ++ .../persistent-volume-claim-lane-store.yaml | 16 ++ .../role-lane-store-reader.yaml | 29 ++++ .../agentic-engineer/service-lane-store.yaml | 31 ++++ .../tests/test-agentic-engineer-lane-store.sh | 125 +++++++++++++++ .../approved-surface.txt | 4 + 16 files changed, 488 insertions(+), 11 deletions(-) create mode 100644 k8s/providers/hetzner/apps/agentic-engineer/cilium-network-policy-lane-store.yaml create mode 100644 k8s/providers/hetzner/apps/agentic-engineer/deployment-lane-store.yaml create mode 100644 k8s/providers/hetzner/apps/agentic-engineer/egress-policy-lane-store.yaml create mode 100644 k8s/providers/hetzner/apps/agentic-engineer/external-secret-lane-store.yaml create mode 100644 k8s/providers/hetzner/apps/agentic-engineer/password-lane-store.yaml create mode 100644 k8s/providers/hetzner/apps/agentic-engineer/persistent-volume-claim-lane-store.yaml create mode 100644 k8s/providers/hetzner/apps/agentic-engineer/role-lane-store-reader.yaml create mode 100644 k8s/providers/hetzner/apps/agentic-engineer/service-lane-store.yaml create mode 100755 scripts/tests/test-agentic-engineer-lane-store.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2a21c8760e..c61c193d8c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -606,6 +606,7 @@ jobs: - 'scripts/tests/test-coroot-application-health-reconciler.sh' - 'scripts/tests/test-coroot-helper-image-pull-policy.sh' - 'scripts/tests/test-agentic-engineer-schedule.sh' + - 'scripts/tests/test-agentic-engineer-lane-store.sh' - 'scripts/tests/test-anthropic-proxy-envoy-config.sh' - 'scripts/tests/test-velero-data-mover-resources.sh' - 'scripts/tests/test-velero-cancellation-release.sh' @@ -2856,6 +2857,15 @@ jobs: shellcheck scripts/tests/test-agentic-engineer-schedule.sh bash scripts/tests/test-agentic-engineer-schedule.sh + - name: 🗄️ Validate the Agentic Engineer lane store + if: needs.changes.outputs.k8s == 'true' + # Renders the lane and Substrate: the agent reaches the store only through + # the egress gateway with the store credential, and the maintainer reads it + # only through the API server. + run: | + shellcheck scripts/tests/test-agentic-engineer-lane-store.sh + bash scripts/tests/test-agentic-engineer-lane-store.sh + - name: 🔑 Validate the Anthropic proxy's Envoy bootstrap if: needs.changes.outputs.k8s == 'true' # The proxy crash-looped on a bootstrap Envoy rejects at startup (#5905). diff --git a/k8s/bases/infrastructure/substrate/cilium-network-policy-egress-gateway.yaml b/k8s/bases/infrastructure/substrate/cilium-network-policy-egress-gateway.yaml index 44781b3e0b..83d01cc989 100644 --- a/k8s/bases/infrastructure/substrate/cilium-network-policy-egress-gateway.yaml +++ b/k8s/bases/infrastructure/substrate/cilium-network-policy-egress-gateway.yaml @@ -2,7 +2,8 @@ # actor's own egress policy (the destinations its kagent EgressPolicies and # model grant) at layer 7, so this rule does not repeat that allow-list: it # lets the gateway reach the internet over HTTP(S), the kagent controller -# (agents' task store calls) and the in-cluster credential services. +# (agents' task store calls), the in-cluster credential services and the +# Agentic Engineer lane's store. apiVersion: cilium.io/v2 kind: CiliumNetworkPolicy metadata: @@ -26,3 +27,11 @@ spec: k8s:io.kubernetes.pod.namespace: kagent - matchLabels: k8s:io.kubernetes.pod.namespace: kagent-credentials + - toEndpoints: + - matchLabels: + k8s:io.kubernetes.pod.namespace: agentic-engineer + app.kubernetes.io/name: lane-store + toPorts: + - ports: + - port: "8080" + protocol: TCP diff --git a/k8s/providers/hetzner/apps/agentic-engineer/agent-template.yaml b/k8s/providers/hetzner/apps/agentic-engineer/agent-template.yaml index d867d55dfb..d3377d035b 100644 --- a/k8s/providers/hetzner/apps/agentic-engineer/agent-template.yaml +++ b/k8s/providers/hetzner/apps/agentic-engineer/agent-template.yaml @@ -1,7 +1,7 @@ # Who the agent is and the rules every run keeps. What a run does comes from -# the message the trigger sends (cron-job.yaml). The engineering contract -# itself is devantler-tech/monorepo's AGENTS.md, which the agent reads from its -# own checkout, so it never drifts from the reviewed source. +# the prompt of its kagent scheduled run (job-schedule.yaml). The engineering +# contract itself is devantler-tech/monorepo's AGENTS.md, which the agent reads +# from its own checkout, so it never drifts from the reviewed source. apiVersion: api.kagent.dev/v1alpha3 kind: AgentTemplate metadata: @@ -17,12 +17,18 @@ spec: devantler-tech-engineering[bot] and own only `kagent/*` branches. Credentials never reach you. Substrate's egress gateway replaces a - placeholder Authorization header with the App's token: + placeholder Authorization header with the real credential: - GitHub API: `gh` works as is. With curl or wget, send `Authorization: Bearer kagent-credential-injected`. - Git over HTTPS: run git with `-c http.extraHeader='Authorization: Basic kagent-credential-injected'`. - Only GitHub and the model are reachable. + - The lane store, your memory between runs and the place each run leaves + its transcript, is a WebDAV server at + http://lane-store.agentic-engineer.svc.cluster.local:8080. Send + `Authorization: Basic kagent-credential-injected`, for example + `curl -fsS -H 'Authorization: Basic kagent-credential-injected' ` to + read a file and `curl -fsS -T -H '...' ` to write one. + Only GitHub, the model and the lane store are reachable. Rules for every run: - Do only what this run's message asks, then stop. diff --git a/k8s/providers/hetzner/apps/agentic-engineer/agent.yaml b/k8s/providers/hetzner/apps/agentic-engineer/agent.yaml index deb6adf334..6e5c84a07f 100644 --- a/k8s/providers/hetzner/apps/agentic-engineer/agent.yaml +++ b/k8s/providers/hetzner/apps/agentic-engineer/agent.yaml @@ -14,3 +14,4 @@ spec: name: claude egressPolicyRefs: - name: github + - name: lane-store diff --git a/k8s/providers/hetzner/apps/agentic-engineer/cilium-network-policy-agents.yaml b/k8s/providers/hetzner/apps/agentic-engineer/cilium-network-policy-agents.yaml index edcc3e7a8d..99e45a54b2 100644 --- a/k8s/providers/hetzner/apps/agentic-engineer/cilium-network-policy-agents.yaml +++ b/k8s/providers/hetzner/apps/agentic-engineer/cilium-network-policy-agents.yaml @@ -1,7 +1,7 @@ # Worker pods talk only to Substrate (its egress gateway carries the agent's -# traffic; its router and API server reach the workers). The schedule Job is -# excluded: its only path, to kagent's API, is -# cilium-network-policy-schedule.yaml. +# traffic; its router and API server reach the workers). The schedule Job and +# the lane store are excluded: their paths are cilium-network-policy-schedule.yaml +# and cilium-network-policy-lane-store.yaml. apiVersion: cilium.io/v2 kind: CiliumNetworkPolicy metadata: @@ -13,6 +13,9 @@ spec: - key: app operator: NotIn values: [agentic-engineer-schedule] + - key: app.kubernetes.io/name + operator: NotIn + values: [lane-store] ingress: - fromEndpoints: - matchLabels: diff --git a/k8s/providers/hetzner/apps/agentic-engineer/cilium-network-policy-lane-store.yaml b/k8s/providers/hetzner/apps/agentic-engineer/cilium-network-policy-lane-store.yaml new file mode 100644 index 0000000000..5aa7482742 --- /dev/null +++ b/k8s/providers/hetzner/apps/agentic-engineer/cilium-network-policy-lane-store.yaml @@ -0,0 +1,38 @@ +# The lane store takes the agent's traffic only from Substrate's egress gateway, +# which adds the store credential, and its read-only view only from the +# Kubernetes API server, which checks the reader's services/proxy grant. The +# agent's sandbox has no direct path to either, and the store reaches nothing. +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: allow-lane-store + namespace: agentic-engineer +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: lane-store + # Deny everything not allowed below, including all egress: the store calls + # nothing. + enableDefaultDeny: + ingress: true + egress: true + ingress: + - fromEndpoints: + - matchLabels: + k8s:io.kubernetes.pod.namespace: ate-system + app: atenet-egress + toPorts: + - ports: + - port: "8080" + protocol: TCP + # The API server runs on the control-plane nodes' host network, so its + # proxied requests can arrive as the node, as for the platform's webhooks. + # Only host-network system pods share that identity. + - fromEntities: + - kube-apiserver + - remote-node + - host + toPorts: + - ports: + - port: "8081" + protocol: TCP diff --git a/k8s/providers/hetzner/apps/agentic-engineer/deployment-lane-store.yaml b/k8s/providers/hetzner/apps/agentic-engineer/deployment-lane-store.yaml new file mode 100644 index 0000000000..3eb088a49a --- /dev/null +++ b/k8s/providers/hetzner/apps/agentic-engineer/deployment-lane-store.yaml @@ -0,0 +1,143 @@ +# The lane store (devantler-tech/monorepo#4869, #4870). A run starts in a fresh +# sandbox, so the lane keeps its memory here between runs, and each run leaves +# its transcript here for the Agent Improver. Two rclone listeners share one +# volume: +# +# - webdav (8080): read and write, behind the lane-store credential. Only +# Substrate's egress gateway reaches it, sending that credential in place of +# the agent's placeholder (egress-policy-lane-store.yaml). +# - reader (8081): read only, with no credential of its own. Only the +# Kubernetes API server reaches it, so a reader needs the +# services/proxy grant in role-lane-store-reader.yaml. +# +# One replica on a ReadWriteOnce volume: the lane runs once a day. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lane-store + namespace: agentic-engineer + labels: + app.kubernetes.io/name: lane-store + platform.devantler.tech/replica-floor: exempt +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: lane-store + template: + metadata: + labels: + app.kubernetes.io/name: lane-store + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: webdav + image: docker.io/rclone/rclone:1.75.2@sha256:2687085f718d3c628f7fdfb77c52a1d344332aed543110bb88088f2c70d43eb5 + imagePullPolicy: IfNotPresent + args: + - serve + - webdav + - /data + - --addr=:8080 + - --htpasswd=/etc/lane-store/htpasswd + - --config=/tmp/rclone.conf + - --cache-dir=/tmp/cache + ports: + - name: webdav + containerPort: 8080 + protocol: TCP + readinessProbe: + tcpSocket: + port: webdav + periodSeconds: 10 + livenessProbe: + tcpSocket: + port: webdav + periodSeconds: 30 + resources: + requests: + cpu: 5m + memory: 32Mi + limits: + cpu: 200m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + volumeMounts: + - name: data + mountPath: /data + - name: credential + mountPath: /etc/lane-store + readOnly: true + - name: tmp + mountPath: /tmp + - name: reader + image: docker.io/rclone/rclone:1.75.2@sha256:2687085f718d3c628f7fdfb77c52a1d344332aed543110bb88088f2c70d43eb5 + imagePullPolicy: IfNotPresent + args: + - serve + - http + - /data + - --addr=:8081 + - --read-only + - --config=/tmp/rclone.conf + - --cache-dir=/tmp/cache + ports: + - name: reader + containerPort: 8081 + protocol: TCP + readinessProbe: + httpGet: + path: / + port: reader + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: reader + periodSeconds: 30 + resources: + requests: + cpu: 5m + memory: 32Mi + limits: + cpu: 200m + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + volumeMounts: + - name: data + mountPath: /data + readOnly: true + - name: tmp + mountPath: /tmp + volumes: + - name: data + persistentVolumeClaim: + claimName: lane-store + - name: credential + secret: + secretName: lane-store + items: + - key: htpasswd + path: htpasswd + - name: tmp + emptyDir: + sizeLimit: 64Mi diff --git a/k8s/providers/hetzner/apps/agentic-engineer/egress-policy-lane-store.yaml b/k8s/providers/hetzner/apps/agentic-engineer/egress-policy-lane-store.yaml new file mode 100644 index 0000000000..f3977cbf3f --- /dev/null +++ b/k8s/providers/hetzner/apps/agentic-engineer/egress-policy-lane-store.yaml @@ -0,0 +1,17 @@ +# The lane store for the agent (deployment-lane-store.yaml). The gateway +# replaces the Authorization header with the store credential, so the agent +# sends only `Authorization: Basic kagent-credential-injected`. +apiVersion: api.kagent.dev/v1alpha3 +kind: EgressPolicy +metadata: + name: lane-store + namespace: agentic-engineer +spec: + destinations: + - origin: http://lane-store.agentic-engineer.svc.cluster.local:8080 + authentication: + header: Authorization + prefix: "Basic " + secretKeyRef: + name: lane-store + key: basic diff --git a/k8s/providers/hetzner/apps/agentic-engineer/external-secret-lane-store.yaml b/k8s/providers/hetzner/apps/agentic-engineer/external-secret-lane-store.yaml new file mode 100644 index 0000000000..55537a8551 --- /dev/null +++ b/k8s/providers/hetzner/apps/agentic-engineer/external-secret-lane-store.yaml @@ -0,0 +1,25 @@ +# The lane store's one credential, generated once in-cluster. The store checks +# it from the htpasswd key, and Substrate's egress gateway sends it from the +# basic key, so the agent only ever sends a placeholder +# (egress-policy-lane-store.yaml). +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: lane-store + namespace: agentic-engineer +spec: + refreshInterval: "0" + target: + name: lane-store + creationPolicy: Owner + template: + engineVersion: v2 + data: + htpasswd: '{{ htpasswd "agentic-engineer" .password "bcrypt" }}' + basic: '{{ printf "agentic-engineer:%s" .password | b64enc }}' + dataFrom: + - sourceRef: + generatorRef: + apiVersion: generators.external-secrets.io/v1alpha1 + kind: Password + name: lane-store diff --git a/k8s/providers/hetzner/apps/agentic-engineer/kustomization.yaml b/k8s/providers/hetzner/apps/agentic-engineer/kustomization.yaml index 246277cfa2..80ad4f628d 100644 --- a/k8s/providers/hetzner/apps/agentic-engineer/kustomization.yaml +++ b/k8s/providers/hetzner/apps/agentic-engineer/kustomization.yaml @@ -1,6 +1,6 @@ # The Agentic Engineer's kagent lane (devantler-tech/monorepo#4866): the agent, -# its Substrate workers, its GitHub and Anthropic credentials, and the daily -# schedule. Prod-only, on the kagent and Substrate infrastructure. +# its Substrate workers, its GitHub and Anthropic credentials, its store and +# the daily schedule. Prod-only, on the kagent and Substrate infrastructure. --- apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization @@ -18,14 +18,22 @@ resources: - external-secret-github-token.yaml - password-model-placeholder.yaml - external-secret-model-placeholder.yaml + - password-lane-store.yaml + - external-secret-lane-store.yaml + - persistent-volume-claim-lane-store.yaml + - deployment-lane-store.yaml + - service-lane-store.yaml + - role-lane-store-reader.yaml - worker-pool.yaml - model-config.yaml - harness.yaml - egress-policy-github.yaml + - egress-policy-lane-store.yaml - agent-template.yaml - agent.yaml - job-schedule.yaml - cilium-network-policy-agents.yaml - cilium-network-policy-credentials.yaml - cilium-network-policy-schedule.yaml + - cilium-network-policy-lane-store.yaml - cilium-network-policy-external-secrets.yaml diff --git a/k8s/providers/hetzner/apps/agentic-engineer/password-lane-store.yaml b/k8s/providers/hetzner/apps/agentic-engineer/password-lane-store.yaml new file mode 100644 index 0000000000..8b9db97b7e --- /dev/null +++ b/k8s/providers/hetzner/apps/agentic-engineer/password-lane-store.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: generators.external-secrets.io/v1alpha1 +kind: Password +metadata: + name: lane-store + namespace: agentic-engineer +spec: + length: 32 + digits: 8 + symbols: 0 + noUpper: false + allowRepeat: true diff --git a/k8s/providers/hetzner/apps/agentic-engineer/persistent-volume-claim-lane-store.yaml b/k8s/providers/hetzner/apps/agentic-engineer/persistent-volume-claim-lane-store.yaml new file mode 100644 index 0000000000..f6a25ca238 --- /dev/null +++ b/k8s/providers/hetzner/apps/agentic-engineer/persistent-volume-claim-lane-store.yaml @@ -0,0 +1,16 @@ +# The lane store's files: the lane's memory between runs and each run's +# transcript. Kept if the store is ever removed from Git, so a mistaken +# removal cannot delete them. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: lane-store + namespace: agentic-engineer + annotations: + kustomize.toolkit.fluxcd.io/prune: disabled +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi diff --git a/k8s/providers/hetzner/apps/agentic-engineer/role-lane-store-reader.yaml b/k8s/providers/hetzner/apps/agentic-engineer/role-lane-store-reader.yaml new file mode 100644 index 0000000000..ba7e8eeaf7 --- /dev/null +++ b/k8s/providers/hetzner/apps/agentic-engineer/role-lane-store-reader.yaml @@ -0,0 +1,29 @@ +# The maintainer reads the lane store's read-only view through the Kubernetes +# API (kubectl get --raw .../services/lane-store-reader/proxy/), which is +# how the Agent Improver on the agent host sees the lane's runs. Only get, and +# only on that one Service's proxy. +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: lane-store-reader + namespace: agentic-engineer +rules: + - apiGroups: [""] + resources: [services/proxy] + resourceNames: [lane-store-reader] + verbs: [get] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: lane-store-reader + namespace: agentic-engineer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: lane-store-reader +subjects: + - apiGroup: rbac.authorization.k8s.io + kind: User + name: "oidc:${admin_email}" diff --git a/k8s/providers/hetzner/apps/agentic-engineer/service-lane-store.yaml b/k8s/providers/hetzner/apps/agentic-engineer/service-lane-store.yaml new file mode 100644 index 0000000000..c7982a7a1f --- /dev/null +++ b/k8s/providers/hetzner/apps/agentic-engineer/service-lane-store.yaml @@ -0,0 +1,31 @@ +# The lane store's two listeners: the authenticated WebDAV store the agent +# reaches through Substrate's egress gateway, and the read-only view the +# maintainer and the Agent Improver read through the Kubernetes API. +--- +apiVersion: v1 +kind: Service +metadata: + name: lane-store + namespace: agentic-engineer +spec: + selector: + app.kubernetes.io/name: lane-store + ports: + - name: http + port: 8080 + targetPort: webdav + protocol: TCP +--- +apiVersion: v1 +kind: Service +metadata: + name: lane-store-reader + namespace: agentic-engineer +spec: + selector: + app.kubernetes.io/name: lane-store + ports: + - name: http + port: 8081 + targetPort: reader + protocol: TCP diff --git a/scripts/tests/test-agentic-engineer-lane-store.sh b/scripts/tests/test-agentic-engineer-lane-store.sh new file mode 100755 index 0000000000..a8f70faf59 --- /dev/null +++ b/scripts/tests/test-agentic-engineer-lane-store.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Prove the Agentic Engineer lane's store is wired end to end, and reachable only as intended. +# +# A kagent run starts in a fresh sandbox, so the lane keeps its memory and transcripts in an +# in-cluster WebDAV store (devantler-tech/monorepo#4869, #4870). The agent reaches it only through +# Substrate's egress gateway, which swaps its placeholder for the store credential; the maintainer +# and the Agent Improver read it only through the Kubernetes API. This renders the lane and the +# Substrate base and checks every link: the EgressPolicy names the store's real address and the +# credential the store checks, the Agent uses it, the gateway may reach it, and nothing else may. +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +work="$(mktemp -d)" +completed=0 +# Remove the work directory on exit, and fail any exit that did not reach the final PASS. +cleanup() { + rm -rf "${work}" + if [[ "${completed}" != 1 ]]; then + printf 'FAIL: agentic-engineer lane store: aborted before completion\n' >&2 + exit 1 + fi +} +trap cleanup EXIT +# Print one failure reason and stop. +fail() { + printf 'FAIL: agentic-engineer lane store: %s\n' "$1" >&2 + exit 1 +} +for tool in yq kubectl; do + command -v "${tool}" >/dev/null || fail "${tool} is required" +done + +kubectl kustomize "${root}/k8s/providers/hetzner/apps/agentic-engineer" >"${work}/lane.yaml" || + fail 'the lane does not render' +kubectl kustomize "${root}/k8s/bases/infrastructure/substrate" >"${work}/substrate.yaml" || + fail 'the Substrate base does not render' +# doc : one rendered document as JSON, or nothing. +doc() { yq -o=json -I=0 "select(.kind == \"$2\" and .metadata.name == \"$3\")" "${work}/$1"; } +# q : one value from a document. +q() { yq -p=json -r "$2" <<<"$1"; } + +deployment="$(doc lane.yaml Deployment lane-store)" +service="$(doc lane.yaml Service lane-store)" +reader_service="$(doc lane.yaml Service lane-store-reader)" +policy="$(doc lane.yaml EgressPolicy lane-store)" +secret="$(doc lane.yaml ExternalSecret lane-store)" +network="$(doc lane.yaml CiliumNetworkPolicy allow-lane-store)" +workers="$(doc lane.yaml CiliumNetworkPolicy allow-agentic-engineer)" +role="$(doc lane.yaml Role lane-store-reader)" +binding="$(doc lane.yaml RoleBinding lane-store-reader)" +agent="$(doc lane.yaml Agent agentic-engineer)" +template="$(doc lane.yaml AgentTemplate agentic-engineer)" +gateway="$(doc substrate.yaml CiliumNetworkPolicy allow-egress-gateway)" +for name in deployment service reader_service policy secret network workers role binding agent template gateway; do + [[ -n "${!name}" ]] || fail "the ${name//_/ } is not rendered" +done + +container() { q "${deployment}" ".spec.template.spec.containers[] | select(.name == \"$1\") | $2"; } +writer_port="$(container webdav '.ports[] | select(.name == "webdav") | .containerPort')" +reader_port="$(container reader '.ports[] | select(.name == "reader") | .containerPort')" +[[ "$(q "${service}" '.spec.ports[0].targetPort')" == webdav ]] || fail 'the store Service does not front the WebDAV listener' +[[ "$(q "${reader_service}" '.spec.ports[0].targetPort')" == reader ]] || + fail 'the reader Service does not front the read-only listener' +service_port="$(q "${service}" '.spec.ports[0].port')" + +# The agent's way in: the EgressPolicy names the Service's real address, the Agent uses the +# policy, and the system prompt tells the agent that address. +origin="http://lane-store.agentic-engineer.svc.cluster.local:${service_port}" +destinations="$(q "${policy}" '[.spec.destinations[].origin] | join(",")')" +[[ "${destinations}" == "${origin}" ]] || fail "the EgressPolicy does not name the store at ${origin} alone: ${destinations}" +grep -qx lane-store <<<"$(q "${agent}" '.spec.egressPolicyRefs[].name')" || fail 'the Agent does not use the store EgressPolicy' +grep -Fq "${origin}" <<<"$(q "${template}" '.spec.systemPrompt')" || fail "the system prompt does not give the store at ${origin}" + +# One credential: the gateway sends the policy's key, the store checks the htpasswd key, and both +# come from the same generated password and user. ESO's htpasswd takes the hash as a third argument. +[[ "$(q "${policy}" '.spec.destinations[0].authentication.secretKeyRef.name')" == "$(q "${secret}" '.spec.target.name')" ]] || + fail 'the EgressPolicy does not use the store Secret' +key="$(q "${policy}" '.spec.destinations[0].authentication.secretKeyRef.key')" +[[ "$(q "${policy}" '.spec.destinations[0].authentication.prefix')" == 'Basic ' ]] || fail 'the gateway does not send Basic credentials' +[[ "$(q "${secret}" ".spec.target.template.data.${key}")" == '{{ printf "agentic-engineer:%s" .password | b64enc }}' ]] || + fail "the gateway's ${key} key is not the store user's Basic credential" +[[ "$(q "${secret}" '.spec.target.template.data.htpasswd')" == '{{ htpasswd "agentic-engineer" .password "bcrypt" }}' ]] || + fail 'the store does not check a bcrypt htpasswd entry for the same user' +grep -qx -- '--htpasswd=/etc/lane-store/htpasswd' <<<"$(container webdav '.args[]')" || fail 'the WebDAV listener does not require the credential' +mount="$(container webdav '.volumeMounts[] | select(.mountPath == "/etc/lane-store") | .name')" +source="$(q "${deployment}" ".spec.template.spec.volumes[] | select(.name == \"${mount}\") | .secret.secretName")" +[[ "${source}" == lane-store ]] || fail 'the WebDAV listener does not read the credential from the store Secret' + +# The reader only reads. +grep -qx -- '--read-only' <<<"$(container reader '.args[]')" || fail 'the reader is not read-only' +[[ "$(container reader '.volumeMounts[] | select(.name == "data") | .readOnly')" == true ]] || + fail "the reader's data mount is writable" + +# Network: deny by default, the gateway on the WebDAV port, the API server on the reader port, +# no egress, and the namespace's worker policy leaves the store alone. +[[ "$(q "${network}" '.spec.enableDefaultDeny.ingress')" == true && "$(q "${network}" '.spec.enableDefaultDeny.egress')" == true ]] || + fail 'the store policy does not deny by default' +[[ "$(q "${network}" '.spec.egress // [] | length')" == 0 ]] || fail 'the store may make outbound calls' +[[ "$(q "${network}" '.spec.ingress | length')" == 2 ]] || fail 'the store admits more than the gateway and the API server' +gateway_peer="$(q "${network}" '.spec.ingress[] | select(.fromEndpoints) | .fromEndpoints[0].matchLabels | to_entries | map(.key + "=" + .value) | sort | join(",")')" +[[ "${gateway_peer}" == 'app=atenet-egress,k8s:io.kubernetes.pod.namespace=ate-system' ]] || + fail "the WebDAV port admits more than the egress gateway: ${gateway_peer}" +[[ "$(q "${network}" '.spec.ingress[] | select(.fromEndpoints) | .toPorts[0].ports[0].port')" == "${writer_port}" ]] || + fail 'the gateway is not admitted to the WebDAV port' +[[ "$(q "${network}" '.spec.ingress[] | select(.fromEntities) | .toPorts[0].ports[0].port')" == "${reader_port}" ]] || + fail 'the API server is not admitted to the reader port' +[[ "$(q "${network}" '.spec.ingress[] | select(.fromEntities) | .fromEntities | sort | join(",")')" == host,kube-apiserver,remote-node ]] || + fail 'the reader port admits more than the API server' +[[ "$(q "${workers}" '.spec.endpointSelector.matchExpressions[] | select(.key == "app.kubernetes.io/name" and .operator == "NotIn") | .values[]')" == lane-store ]] || + fail "the namespace's worker policy also selects the store" +[[ "$(q "${gateway}" ".spec.egress[] | select(.toEndpoints[0].matchLabels[\"app.kubernetes.io/name\"] == \"lane-store\") | .toPorts[0].ports[0].port")" == "${writer_port}" ]] || + fail 'the egress gateway may not reach the WebDAV port' + +# The read grant: get on that one Service's proxy, for the maintainer only. +grant="$(q "${role}" '[.rules[] | (.resources | join(",")) + "|" + (.resourceNames | join(",")) + "|" + (.verbs | join(","))] | join(";")')" +[[ "${grant}" == 'services/proxy|lane-store-reader|get' ]] || fail "the reader Role grants more than get on the reader proxy: ${grant}" +holders="$(q "${binding}" '.roleRef.name + "|" + ([.subjects[].name] | join(","))')" +# shellcheck disable=SC2016 # Flux substitutes the maintainer's address after this render +[[ "${holders}" == 'lane-store-reader|oidc:${admin_email}' ]] || fail "the reader grant is not bound to the maintainer alone: ${holders}" + +grep -Fq 'bash scripts/tests/test-agentic-engineer-lane-store.sh' "${root}/.github/workflows/ci.yaml" || + fail 'CI does not run this test' + +completed=1 +printf 'agentic-engineer lane store: PASS\n' diff --git a/scripts/validate-eks-ci-role-policy/approved-surface.txt b/scripts/validate-eks-ci-role-policy/approved-surface.txt index bfded8a000..70373b5644 100644 --- a/scripts/validate-eks-ci-role-policy/approved-surface.txt +++ b/scripts/validate-eks-ci-role-policy/approved-surface.txt @@ -270,6 +270,8 @@ rbac.authorization.k8s.io/v1|ClusterRole||tenant-external-dns-global 24084ed6f90 rbac.authorization.k8s.io/v1|ClusterRole||vpa-updater-event-writer 3879241a89e481edb6edef4a634d689ca6cbf648cf0f18ecaa74304e9542e52f +rbac.authorization.k8s.io/v1|RoleBinding|agentic-engineer|lane-store-reader 6cb9a46fe78f3579c7ce18908d16158a633fd3e2fdb3884ddaed99cc2ca083db + rbac.authorization.k8s.io/v1|RoleBinding|ascoachingogvaner|ascoachingogvaner cb7ca7497e2f066851a9354b75f38962fea1e588598d96dde786f7043576a08d rbac.authorization.k8s.io/v1|RoleBinding|ascoachingogvaner|ascoachingogvaner-external-dns bd51e723eadb52df8b583fc8931be70ecade6863d8d2821ecad5f128c8bf6888 @@ -326,6 +328,8 @@ rbac.authorization.k8s.io/v1|RoleBinding|world-at-ruin|world-at-ruin 593fd95bd84 rbac.authorization.k8s.io/v1|RoleBinding|world-at-ruin|world-at-ruin-zone-trial-operator b14714377d4a5317c7d829bc1b378ecea44ed82f2b1fff32d4c923f9ee4809d6 +rbac.authorization.k8s.io/v1|Role|agentic-engineer|lane-store-reader 106d83767c8414ced671f67a43a09030e554a529851f0967021c98bc2a031542 + rbac.authorization.k8s.io/v1|Role|ate-system|substrate-ca-reader 153cc02bc210a1f00ad70fb8c260500fa9593e634302378d67d91af10cf3fafd rbac.authorization.k8s.io/v1|Role|aws|aws-managed-resources bcbd97adfa055719ff19364d7341e4952fcb0566f1c1ba16d073b6f1ae4108b0 From 61d403fa5a26b3d5cb53f3a9bd9b87eb408e8ebd Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 10 Nov 2026 15:18:31 +0100 Subject: [PATCH 2/4] fix(agentic-engineer): keep one lane store resource per file The naming validator requires each Kubernetes resource in its own file: split the reader Role from its binding and the two Services apart. Co-Authored-By: Claude Opus 5.5 --- .../apps/agentic-engineer/kustomization.yaml | 2 ++ .../role-binding-lane-store-reader.yaml | 14 +++++++++++++ .../role-lane-store-reader.yaml | 17 +--------------- .../service-lane-store-reader.yaml | 15 ++++++++++++++ .../agentic-engineer/service-lane-store.yaml | 20 ++----------------- 5 files changed, 34 insertions(+), 34 deletions(-) create mode 100644 k8s/providers/hetzner/apps/agentic-engineer/role-binding-lane-store-reader.yaml create mode 100644 k8s/providers/hetzner/apps/agentic-engineer/service-lane-store-reader.yaml diff --git a/k8s/providers/hetzner/apps/agentic-engineer/kustomization.yaml b/k8s/providers/hetzner/apps/agentic-engineer/kustomization.yaml index 80ad4f628d..c9a115c2ff 100644 --- a/k8s/providers/hetzner/apps/agentic-engineer/kustomization.yaml +++ b/k8s/providers/hetzner/apps/agentic-engineer/kustomization.yaml @@ -23,7 +23,9 @@ resources: - persistent-volume-claim-lane-store.yaml - deployment-lane-store.yaml - service-lane-store.yaml + - service-lane-store-reader.yaml - role-lane-store-reader.yaml + - role-binding-lane-store-reader.yaml - worker-pool.yaml - model-config.yaml - harness.yaml diff --git a/k8s/providers/hetzner/apps/agentic-engineer/role-binding-lane-store-reader.yaml b/k8s/providers/hetzner/apps/agentic-engineer/role-binding-lane-store-reader.yaml new file mode 100644 index 0000000000..78c93fd005 --- /dev/null +++ b/k8s/providers/hetzner/apps/agentic-engineer/role-binding-lane-store-reader.yaml @@ -0,0 +1,14 @@ +# Binds the lane store reader Role to the maintainer alone. +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: lane-store-reader + namespace: agentic-engineer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: lane-store-reader +subjects: + - apiGroup: rbac.authorization.k8s.io + kind: User + name: "oidc:${admin_email}" diff --git a/k8s/providers/hetzner/apps/agentic-engineer/role-lane-store-reader.yaml b/k8s/providers/hetzner/apps/agentic-engineer/role-lane-store-reader.yaml index ba7e8eeaf7..067f9b4d8b 100644 --- a/k8s/providers/hetzner/apps/agentic-engineer/role-lane-store-reader.yaml +++ b/k8s/providers/hetzner/apps/agentic-engineer/role-lane-store-reader.yaml @@ -1,8 +1,7 @@ # The maintainer reads the lane store's read-only view through the Kubernetes # API (kubectl get --raw .../services/lane-store-reader/proxy/), which is # how the Agent Improver on the agent host sees the lane's runs. Only get, and -# only on that one Service's proxy. ---- +# only on that one Service's proxy (role-binding-lane-store-reader.yaml). apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: @@ -13,17 +12,3 @@ rules: resources: [services/proxy] resourceNames: [lane-store-reader] verbs: [get] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: lane-store-reader - namespace: agentic-engineer -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: lane-store-reader -subjects: - - apiGroup: rbac.authorization.k8s.io - kind: User - name: "oidc:${admin_email}" diff --git a/k8s/providers/hetzner/apps/agentic-engineer/service-lane-store-reader.yaml b/k8s/providers/hetzner/apps/agentic-engineer/service-lane-store-reader.yaml new file mode 100644 index 0000000000..bfc8aa6d84 --- /dev/null +++ b/k8s/providers/hetzner/apps/agentic-engineer/service-lane-store-reader.yaml @@ -0,0 +1,15 @@ +# The lane store's read-only view, which the maintainer and the Agent Improver +# read through the Kubernetes API (role-lane-store-reader.yaml). +apiVersion: v1 +kind: Service +metadata: + name: lane-store-reader + namespace: agentic-engineer +spec: + selector: + app.kubernetes.io/name: lane-store + ports: + - name: http + port: 8081 + targetPort: reader + protocol: TCP diff --git a/k8s/providers/hetzner/apps/agentic-engineer/service-lane-store.yaml b/k8s/providers/hetzner/apps/agentic-engineer/service-lane-store.yaml index c7982a7a1f..477fb00d04 100644 --- a/k8s/providers/hetzner/apps/agentic-engineer/service-lane-store.yaml +++ b/k8s/providers/hetzner/apps/agentic-engineer/service-lane-store.yaml @@ -1,7 +1,5 @@ -# The lane store's two listeners: the authenticated WebDAV store the agent -# reaches through Substrate's egress gateway, and the read-only view the -# maintainer and the Agent Improver read through the Kubernetes API. ---- +# The authenticated WebDAV store the agent reaches through Substrate's egress +# gateway (egress-policy-lane-store.yaml). apiVersion: v1 kind: Service metadata: @@ -15,17 +13,3 @@ spec: port: 8080 targetPort: webdav protocol: TCP ---- -apiVersion: v1 -kind: Service -metadata: - name: lane-store-reader - namespace: agentic-engineer -spec: - selector: - app.kubernetes.io/name: lane-store - ports: - - name: http - port: 8081 - targetPort: reader - protocol: TCP From bdb3ac5a615704853f380bf3b32890b8decda417 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 10 Nov 2026 16:28:34 +0100 Subject: [PATCH 3/4] fix(agentic-engineer): leave the lane store reader's port unnamed A proxy path that names no port, .../services/lane-store-reader/proxy/, resolves only to an unnamed Service port; with the port named http the API server answered 503 and the reader grant had no working route. Co-Authored-By: Claude Opus 5.5 --- .../apps/agentic-engineer/service-lane-store-reader.yaml | 8 +++++--- scripts/tests/test-agentic-engineer-lane-store.sh | 4 ++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/k8s/providers/hetzner/apps/agentic-engineer/service-lane-store-reader.yaml b/k8s/providers/hetzner/apps/agentic-engineer/service-lane-store-reader.yaml index bfc8aa6d84..e76fcd6190 100644 --- a/k8s/providers/hetzner/apps/agentic-engineer/service-lane-store-reader.yaml +++ b/k8s/providers/hetzner/apps/agentic-engineer/service-lane-store-reader.yaml @@ -1,5 +1,8 @@ # The lane store's read-only view, which the maintainer and the Agent Improver -# read through the Kubernetes API (role-lane-store-reader.yaml). +# read through the Kubernetes API (role-lane-store-reader.yaml). Its one port is +# deliberately unnamed: a proxy path without a port, .../services/ +# lane-store-reader/proxy/, resolves only to an unnamed port, and the Role +# grants exactly that name. apiVersion: v1 kind: Service metadata: @@ -9,7 +12,6 @@ spec: selector: app.kubernetes.io/name: lane-store ports: - - name: http - port: 8081 + - port: 8081 targetPort: reader protocol: TCP diff --git a/scripts/tests/test-agentic-engineer-lane-store.sh b/scripts/tests/test-agentic-engineer-lane-store.sh index a8f70faf59..d9d97fe32e 100755 --- a/scripts/tests/test-agentic-engineer-lane-store.sh +++ b/scripts/tests/test-agentic-engineer-lane-store.sh @@ -61,6 +61,10 @@ reader_port="$(container reader '.ports[] | select(.name == "reader") | .contain [[ "$(q "${service}" '.spec.ports[0].targetPort')" == webdav ]] || fail 'the store Service does not front the WebDAV listener' [[ "$(q "${reader_service}" '.spec.ports[0].targetPort')" == reader ]] || fail 'the reader Service does not front the read-only listener' +# The documented proxy path names no port, and the API server resolves that only to one unnamed +# port; a named port would answer 503 behind a grant that could never be used. +reader_ports="$(q "${reader_service}" '[.spec.ports[] | .name // ""] | join(",")')" +[[ "${reader_ports}" == "" ]] || fail "the reader Service port is named (${reader_ports}), so the proxy path cannot reach it" service_port="$(q "${service}" '.spec.ports[0].port')" # The agent's way in: the EgressPolicy names the Service's real address, the Agent uses the From 7f8de467b38b209ea8544f681cd72d7c4d76a0b0 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 10 Nov 2026 16:45:35 +0100 Subject: [PATCH 4/4] test(agentic-engineer): pin every lane store ingress peer and port The ingress checks read only the first peer selector and the first port of each rule, so a second selector, a CIDR peer or an extra port could widen a listener unseen; the reader has no credential of its own. Each rule is now exactly one peer list with exactly one port. Co-Authored-By: Claude Opus 5.5 --- scripts/tests/test-agentic-engineer-lane-store.sh | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/scripts/tests/test-agentic-engineer-lane-store.sh b/scripts/tests/test-agentic-engineer-lane-store.sh index d9d97fe32e..ebaf69f6f2 100755 --- a/scripts/tests/test-agentic-engineer-lane-store.sh +++ b/scripts/tests/test-agentic-engineer-lane-store.sh @@ -101,13 +101,19 @@ grep -qx -- '--read-only' <<<"$(container reader '.args[]')" || fail 'the reader fail 'the store policy does not deny by default' [[ "$(q "${network}" '.spec.egress // [] | length')" == 0 ]] || fail 'the store may make outbound calls' [[ "$(q "${network}" '.spec.ingress | length')" == 2 ]] || fail 'the store admits more than the gateway and the API server' +# Every rule is exactly one peer list and its ports, so no second selector, CIDR or port can widen +# a listener; the reader has no credential of its own. +shapes="$(q "${network}" '[.spec.ingress[] | keys | sort | join("+")] | sort | join(",")')" +[[ "${shapes}" == 'fromEndpoints+toPorts,fromEntities+toPorts' ]] || fail "an ingress rule admits other peers: ${shapes}" +[[ "$(q "${network}" '.spec.ingress[] | select(.fromEndpoints) | .fromEndpoints | length')" == 1 ]] || + fail 'the WebDAV rule admits more than one peer selector' +# rule_ports : every port the rule with that peer list admits, as port/protocol. +rule_ports() { q "${network}" ".spec.ingress[] | select(.$1) | [.toPorts[].ports[] | (.port | tostring) + \"/\" + .protocol] | join(\",\")"; } +[[ "$(rule_ports fromEndpoints)" == "${writer_port}/TCP" ]] || fail "the WebDAV rule admits ports $(rule_ports fromEndpoints)" +[[ "$(rule_ports fromEntities)" == "${reader_port}/TCP" ]] || fail "the reader rule admits ports $(rule_ports fromEntities)" gateway_peer="$(q "${network}" '.spec.ingress[] | select(.fromEndpoints) | .fromEndpoints[0].matchLabels | to_entries | map(.key + "=" + .value) | sort | join(",")')" [[ "${gateway_peer}" == 'app=atenet-egress,k8s:io.kubernetes.pod.namespace=ate-system' ]] || fail "the WebDAV port admits more than the egress gateway: ${gateway_peer}" -[[ "$(q "${network}" '.spec.ingress[] | select(.fromEndpoints) | .toPorts[0].ports[0].port')" == "${writer_port}" ]] || - fail 'the gateway is not admitted to the WebDAV port' -[[ "$(q "${network}" '.spec.ingress[] | select(.fromEntities) | .toPorts[0].ports[0].port')" == "${reader_port}" ]] || - fail 'the API server is not admitted to the reader port' [[ "$(q "${network}" '.spec.ingress[] | select(.fromEntities) | .fromEntities | sort | join(",")')" == host,kube-apiserver,remote-node ]] || fail 'the reader port admits more than the API server' [[ "$(q "${workers}" '.spec.endpointSelector.matchExpressions[] | select(.key == "app.kubernetes.io/name" and .operator == "NotIn") | .values[]')" == lane-store ]] ||