Security & IAM
What the agent can and cannot do
Section titled “What the agent can and cannot do”This is the canonical answer. Other pages summarize it and link here; if they appear to disagree, this page is correct.
“Is the agent read-only?” has three different answers depending on which plane you mean. Conflating them is the most common misreading of this project’s security posture.
| Plane | What it governs | Can the agent write? |
|---|---|---|
| Kubernetes RBAC (the KSA) | Everything the agent does against a cluster’s Kubernetes API | No for workloads and cluster state — read-only in every configuration apart from a leader-election housekeeping Role confined to its own namespace, and it cannot read Secrets. Enforced by RBAC. |
| GCP IAM (the GSA) | GKE/Google Cloud control-plane calls, including via the gke MCP |
No write to infrastructure, with the default read-only permission set — the one qualification is the Trace-console permissions the Trace role carries, under Roles per set; the only other set the install offers is custom, whose roles you choose yourself. Enforced by IAM, chosen at install time. |
| The GitOps path | Changes to your infrastructure-as-code repository | Yes — by opening a pull request a human must review and merge. Enforced by the credential proxy, which refuses the agent the GitHub write verbs (merge, approve, REST mutation, workflow and release triggers, repo administration). |
What that means in practice
Section titled “What that means in practice”- Workloads and cluster state cannot be mutated through the Kubernetes API by this agent. The KSA’s only write grant is the housekeeping Role
kubeagents:leader:<namespace>:<name>— write on leader-electionleases, confined to the agent’s own namespace, plusget/patchonpodsthere only when the deployment runs more than one replica (spec.deployment.availability.replicas), which is when the leader-election wrapper that labels its own pod actually runs. The operator runs the gateway as a single replica whateverspec.deployment.availability.replicasasks for, so today the Role grantsleasesalone on every install. Beyond that it holds no write verb (see Kubernetes RBAC). No other setting widens this. A kubectl write the command policy refuses also ends the run’s work on it: the runtime halts the turn at the first such refusal by default (permission_refusal_halt_after), and the worker is told to end its card (blocked, or completed when the card asked for a pull request) with the refusal quoted; the halt is enforced, the close is the worker’s to follow. - GCP control-plane mutation is enforced off by default. The default
read-onlypermission set binds read roles, so a write to infrastructure fails at IAM. The one qualification isroles/cloudtrace.user, whose Trace-console permissions (analysis-report tasks and trace scopes) IAM does grant; Roles per set says which paths reach them and which cannot. The install no longer offers an admin bundle —customis the only way to widen this, and it requires you to name every role. If you do grant write roles that way, thegkeMCP server’s cluster-management tools are still refused: the agent’s MCP client reaches that server only through the credential proxy, which relays its read-only tools and refuses the rest. Each profile’s config also offers the model only that server’s read tools, socreate_cluster,patch_k8s_resourceand the other write tools are not offered either. - Persona rules are guidance, not enforcement. A prompt-injection or reasoning failure is bounded by IAM, not by
SOUL.md. Keep the defaultread-onlyset if “read-only on the cloud plane” must be an enforced property of the deployment rather than an intended behaviour of the model (see Configuring read-only mode). - The intended write path is always GitOps — the agent proposes, a human merges, your reconciler applies. The second half is enforced rather than intended: the credential proxy refuses
gh pr merge,gh pr review --approve, mutatinggh apicalls, workflow and release triggers, and repository administration, so the actor that opens a pull request cannot also complete it. See Secure write path and Credential isolation for the rule ids. - The chat front door holds no infrastructure tools at all. Chat ingress terminates at the Planning Agent (the pod’s
defaultHermes profile), whose config pins every surface to routing, kanban-delegation, and per-user memory tools only — no GKE, file, or GitOps write tools. A prompt injected through chat must still be delegated to the Platform Agent, where the IAM and RBAC boundaries above apply. The experimentalplatformFrontDoorflag gives that up on purpose: it points chat ingress straight at the Platform Agent’s toolset, so this row does not hold on an install that has turned it on. It also hands that profile’sconfig.yamlto the agent, so the security and approval keys in it (security.tirith_enabled,approvals.*) stop being restored from the image on every restart. It is off by default and unsupported. See ChatOps. - Cluster Agents are scoped-down, not scoped-up. Each per-cluster Cluster Agent profile shares the pod’s identity (same KSA/GSA, so the same IAM and RBAC ceilings apply), but its config template exposes only the read-only
gkeanddeveloper_knowledgeMCP servers — noplatform_control, no GitOps write path — and itsKUBECONFIGis pinned to one cluster. It proposes fixes back over the kanban card; only the Platform Agent can turn them into PRs.- The management cluster now has a Cluster Agent too, so one of these profiles is scoped to the cluster kube-agents itself runs in and can read the harness’s own namespace — including the namespace holding
platform-agent-secrets. How far that reach goes is set by the GSA’s permission set, not by the Kubernetes RBAC row above. A Cluster Agent does not reach its cluster as the KSA:cluster_agent_profile.create_profilerunsgcloud container clusters get-credentialsand pins the resulting kubeconfig, so its Kubernetes API calls authenticate as the pod’s GSA and thekubeagents:minimal:*ClusterRole does not bound them. (On an install that has armed the scoped service account pool,kubectlunder such a kubeconfig authenticates as its project’s pool member instead — moot today, since the pool is suspended and off by default.) On the defaultread-onlyset (roles/container.clusterViewer,roles/container.viewer) that means the harness’s own workloads, their logs, ConfigMaps and events, and not its Secrets. On acustomset that names an admin role it means the full Kubernetes API on that cluster, Secrets included — so on such an install this profile can read the agent’s own credentials. That reach is accepted on purpose on the default set: the management cluster’s workloads fail like any other cluster’s, and the agent that triages an event has to be the one scoped to the cluster that raised it. If it is not acceptable on your install — most sharply if you have widened the GSA throughcustom— add the cluster’s name tospec.scope.exclude.clusterson thePlatformAgent(naming the cluster by project, location and name; the olderRECONCILE_EXCLUDEbare-name variable still works for one release); the next reconcile then prunes the profile. That removes the Cluster Agent and, with it,stall-watch’s reads of that cluster, which follow the same profile roster; the per-cluster fleet audits and fleet answers leave it out too (#3081), while the daily upgrade-readiness watch, the networking audit’s subnet usage and the GCE audit’s instance listing still cover it. On an installer-managed install, put the triple inSCOPE_EXCLUDE_CLUSTERSininstall.envand runupgrade.sh --upgrade-mode=full, which renders it onto thePlatformAgent; an edit made on thePlatformAgentitself is refused at the next full upgrade until it is recorded there.
- The management cluster now has a Cluster Agent too, so one of these profiles is scoped to the cluster kube-agents itself runs in and can read the harness’s own namespace — including the namespace holding
The end-state design goes further: agents stay read-only on cloud APIs in every configuration, and the
create_clustertool is withdrawn. Removing thegke-adminbundle closed the one-word path to a writable GSA, and the tool list above keepscreate_clusterout of the model’s reach, but neither gets there on its own —customcan still be pointed at admin roles, and the credential behind thegkeserver can still call the tool. That part is a target, not current behaviour.
The rest of this page details the two enforced planes.
Identity model
Section titled “Identity model”The agent uses GKE Workload Identity to bind its in-cluster KSA to a GSA, so no static GCP key ever lands in the cluster.
flowchart LR subgraph K8s["GKE cluster"] Pod["Platform Agent pod"] --> KSA["KSA<br/>kubeagents-platform-agent"] end subgraph IAM["GCP IAM"] KSA -->|Workload Identity| GSA["GSA<br/>kubeagents-platform-gsa@PROJECT.iam.gserviceaccount.com"] GSA -->|IAM roles| Res["GCP / GKE resources"] endThe IAM side of the binding is provisioned by the kube-agents-iam Terraform module (instantiated by the terraform/examples/full-install composition the installer drives), which grants roles/iam.workloadIdentityUser on the GSA to the KSA member <project>.svc.id.goog[kubeagents-system/kubeagents-platform-agent] (the KSA half is the composition’s agent_ksa_name, kubeagents-platform-agent by default). The KSA-side annotation iam.gke.io/gcp-service-account: kubeagents-platform-gsa@<project>.iam.gserviceaccount.com is applied by the operator from spec.security.serviceAccountAnnotations on the PlatformAgent CR.
The shell sandbox uses federation instead
Section titled “The shell sandbox uses federation instead”GKE resolves Workload Identity by Pod IP, so every container in a Pod shares one cloud identity — including a credential proxy sitting beside the agent’s shell. The shell sandbox Pod therefore runs under its own KSA, <agent>-shell, with no iam.gke.io/gcp-service-account annotation: the metadata server answers it with an unbound <project>.svc.id.goog principal that no IAM policy names. When spec.security.workloadIdentityFederation is set, the credential proxy in that Pod gets an audience-scoped projected token mounted in its container alone and exchanges it through Workload Identity Federation for an impersonated kubeagents-platform-gsa token. Mounts are per-container where Pod identity is not.
The chart turns this on with platformAgent.harness.experimental.shellSandbox.enabled and platformAgent.security.workloadIdentityFederation, but nothing creates the pool itself — not the chart, not the provisioning scripts, not the Terraform modules. The one-time gcloud commands are in designs/agent-shell-sandboxing.md.
GCP IAM permission sets
Section titled “GCP IAM permission sets”The install grants the agent GSA one of two permission sets. Both entry points choose from the same vocabulary, and read-only is the default in each: the installer asks the question — or takes --permission-set / --custom-roles — and records the answer in install.env as PLATFORM_AGENT_PERMISSION_SET before generating terraform.tfvars; a hand-driven Terraform composition takes the same two values in its permission_set variable, with custom requiring a project_roles list (setting project_roles explicitly overrides permission_set either way).
| Permission set | PLATFORM_AGENT_PERMISSION_SET |
Use it when |
|---|---|---|
| read-only | read-only (default) |
Auditing / monitoring only — no write to infrastructure. |
| custom | custom |
You supply the exact roles via PLATFORM_AGENT_CUSTOM_ROLES. |
Roles per set
Section titled “Roles per set”The default read-only set binds read roles, with one qualification under the Trace role:
roles/container.clusterViewer,roles/container.viewer— read-only GKE.roles/compute.viewer— read-only compute, reservations, machine types, and quota advice.roles/monitoring.viewer,roles/logging.viewer— read-only metrics and logs.roles/cloudtrace.user— trace reads (cloudtrace.traces.getand.list), for the observability skill’s trace helpers through the credential broker’s relay. Google’s Trace User role also carries create, delete and update on Trace-console objects — analysis-report tasks and trace scopes — and no predefined Trace role grants the two reads without them, so a narrower grant is a custom role of your own. The sandbox’s route to Google is the broker, which forwards relayed reads asGETonly and whosegcloudallowlist carries notracecommand, so nothing the model’s code sends through it uses those permissions. A token minted on the annotated ServiceAccount itself — from the gateway Pod, which shares it with the broker, or from anAgentPlugin, which executes with it — carries every role bound to the identity, these permissions included; the sandbox boundary andAgentPluginnotes below say what bounds those paths.roles/iam.serviceAccountUser— act as service accounts when running jobs.roles/iam.securityReviewer— read IAM policy for review.roles/mcp.toolUser— call the GKE MCP server.roles/serviceusage.serviceUsageConsumer— consume API quota for Developer Knowledge MCP queries.
roles/container.viewer is project-wide and unconditioned, so it reads Kubernetes objects in every cluster in the project, and the grants of the scope’s read roles, per project or inherited from a declared folder or organisation, extend that to every cluster in the scope. The scoped service account pool is where that narrowing is designed to land — suspended, so today nothing narrows it; the pool section says why. A binding on a folder or organisation named in spec.scope is inherited by every project beneath it, which is what makes a container worth declaring and also what a grant there reaches. A Shared VPC host or a Metrics Scope named there inherits nothing: each resolves to projects, and every one of them needs its own binding of the read roles, which the Terraform composition makes after resolving the selector at plan time (so a project attached or linked after the last apply is unreadable until the next one); the lookups themselves need compute.projects.get in the host project, the two Resource Manager reads roles/monitoring.metricsScopesViewer carries in the scoping project, and resourcemanager.projects.get in each monitored project it names; the read roles carry all of them between them.
The custom set binds exactly the roles listed in --custom-roles (space- or comma-separated; the installer prompts for it and requires a non-empty value when this set is selected), carried as the composition’s project_roles list — none of the built-in role bundles are added.
If that list names a role like roles/container.admin, the installer says so at the point of choice — it is the authority the removed bundle granted, reached the long way round — and continues. It is your call to make, not the installer’s.
Projects, folders, organisations and selectors in scope
Section titled “Projects, folders, organisations and selectors in scope”A project named in spec.scope.projects (through SCOPE_PROJECTS in install.env, or the composition’s scope variable), a folder or organisation named in spec.scope.folders or spec.scope.organizations (SCOPE_FOLDERS, SCOPE_ORGANIZATIONS), and every project a Shared VPC host or Metrics Scope named in spec.scope.sharedVpcHosts or spec.scope.metricsScopes (SCOPE_SHARED_VPC_HOSTS, SCOPE_METRICS_SCOPES) resolves to at plan time, the scoping project included, gets less than the host project (a Shared VPC host not otherwise in scope gets roles/compute.viewer alone, for the lookup): the read subset of the set above — roles/container.clusterViewer, roles/container.viewer, roles/compute.viewer, roles/monitoring.viewer, roles/logging.viewer, roles/iam.securityReviewer — intersected with whatever the host project was actually granted, and nothing else. What the install creates beyond the agent’s grants, in a project named in spec.scope.projects or resolved from a Shared VPC host or Metrics Scope, is one Log Router sink while the drift detector is on, exporting the project’s GKE admin-activity audit records into the management project’s drift topic, where the subscription retains them for 31 days; a project a SCOPE_EXCLUDE_PROJECTS entry names or a glob there matches, or one TF_VAR_drift_pubsub_source_exclude_projects names, gets none, and a folder’s or organisation’s members get none. roles/iam.serviceAccountUser, roles/mcp.toolUser, roles/serviceusage.serviceUsageConsumer, roles/cloudtrace.user (the observability helpers read the install’s own traces, and its Trace-console permissions go nowhere else) and any role a custom set adds stay in the host project, so a custom list carrying roles/container.admin at home carries no container.clusters.impersonate anywhere else, and the install is refused at plan time when the intersection carries neither roles/container.clusterViewer nor roles/container.viewer, the two roles that list and get clusters (roles/iam.securityReviewer lists but cannot get). Nothing here grants a write anywhere. What does widen is how much one credential can read: roles/container.viewer in each scoped project reads Kubernetes objects in every cluster there, the way it already does at home; a folder or organisation carries the same set plus roles/cloudasset.viewer, bound on the container itself and so inherited by every project beneath it, which is what lets a project created under a declared folder later be discovered and read with no change to the install, and is also why an organisation is wide. roles/cloudasset.viewer is wider than cluster discovery: it lets the one credential search and export the asset inventory and the IAM policies of every resource under the container, not only its GKE clusters, so a container is declared only where reading all of that with one account is acceptable. A Shared VPC host or a Metrics Scope inherits nothing: the read roles land in the projects it resolved to when Terraform last planned, so whoever can attach a service project to the host or link a project into the Metrics Scope chooses where the next full upgrade binds them. An exclude.projects entry that names a Shared VPC service project by ID, or a monitored project by its project number, withholds that grant. Two projects keep theirs: a monitored project excluded by ID, because the reconcile has to name the project with the agent’s own credentials before the exclusion can match, and the scoping project, however it is excluded, because the reconcile reads it to resolve the selector. Removing a project, folder, organisation, Shared VPC host or Metrics Scope from the scope revokes its bindings on the next full upgrade, and uninstalling revokes them all, because Terraform owns them.
Why there is no gke-admin set
Section titled “Why there is no gke-admin set”There used to be a third set, gke-admin, which bound roles/container.clusterAdmin and roles/container.admin. It was removed rather than deprecated because it did not simply widen the ceiling — it removed one:
- GKE authorizes an action if either IAM or Kubernetes RBAC allows it (Access control). A GSA holding
roles/container.adminis authorized by IAM whatever the KSA’s RBAC says, so the read-only Kubernetes footprint below stops constraining anything the agent reaches through that identity. roles/container.adminis the one predefined GKE role that carriescontainer.clusters.impersonate—roles/container.clusterAdmindoes not. GKE grants IAM roles at the project level; a cluster is not a resource an IAM policy attaches to, so “an IAM role grants privileges across all clusters in the project”. The impersonation therefore cannot be narrowed to one cluster.
custom remains, so a deployment that genuinely needs broad roles still has a supported path — it just has to name each role, which makes the grant explicit and reviewable. Setting PLATFORM_AGENT_PERMISSION_SET=gke-admin, or permission_set = "gke-admin" in terraform.tfvars, now fails with an error rather than being silently downgraded. Re-running the install does not strip roles it no longer grants, so an existing GSA has to be brought back by hand — see below.
The scoped service account pool
Section titled “The scoped service account pool”A Terraform install can provision one service account per GCP project in the agent’s scope. The credential broker then mints a short-lived token for the account of the project a kubectl request’s target cluster lives in, rather than running it on the agent’s own identity. Two clusters in one project share an account: the project is the IAM unit the scope is declared in, and the unit an estate of one cluster per project is cut in.
The members are derived, not listed. With scoped_pool_enabled set on the kube-agents-iam module or on terraform/examples/full-install, which passes it through, the module creates an account for the host project, for each project in scope.projects, and for each project a Shared VPC host or Metrics Scope selector resolves to, less an exact exclude.projects entry: the resolved set the plan can list, which is not quite the set the scope’s read roles are bound in. The host project gets an account and no scope binding, since it already carries the agent’s own roles; a project named in scope.projects and in exclude.projects keeps its binding and gets no account; and a monitored project excluded by its ID keeps its binding, because the reconcile has to name it before it can match the entry, and gets no account. A folder’s or organisation’s member projects are listed at plan time while the pool is armed, with the same Cloud Asset Inventory search the reconcile runs, and get their accounts on that apply and no drift audit-log sink (which projects get one is said above), and nothing else: the container’s grant and discovery stay zero-touch. A project created under the folder between applies is discovered and gets its profile at the next reconcile, and every kubectl for it is refused until the next apply adds its account. The listing is one answer from an eventually consistent index with no grace at plan time: a project the index leaves out on one plan loses its account on that apply and gets a new one, under a new unique ID, on the next, so a project whose account must not move is pinned in scope.projects, and the plan warns when a container answers no member at all. The identity that applies needs roles/cloudasset.viewer on the container for the listing, which the installer’s container preflight probes while the pool is armed, and install.sh enables the Asset API in the host project before such a plan (an upgrade that adds the first container and arms the pool in one pass is refused at the plan with the enable command named). A project added to scope.projects gets its account on the apply that grants its roles. A cluster in a project with no account is refused rather than served by a wider credential, with a refusal naming the project and the cluster.
Upgrading an install that armed the pool under the per-cluster field. The CRD update prunes spec.security.scopedServiceAccounts from the stored PlatformAgent, scopedServiceAccountPool.enabled defaults to false, and the operator renders CREDENTIAL_PROXY_SCOPED_SA_POOL=0: every cluster read goes back to the agent’s own identity, which still carries roles/container.viewer, with no Event or condition saying so. An install that drives the kube-agents-iam module directly with scoped_clusters loses that variable, and its next apply destroys the per-cluster accounts. To stay armed across the upgrade, set scoped_pool_enabled (or SCOPED_SA_POOL_ENABLED=true) in the same apply, and expect new per-project accounts in place of the per-cluster ones. No known install had armed the pool.
The accounts are created in the host project, whose service-account quota is 100 by default and is shared with the agent’s own accounts and everything else there. scoped_pool_max_accounts is the bound you declare from the headroom that quota has free; its default is the quota itself, so a large pool at the default can still meet the quota mid-apply. The module refuses a plan whose derived set exceeds the declared bound, and an estate above the quota raises it first and the bound with it.
The narrowing that was to go with it is suspended. The agent’s own GSA was to drop roles/container.viewer whenever the pool was populated, keeping roles/container.clusterViewer — able to enumerate clusters and run get-credentials, unable to read anything inside one. The two changes are coupled because neither is safe alone: narrowing the agent while the pool grants nothing breaks every read, and arming the pool while the agent stays wide leaves the ceiling the pool exists to remove. Both are off, and a test holds the two halves together.
The module grants the agent roles/iam.serviceAccountTokenCreator on each pool member as a resource, never at project level. At project level that role would let the agent mint a token for any service account in the project, which would make the pool decorative.
The mapping reaches the broker through spec.security.scopedServiceAccountPool on the PlatformAgent CR: enabled, and one serviceAccounts row per project with its projectId and serviceAccountEmail. The operator renders the rows into the credential-proxy ConfigMap and sets CREDENTIAL_PROXY_SCOPED_SA_POOL on the broker container from enabled alone. That variable is written in both directions, so which credential mode an install is in is readable from the Deployment rather than inferred from what is absent.
What the pool does not cover. It scopes kubectl, and only kubectl, through the credential broker. gcloud also goes through the broker but stays on the agent’s own identity — it names its target in argv, and deciding scope from argv would put a parser where the boundary belongs — and so does the forge work the version-control verbs ask the broker to do. The sandbox’s own git is local and holds no credential, and there is no gh in it. The gke MCP server goes through the broker too, on the broker’s own credential rather than a pool member: what bounds it is the broker’s per-tool allowlist, which relays the server’s read-only tools only, and then the size of that grant. What bounds gcloud and the forge work is the size of the agent’s own grant, which is the other half of why roles/container.viewer was to come off it.
Kubernetes RBAC
Section titled “Kubernetes RBAC”Independently of the GCP permission set, the operator grants the agent KSA a read-only footprint on the Kubernetes API, plus one namespaced housekeeping Role. It creates three bindings (see platformagent_manifests.go):
| Binding | Role | Grants |
|---|---|---|
kubeagents:minimal:<namespace>:<name> |
custom minimal ClusterRole | Cluster-wide read access (get/list/watch) to audit resources (nodes, pods, deployments, configmaps, services, etc.) and to the GitOps reconcilers’ sync objects (Argo CD Applications, Flux Kustomizations and Git and OCI sources, Config Sync RootSyncs and RepoSyncs), whose inline Helm values and substitutions it can therefore read — excluding Secrets and RBAC. |
kubeagents:local:<namespace>:<name> |
custom namespaced Role | Read access (get/list/watch) to PlatformAgent CRs in the agent’s own namespace only. |
kubeagents:leader:<namespace>:<name> |
custom namespaced Role | Housekeeping in the agent’s own namespace only: write on coordination.k8s.io leases (leader election), plus get/patch on pods only above one replica, which the operator does not render today. |
For the default CR (platform-agent in kubeagents-system) the bindings resolve to kubeagents:minimal:kubeagents-system:platform-agent, kubeagents:local:kubeagents-system:platform-agent, and kubeagents:leader:kubeagents-system:platform-agent.
The Kubernetes minimal and local roles carry no write verbs (create, update, patch, delete) and grant no read access to Secrets or cluster RBAC. (The GCP IAM read-only permission set independently provides cluster-viewer read via Cloud IAM for audits connecting through gcloud container clusters get-credentials.) The only write grant anywhere in Kubernetes RBAC is the leader Role, and it is confined to the agent’s own namespace — leader-election leases, plus get/patch on pods there when the deployment resolves to more than one replica (the leader labels its own pod so the Service routes to it; at one replica that code never runs and the rule is not rendered). The count that matters is the effective one: the operator caps the gateway at one replica whatever spec.deployment.availability.replicas asks for (the ReplicasLimited condition reports it), and spec.deployment.scaleToZero: true resolves to zero, so no install renders the pods rule today. The agent cannot modify Deployments, Services, or namespaces, and it cannot read Secret values — if a resource it proposes needs a Secret, it references the Secret by name rather than reading its contents.
Verify the bindings on a running cluster:
kubectl describe clusterrolebinding kubeagents:minimal:kubeagents-system:platform-agentkubectl describe rolebinding -n kubeagents-system kubeagents:local:kubeagents-system:platform-agentkubectl describe rolebinding -n kubeagents-system kubeagents:leader:kubeagents-system:platform-agentThe admission backstop on agent RBAC
Section titled “The admission backstop on agent RBAC”The RBAC above is what the operator creates. Alongside it, two cluster-scoped ValidatingAdmissionPolicy objects reject some agent RBAC at apply time, whoever applies it. One source, k8s-operator/config/admission/agent-rbac-policy.yaml, and which installs apply it depends on how you install:
| Install method | Ships the policies? |
|---|---|
The install engine — install.sh, or Terraform + Helm directly (Methods 0/1) |
Yes on Kubernetes 1.30+ — templates/agent-rbac-admission-policy.yaml, gated on admissionPolicy.enabled (default on) and on the cluster version. |
Manual make install && make deploy (Method 2) |
No — apply the source yourself; INSTALL.md Method 2 Step 4 has the command. |
They are outside the kustomize overlay on purpose: its namePrefix rewrites each policy’s name but not the spec.policyName its binding refers to, which would leave the bindings pointing at nothing and the policies inert with no error. A plain kubectl apply has no such transform.
They need Kubernetes 1.30 or later, where the policy API reached v1. The chart template checks the cluster’s version as well as the values gate, so on 1.29 it renders nothing rather than failing the install — which also means an install there is not backstopped at admission and nothing says so at the time.
What they govern is narrower than “agent RBAC”, and the gap is worth knowing before you rely on them. Policy 1 selects only objects carrying the kube-agents/tier label. Nothing the operator creates carries it — the operator stamps the four app.kubernetes.io/* labels and no tier — so on a default install with no GitOps overlay, policy 1 matches nothing at all. It is a backstop on the RBAC your overlay writes, not on the RBAC the operator mints. Policy 2 does see the operator’s kubeagents:minimal:* ClusterRoleBinding, whose subject ends in -agent, and deliberately exempts it: the operator has no namespace-tier path, so the one case policy 2 denies is one it cannot produce.
| Policy | Governs | Denies |
|---|---|---|
kube-agents-agent-readonly |
Role / ClusterRole labelled kube-agents/tier |
Any verb outside get/list/watch; any rule reaching secrets; a ClusterRole for the developer-team tier. |
kube-agents-agent-binding-scope |
RoleBinding / ClusterRoleBinding whose subject is a *-agent ServiceAccount |
A ClusterRoleBinding to developer-team-agent. |
What they do not cover, stated plainly because a backstop misread as complete is worse than none:
- They cannot check the role a binding points at. CEL in a
ValidatingAdmissionPolicysees only the object being admitted, so an unlabelled writeRolebound to an agent ServiceAccount is admitted. Closing that needs a cross-object webhook, which is not built. - The content policy is label-selected.
kube-agents-agent-readonlyonly looks at objects carryingkube-agents/tier. A hand-written manifest that omits the label is not examined at all; pull-request review is what catches that. The binding-scope policy is not evadable that way: it keys on the ServiceAccount being privileged, which the binding cannot omit. Nor by adding something — its one exemption, for the operator’s own reconcile, matches onrequest.userInfo.username, which the API server fills in from the authenticated request and no manifest can carry. That distinction is load-bearing rather than pedantic:matchConditionsare ANDed, so a false one drops the object from the policy altogether, and an exemption an author could satisfy from inside the manifest would be a bypass of the whole rule rather than a carve-out from it. - Policy 2 denies exactly one ServiceAccount name. A cluster-scoped binding to any other agent ServiceAccount is admitted. Combined with the label point above, this means an unlabelled
ClusterRolegranting*on*, bound to the agent’s own ServiceAccount, is admitted by both policies on a default install. Pull-request review on the GitOps repository is the control for that, which is what the branch-protection guidance shipped with the example overlay is for. - Policy 2 only examines
kind: ServiceAccountsubjects. A binding whose subject is akind: Group—system:serviceaccounts:team-x, say — is not selected. The subject is unforgeable for the kinds the policy looks at; choosing a different kind steps outside them. - They govern agent RBAC, not the operator’s own. The controller’s ClusterRole below is unlabelled and out of scope by design.
The operator controller is a separate identity
Section titled “The operator controller is a separate identity”Everything above describes the agent. The controller-manager that reconciles PlatformAgent CRs runs under its own KSA, kubeagents-controller (the kustomize namePrefix: kubeagents- applied to the base controller ServiceAccount), and its Kubernetes permissions are the Kubebuilder-generated ClusterRole in k8s-operator/config/rbac/role.yaml (regenerated with make manifests): write access to the object kinds it reconciles for the agent pod — Deployments/StatefulSets, ServiceAccounts, Services, ConfigMaps, PVCs, NetworkPolicies, PodDisruptionBudgets, and the agent RBAC objects above — plus, for the mode: next A2A stack, Secrets (the generated NATS credentials and config — by name only: the controller holds no list or watch on Secrets, so it cannot enumerate them), batch Jobs (the bus provisioning run), and ResourceQuotas (the session-pod bound) — plus create and patch on Events, for the Warning it records on the PlatformAgent when the bus provisioning Job finds a stream it could not change (see the CRD reference) — plus read-only access to what it merely watches (nodes, namespaces, CRDs, RuntimeClasses), and read-only access to the GitOps reconcilers’ sync objects (Argo CD Applications, Flux Kustomizations and Git and OCI sources, Config Sync RootSyncs and RepoSyncs), which it never reads and holds only so Kubernetes lets it grant them to the agent. On every install, not only under mode: next, the controller also reads the values of the Secret keys the agent pod consumes as environment, and digests them onto the pod template so that rotating one rolls the pod (PlatformAgent CRD); which Secrets those are is decided by the rendered pod spec, so a CR-supplied or plugin-supplied SecretKeyRef widens it to that Secret too. Those reads are by name and uncached like the rest, the digest is an HMAC-SHA256 keyed by the UIDs of the Secrets it reads from so the annotation cannot be used to verify guesses at a value, and the list/watch ceiling is unchanged — the controller still cannot enumerate Secrets, and tests/test_operator_secrets_grant.py fails if that changes. The controller verifies at startup, and every five minutes after on its own ticker, that the role bound to it grants every permission the +kubebuilder:rbac markers that generate role.yaml declare, and reports a shortfall as a Degraded/RBACIncomplete condition on each PlatformAgent — the signature of an image deployed ahead of its manifests; the operator page has the cadence and the make deploy guard that prevents it. One controller permission is cluster-scoped and reaches beyond the objects above: bind on the built-in system:auth-delegator ClusterRole, granted by name. It lets the controller bind the mode: next auth callout’s ServiceAccount to that role, which is what permits the callout to create TokenReviews and so validate the ServiceAccount tokens bus clients present. Two things about it are worth stating plainly. It ships on every install, not only under mode: next, because the ClusterRole is generated whole rather than templated per mode. And bind is one of the three RBAC verbs that let a subject hand out authority it does not exercise itself — bind, escalate and impersonate — held here in its narrow, resourceNames-scoped form, which permits granting that one role and confers none of its permissions on the controller itself. What it is not is a substitute for clusterroles: create: the controller has held unscoped create/update/patch/delete on clusterroles and clusterrolebindings since long before the callout existed, in the same generated file. What bounds that pre-existing grant is the API server’s escalation check — no subject may author or bind a role conferring permissions it does not itself hold — together with the absence of escalate, the verb that waives the check. The bind grant is needed for one specific reason: system:auth-delegator also carries subjectaccessreviews: create, which the controller does not hold, so without bind the API server refuses the binding with attempting to grant RBAC permissions not currently held. Binding the role kube-apiserver ships is the conventional pattern for a TokenReview client and keeps the grant auditable by name; a narrower alternative — a controller-authored ClusterRole carrying only tokenreviews: create, which is all the callout calls, and which needs no new controller permission — is not what ships today. Because it is a +kubebuilder:rbac marker like the rest, the startup self-check above covers it: an install whose ClusterRole predates it reports RBACIncomplete rather than failing to reconcile silently.
Unlike the agent, the controller has no GCP identity: no install path creates a controller GSA or Workload Identity binding for it.
The Vertex AI gateway is a separate identity
Section titled “The Vertex AI gateway is a separate identity”With MODEL_PROVIDER=vertex_ai the LiteLLM gateway gets its own KSA (kubeagents-litellm) bound to its own GSA (kubeagents-litellm-gsa), holding exactly one role — roles/aiplatform.user on the serving project — and nothing else. It is deliberately not the agent’s identity: the gateway is a network-exposed proxy that forwards attacker-influenceable prompt content, so it is scoped to calling models and cannot touch GKE, logging, or the GitOps path. With model_provider = "vertex_ai" the full-install composition instantiates the kube-agents-iam module a second time for the gateway — the GSA, roles/aiplatform.user on the serving project, and the Workload Identity binding — and the Helm chart renders the annotated KSA; because the pair is Terraform-managed, switching away from vertex_ai removes it on the next apply and leaves no orphan. The exception is an install with VERTEX_MANAGE_SERVING_PROJECT=false, for a serving project the installing identity cannot administer: there the operator makes the roles/aiplatform.user grant by hand, Terraform never owns it, and the operator removes it. The other providers give the gateway no GCP identity at all — it authenticates with an API key from platform-agent-secrets, or, for sap, any other LiteLLM provider slug and an openai_compatible gateway behind OAuth2 client credentials, with the contents of a Secret you create and name with --litellm-env-secret, which the install never creates, stores or rewrites (the credentials Secret). With openai_compatible the key is optional and is read from OPENAI_COMPATIBLE_API_KEY in platform-agent-secrets, and the gateway pod receives none of the Gemini, OpenAI or Anthropic keys, so nothing it sends to your server can carry one.
What leaves for the model provider
Section titled “What leaves for the model provider”The gateway is the one point every provider request transits, and the chart can make it redact those requests: litellm.redaction.enabled=true (LITELLM_REDACTION_ENABLED=true in install.env, or the Terraform example’s litellm_redaction variable) runs the shared AuditRedactor as a LiteLLM pre-call hook, masking the built-in credential shapes and masking or pseudonymising IP literals and any operator-named identifiers before the body leaves the cluster. It is off by default, it covers request bodies only, and a pseudonymised identifier is one the agent can no longer act on; Inference gateway owns the rules, the actions and that limitation.
Egress when model data must stay off hosted providers
Section titled “Egress when model data must stay off hosted providers”With MODEL_PROVIDER=openai_compatible every model request goes from the gateway to a server you run, and the gateway’s NetworkPolicy gains one rule, derived from that server’s URL: the server’s namespace on its pod port for an in-cluster Service, or the CIDRs you list on the URL’s port for a private address (Inference gateway has the forms). That keeps prompts off a hosted model. It does not make the install offline. A fully disconnected cluster is not supported: Google APIs have to be reachable, and Private Google Access is enough for that. Its private.googleapis.com and restricted.googleapis.com ranges are outside the private ranges the generated policies exclude, so their existing 443 rules already reach them.
What else leaves the cluster, and whether you can turn it off:
| Destination | Who calls it | Required | How to turn it off |
|---|---|---|---|
Google APIs (container, developerknowledge, IAM and STS, Logging, Monitoring, and so on) |
The agent’s gke and developer_knowledge MCP servers, the credential broker’s gcloud and kubectl, Workload Identity, GKE’s own agents |
Yes | Cannot be turned off. Use Private Google Access. |
| The image registry | The nodes, when they pull images | Yes | Mirror the images into a registry the nodes reach privately: Private / custom registry. |
GitHub (github.com, api.github.com) |
The token minter and the broker’s version-control verbs, for GitOps on GitHub | No | Install without GitOps, or put the GitOps repository on a GitLab instance on your own network (GitLab). |
| Slack, Google Chat | The chat integrations | No | Leave them off (--enable-slack, --enable-google-chat unset). The agent is then reached from a terminal. |
| DuckDuckGo | The agent’s web toolset (web.backend: ddgs), on in every profile except the Chat Agent’s |
No | No install setting reaches them: toolsets come from the profile configs baked into the agent image. Add web to agent.disabled_toolsets in those configs and build your own image, or block the traffic at the VPC firewall. A blocked call fails that one tool call, and the agent carries on without it. |
raw.githubusercontent.com |
LiteLLM, for its model-price table at startup | No | Not fetched with openai_compatible, which sets LITELLM_LOCAL_MODEL_COST_MAP=True. With other providers a failed fetch falls back to the table in the image. |
The install itself runs from a workstation, not the cluster, and that machine reaches the internet for Terraform providers, the installer’s own tools and the cert-manager chart on charts.jetstack.io (SKIP_CERT_MANAGER=true skips that chart on a cluster that already runs cert-manager).
Each A2A session pod is its own bus identity
Section titled “Each A2A session pod is its own bus identity”docs/designs/spec-nats-deployment.md is canonical for the identity map, the grant sets and how they are derived; this section is the summary. Under mode: next the chatops gateway spawns a pod for a delegated task, which runs that one task, or for a conversation on its session route, which keeps one pod for the whole conversation: that pod takes each of the conversation’s turns, holds its bus credential the whole time, and is reaped at the idle TTL (30 minutes by default) after the last user message, or at the 4-hour maximum lifetime. That pod is where model output actually executes. Any other chat message is answered in the agent pod instead, as ChatOps describes. The pod runs as <agent>-a2a-session, a KSA that holds no Kubernetes RBAC and no Workload Identity binding: the token exists so the message bus can be told who is connecting, not so the pod can talk to the API server or to GCP. A NetworkPolicy denies it the API server anyway, and automountServiceAccountToken is false so no default-audience token rides along beside the bus one. With the operator flag A2A_SESSION_CLUSTER_VIEW=true the pod also projects a second audience-bound token, for the credential broker: the broker maps that audience to a session role that reaches its exec route for kubectl and gcloud only, read-only, and refuses every other route and executable. The pod still holds no API-server credential and the ServiceAccount still has no RBAC; the view is the broker’s, not the pod’s. A session is limited to a small number of concurrent broker commands (CREDENTIAL_PROXY_SESSION_MAX_CONCURRENT_COMMANDS, default 2), so it cannot hold the command pool the platform agent’s shell shares. That cuts both ways: it is read-only in verbs, but it reads with the install’s permission set, so under a custom set with an admin role the broker’s allowlist is the only control and kubectl get secret returns data (Credential isolation says the same of the platform agent). Off by default, and a temporary aid until declarative profiles.
Every session pod shares that one KSA, so the KSA alone cannot tell two conversations apart. What can is the pod. The pod’s bus credential is a projected ServiceAccount token with a dedicated audience, which the kubelet binds to the pod it mounts it into. When the pod presents it, the bus’s auth callout runs a TokenReview, and the API server reports that pod’s name and UID in fields the client cannot set. The gateway names each pod after the conversation it spawned it for, so the attested pod name is the addressee, and the permissions minted for the connection are built from that name: its own task’s events, its own three JetStream consumers, its own reply inbox. Nothing else on the bus, including the pods running beside it.
Two consequences worth stating plainly:
- The model harness can read the credential, and that is accounted for. The session adapter is PID 1 and the harness is its child at the same UID, so anything the adapter holds — an environment variable, a mounted file — is readable through
/proc. No amount of care in how the child’s environment is built changes that. What changed is not reachability but worth: the credential a harness can lift now authorises exactly the session it is already running as. Before this, every session pod held one shared password that could publish events for any addressee and read the whole task plane. - Revocation has two clocks, and the pod is only the first one. Deleting the pod stops the API server authenticating its token, so a new connection is refused — not instantaneously, but within about ten seconds of a zero-grace delete, which is the API server’s success cache rather than the token’s one-hour lifetime. A connection that is already open is the second clock and a longer one: the callout signs the grants into a user JWT at connect and the bus holds them until that JWT expires, so deleting the pod does not close the socket. Reaping a session bounds its bus access at the grant TTL, not at the pod’s lifetime. This matters much less than it would have before per-session credentials — the grants a stale session keeps reach only its own already-dead task — but it is the real bound, and shortening it means shortening the grant TTL rather than reaping faster.
Four of the files a kanban worker writes to the agent’s volume are redacted inside the image, independently of that gateway switch. Upstream Hermes already formats agent.log through its RedactingFormatter and passes the terminal-output spill under cache/terminal-output/ through the same engine. The image adds the worker transcript, because a worker that hand-rolls an authenticated curl after a helper script fails otherwise leaves the access token there in plaintext for the hour it is valid. The ya29. access-token shape is registered alongside the engine’s built-in credential prefixes, so a bare token is masked wherever the engine runs, not only inside an Authorization header. The 💻 $ completion line and the tool preview mask a terminal command or execute_code source before rendering it. A worker’s stdout and stderr, which the dispatcher points at the board’s logs/<task>.log, are redacted line by line on the way to that file, and that redaction is forced: security.redact_secrets: false does not reopen it. It runs only in a process with HERMES_KANBAN_TASK set and a non-terminal stdout, holds a partial line until its newline, and does not see writes that bypass sys.stdout. The audit file the tool_call_audit plugin and the chat_message_audit hook append to, logs/audit.jsonl under each profile, is redacted twice before the write: by the emitters themselves, AuditRedactor over arguments, results and message text and a pseudonymised principal, and then as one line by the same engine RedactingFormatter runs, Hermes’ redact_sensitive_text, which the sink applies forced so that security.redact_secrets: false does not reopen it; the record no longer passes through Hermes’ logger, so the formatter itself is not in its path, and the same two layers cover the line an emitter prints to its stdout when the file cannot take it.
The session store is not covered. Hermes persists every turn, tool calls included, to state.db on the same volume without redaction, so a command that carried a token is still there verbatim after the transcript has masked it. Nothing already on a volume is rewritten either. deploy/docker/patches/credential_redaction.py documents the mechanism and its limits.
Configuring read-only (auditing) mode
Section titled “Configuring read-only (auditing) mode”read-only is the install default, so a fresh install already runs in this posture. To pin it explicitly, or to bring a deployment provisioned with the removed gke-admin set back to it:
-
With the installer (recommended) — accept the default option in its permission-set menu, or pass it explicitly (a re-run reconciles the change through one
terraform apply):Terminal window curl -fsSL https://raw.githubusercontent.com/gke-labs/kube-agents/<RELEASE_VERSION>/install.sh | bash -s -- \--permission-set=read-only -
With a hand-driven Terraform composition — set
permission_set = "read-only"interraform.tfvarsand apply. -
On an existing GSA provisioned with the old
gke-adminset — re-running the install will not strip roles it no longer grants, so swap the admin roles for viewers by hand:Terminal window PROJECT_ID="your-gcp-project-id"GSA_EMAIL="kubeagents-platform-gsa@${PROJECT_ID}.iam.gserviceaccount.com"# Remove the admin rolesfor role in roles/container.clusterAdmin roles/container.admin roles/monitoring.admin; dogcloud projects remove-iam-policy-binding "${PROJECT_ID}" \--member="serviceAccount:${GSA_EMAIL}" --role="${role}"done# Add the read-only roles -- all ten, not just the three removed above.# add-iam-policy-binding is idempotent, so naming one the GSA already holds# costs nothing.for role in roles/container.clusterViewer roles/container.viewer roles/compute.viewer \roles/monitoring.viewer roles/logging.viewer roles/cloudtrace.user \roles/iam.serviceAccountUser roles/iam.securityReviewer roles/mcp.toolUser \roles/serviceusage.serviceUsageConsumer; dogcloud projects add-iam-policy-binding "${PROJECT_ID}" \--member="serviceAccount:${GSA_EMAIL}" --role="${role}"doneThe ten are
local.read_only_rolesinterraform/examples/full-install/main.tf.
The Kubernetes RBAC above is already read-only in every mode, so no cluster-side change is needed. Neither is the GitOps path affected: the agent proposes pull requests under every permission set, because what makes it propose rather than apply is Kubernetes RBAC, not the IAM set.
Secure write path: GitOps
Section titled “Secure write path: GitOps”Because the agent’s Kubernetes RBAC is read-only, remediations are proposed rather than applied:
- The agent invokes the
submit-suggestionskill with a proposed diff — or, for a scheduled fleet audit, thefleet-auditskill with a validated findings file. - The skill’s helper commits to a topic branch and asks the credential broker to publish it; the broker — not the helper — calls Minty for a short-lived GitHub App token.
- A Pull Request is opened against your GitOps repository.
fleet-auditpublishes its report as one GitHub issue per audit stream — the ledger, rewritten in place each run — and opens a narrow Pull Request only for a finding whose fix is a manifest, linked back to that ledger. One more machine-owned issue exists:chat-delivery-watch, a scheduled script with no model in the loop, opens, edits and closes a single issue labelledagent:delivery-watchin that repository (or the oneCHAT_DELIVERY_LEDGER_REPOnames) when scheduled reports stop reaching chat, and creates that label if it is missing; the relay design has the reasoning. - A human reviews and merges; a GitOps controller (Argo CD, Flux) reconciles the change into the cluster.
Both paths share the same guardrails: the helper stages the paths it is named — or, named none, only files the working copy already tracks — never git add . or git add -A, and pushes to main, master, and production are hard-blocked. A gitops or managed repository that sets baseBranch adds that branch to the blocked pushes and pins that repository’s pull requests to it: the credential broker refuses one opened onto any other branch, and pull requests opened before the field was set keep their base. The only branch the version-control verbs will delete is a spent one of the agent’s own: under platform-agent/, with no open pull request, and still at the revision a closed or merged pull request from the same repository carried — one this install opened, whenever its credential can name its own login. The submit-suggestion path carries one the audit path does not — a file the agent never staged itself is refused by name rather than swept into the change.
The agent never has direct write access to running infrastructure — see Declarative workflow.
Change control & safety
Section titled “Change control & safety”-
No direct cluster writes. Enforced by RBAC (above) and by the persona’s automation-first stance — the agent does not
kubectl apply; a change you asked for becomes a pull request, and a diagnostic request gets its fix in the reply rather than on your repository. See Platform Agent and Declarative workflow. -
No merging its own work. The agent may open pull requests, issues, and comments; the proxy refuses the verbs that would let it merge, approve, trigger a pipeline, or administer the repository. This is what keeps the review gate a gate rather than a convention. See Credential isolation.
-
No credentials in the sandbox. API keys, chat tokens, and ServiceAccount tokens live only in the credential broker Pod. (One carve-out under
mode: next: the agent container carries a bus credential of its own — an audience-bound ServiceAccount token projected into the container, plusNATS_URLandA2A_BUS_USERnaming the principal it authenticates as. No password: the bus resolves the token through an auth callout. Both env names are appended after the plugin env merge and blocked fromspec.deployment.envbySensitiveEnvVars; the credential appears and disappears with the A2A stack, and the sandbox Pod never gets it. From the other direction, the reservation has two halves. By source, two shapes of user-authored volume onextraVolumesorsidecarVolumesare refused: a projection of thea2a-busaudience under any name, which would hand a second container this same token, and asecretvolume or projectedsecretsource naming one of the three<agent>-a2a-*Secrets, which carry the$SYSpassword and the callout issuer seed rather than this token. (Only those two source fields – the ones that mount a Secret into the container. Acsi.nodePublishSecretRefhands it to a node plugin instead and is not matched.) By name,a2a-bus-tokenis reserved across all five user-authored volume and mount fields, and the mount half is the one that matters most: asidecars[].volumeMounts,initContainers[].volumeMountsorextraVolumeMountsentry naming the operator’s own rendered volume is a second workload wearing the agent’s bus identity, and it needs no user-authored volume at all. On an install running the A2A surface the render drops the same entries again, plus any mount naming a volume the source half already dropped. See PlatformAgent CRD § Reconcile behavior. That is a guard against a misconfigured CR rather than a boundary: ServiceAccount tokens are pod-scoped, so a container already running in that Pod is not held out by it.) The agent container has nogcloud,kubectl,ghorgitat all; the sandbox Pod that runs model-authored code gets wrappers that forwardgcloudandkubectlto the broker through a policy-enforced proxy, reaches a repository or forge through the broker’s verbs, files a confirmed report about kube-agents itself through the broker’s feedback verb (the public form needs no credential, and the broker reads the public tracker to find the issue; this is the sandbox’s one write to a public destination, bounded as Credential isolation describes), and keeps a credential-less localgitfor working copies. See Credential isolation.This is a filesystem, environment and identity boundary for the Pod that runs model-authored code, and for the gateway. Everything the agent executes runs in the
<name>-shellPod, whose ServiceAccount carries noiam.gke.io/gcp-service-accountannotation, so the link-local metadata server at169.254.169.254answers it with a principal IAM grants nothing. The gateway Pod runs as<name>-gateway-agent, which carries none either, and its NetworkPolicy does not open the metadata path. The Workload Identity-annotated ServiceAccount is the credential broker’s alone. What each control costs and what is still open are on Credential isolation, which owns the topic. -
Cloud API reads go through the same broker. A script in the sandbox that needs a Cloud Monitoring or Cloud Trace read sends
GET /v1/gcp/<host>/<path>to the credential broker, which checks the host, method and path against the read-only allowlist inagents/platform/scripts/api_policy.py(the MonitoringtimeSeriesandmetricDescriptorslists, the Managed Prometheus query endpoints, and the Cloud Tracetraceslist and single-trace get today), forwards it on its own identity with only its own two headers, and returns the upstream response unchanged. Token endpoints are refused before the table is read, redirects are not followed, the route is open to the shell role only, and a refusal is a 403 naming agcp.api.*rule. Widening the table is a pull request. The design and its security review are indesigns/gcp-api-relay.md. -
A shipped skill cannot be rewritten through the agent’s own tools. The agent’s own
skill_managetool refuses every write action that would touch a skill the image ships for that profile (astaton the read-only template tree, per profile), and the file tools (write_file,patch,delete_file,move_file) refuse any target under a profile’sskills/orscripts/tree, so the gateway’s copy of a shipped skill, the one the prompt reads, cannot be rewritten from the agent’s tools between two restarts, which is where an agent that hits a gap in a skill would otherwise patch its own instructions. The shell sandbox’s copy of the same trees is root-owned and mounted read-only in the sandbox Pod, so what runs there (a shell command,execute_code, or a file tool writing through a symlink made in the sandbox, which the gateway-side guard cannot see) cannot rewrite it either (Agent shell sandboxing). -
The prompt material baked into the image is the material it was built with. A
SKILL.mdis instructions the agent follows, aSOUL.mdis the persona it follows them as, and a skill’sscripts/*.pyruns with the agent’s credentials, so all three are part of the trust boundary. Ownership is the barrier: the image’s specialist templates (/opt/platform-templateand/opt/cluster-templatein full — persona, config and skills alike), the shared skill tree at/opt/hermes/skills, the Hermes plugins at/opt/hermes/plugins(Python imported into the agent’s own process), the shared scripts at/opt/defaults/scripts(which include the manifest checker itself, in the sidecar as well as the agent container) and the Chat Agent’s config template at/opt/chat-template(which every boot back-fills absent keys into the live default profile from) are root-owned while the agent runs as uid 10000, so the agent cannot rewrite what the entrypoint installs into every profile on the next boot. That sits beneathreadOnlyRootFilesystem, which the operator already sets on every container it builds; ownership is what still holds for the image run outside this operator, for a container given a writable root filesystem, and during the build itself. A build-time manifest is the detection half: the build writes a SHA-256 of every file in each baked skill tree into that tree, and the entrypoint re-checks it at startup before any of it is copied to a profile — Container entrypoint is canonical for what happens when it does not match. The personas, plugins, shared scripts and config template get the barrier but not the hash: they are covered against the agent, not against a corrupted layer. What this does not cover: the per-profile copies on the agent’s$HERMES_HOMEvolume, legitimately rewritten after the copy by profile scaffolding and by the hourly cluster reconcile, so they have no fixed checksum to compare against; the rest of/opt/defaults, the Chat Agent’s own default profile, which stays agent-owned and is still read back from the image after the boot copy (cron/jobs.jsonby the cron reconciler,plugins/by profile scaffolding and by the OTel config generator,onboarding/by the onboarding plugin); the rest of/opt/hermes—gateway/,tools/and the venv interpreter — whose ownership comes from the Hermes base image and which this build does not assert; skills a plugin brings in, which arrive from an OCI image rather than this one; and tampering by anything running as root inside the pod. The first three are residue that only the read-only root filesystem closes. Read it as an integrity check on the image, backed by an ownership barrier — not as a runtime sandbox. -
Network isolation and egress boundaries. The Platform Agent is protected by
NetworkPolicyobjects the operator renders, among them*-gateway-netpolover the agent Pod,<name>-credential-proxyover the broker and<name>-shellover the sandbox; no static copy of any of them ships:- Ingress: Restricted to essential Hermes API (
8642), Envoy Credential Proxy API (8643), and conditionally dashboard (9119) ports from pods within the agent’s own namespace (podSelector: {}), plus the event watcher’s Prometheus metrics port (9095) and the chat plugin’s (9097) from pods in thegke-gmp-systemnamespace, where GKE Managed Prometheus’s collectors run, and from the operator’s own pods (its namespace andapp.kubernetes.io/name: kube-agents-operator), which read the watcher’s accepted-event counter and per-cluster up gauge, and the chat plugin’s inbound-message counter, intostatus.usage. The watcher’s listener serves/metricsand/healthzunauthenticated; its series carry cluster, project, location, namespace and event-reason labels and no pod names or event text, the same class of exposure as the LiteLLM gateway’s/metrics. The chat plugin’s serves/metricsalone, also unauthenticated: one counter under a closedplatformvocabulary and the process start time, no sender, text or space. The rule is rendered whether or not the chart’sPodMonitoringfor the gateway pod (platformAgent.podMonitoring) is, so that switching the scrape off is the one thing that stops it. - The credential broker’s own policy (
<name>-credential-proxy, ingress only) admits the sandbox and the gateway pods on the credentialed port8765, and thegke-gmp-systemnamespace and the operator’s own pods (the same namespace-and-label selector) on the broker’s metrics-only port8766, and nothing else on either by selector. Above one replica the agent’s own Role would holdgetandpatchon the pods of its namespace, which on a chart install is the operator’s, so it could label a pod to match the operator’s selector. The operator runs the gateway as a single replica today, so no install renders that grant; where one did, it would gain the three listeners’ counters, the watcher’s and the chat plugin’s, which the agent container already reaches over its own pod’s loopback, and the broker’s, to which it has no route today: integers under closed label vocabularies, no route, credential or policy, and nothing else; the collector never has a route to the credentialed listener. Credential isolation is canonical for that pod. - Egress (Internal Services): DNS on TCP/UDP port
53to CoreDNS/NodeLocal DNS pods, the cluster’s DNS ClusterIP, and the metadata address169.254.169.254/32, which is the resolver rather than kube-dns on a Cloud DNS for GKE cluster — Credential isolation is canonical for why a resolver peer at a metadata address reaches no token. The DNS ClusterIP is the one discovered from thekube-system/kube-dnsService (or set by annotation, the typed field or the operator environment); the classic10.96.0.10/32is rendered only when discovery and every override yield nothing, because a fixed literal cannot know which ClusterIP a cluster’s service range yields (newer GKE clusters allocate from public space, e.g.34.118.224.0/20). The operator does not detect Cloud DNS, so the resolver peer is unconditional. The metadata server’s credential ports (80, and the Workload Identity daemon’s988) are not allowlisted: the gateway Pod holds no Google identity. Also allowlisted: LiteLLM Gateway and Standalone Replay pods (app: litellmandapp: standalone-replayon TCP ports80,4000, and8080), vLLM inference servers (app: gemma-serveron TCP ports80and8000), GitHub Token Minter pods (app: github-token-minteron TCP port8080), and — when the agent is exporting telemetry — the resolved collector’s namespace on TCP ports4317/4318(gke-managed-otelby default). The operator omits that last rule entirely whenstatus.telemetry.otlpEndpointSourceisNone, meaning discovery found no collector and the agent exports nothing. One further rule covers the Hindsight memory API (app.kubernetes.io/name: hindsightandapp.kubernetes.io/component: apion TCP port8888, so it does not also reach the Postgres pod, which carries the same name label). It is rendered unconditionally and matches no pod on an install that did not choose a Hindsight-backed memory provider. - Egress (Control Plane, Fleet & External): Allowlisted to the internal Kubernetes Control Plane API server (the resolved control plane endpoints, falling back to
10.96.0.1/32when discovery yields nothing, on TCP ports443/6443/8443, as well as remote private fleet cluster CIDRs and PSC endpoints configured viakubeagents.x-k8s.io/apiserver-cidr/kubeagents.x-k8s.io/custom-egress-cidrs), and external HTTPS destinations (0.0.0.0/0and::/0on TCP port443excluding private subnets10.0.0.0/8,172.16.0.0/12,192.168.0.0/16, and100.64.0.0/10to block internal lateral movement). - Defense-in-Depth Layering: Environments requiring stricter egress control over HTTPS (
443) should combine this with Private Google Access / VPC Service Controls CIDR blocks or FQDN-based egress filtering (e.g.,FQDNNetworkPolicyon Dataplane V2). - Everything in this bullet assumes the cluster enforces NetworkPolicy. On a cluster with neither Dataplane V2 nor the Calico addon, every policy named here is accepted by the API server and enforced by nothing. The installer refuses such a cluster unless told to enable Calico or to install without enforcement; an install made through the Terraform composition that chose the latter carries
kubeagents.x-k8s.io/network-policy-enforcement: absent-acceptedon thePlatformAgent. The annotation records that choice; whether the cluster enforces today is the cluster’s owndatapathProviderandnetworkPolicy.enabled, which the prerequisites table shows how to read. Installing without NetworkPolicy enforcement lists what is unenforced. - How the egress boundary can move. Three
PlatformAgentspec fields move it from the CR:spec.networkPolicy.enabled: falsestops the operator generating a policy at all and deletes the policies it manages (<name>-gateway-netpol,<name>-fqdn-netpol, andlitellm-policywhen LiteLLM is present), leaving the agent and LiteLLM pods with whatever egress the cluster’s other policies allow (the NATS ingress policy rendered undermode: nextis not governed by this flag — it appears and disappears with the A2A stack);spec.networkPolicy.additionalEgressappends caller-supplied CIDR and port rules to the generated policy, bounded at 32 rules and by a/12(IPv4) //48(IPv6) prefix floor; andspec.security.egressAllowlist(live only behindegressPolicy: Allowlistand the split broker) addsextraRulesto the second policy selecting the same pod — metadata-guarded and refused loudly when a rule reaches a metadata address, but with no width floor of its own (see Credential isolation). That floor bounds how wide a peer range may be, not which host it reaches:169.254.169.254/32clears a/12trivially, so a rule can put back the ALTS DirectPath port8080that the metadata rule omits, and10.0.0.0/12on10250reaches every kubelet in that range. A rule that omitsportsaltogether opens every port to its peers. All three arePlatformAgentspec fields, so write access to the CR is write access to the egress boundary — treat it the way theAgentPluginnote below asks you to treat plugin creation. The agent cannot take that route against itself: its own ServiceAccount holdsget,list, andwatchonplatformagentsand no write verb. A fourth route is dynamic discovery, forlitellm-policyonly (the gateway policy opens no metadata path): its metadata-daemon port is resolved by the operator from thekube-system/gke-metadata-serverDaemonSet (defaulting to988, with non-standard ports logged). Write access to that DaemonSet is confined to cluster administrators withkube-systemprivileges; the agent’s ServiceAccount has no permissions to modify DaemonSets. See PlatformAgent CRD.
- Ingress: Restricted to essential Hermes API (
-
One agent per cluster. The admission webhook rejects a second
PlatformAgentCR in the same cluster, so a cluster can’t accumulate agents with overlapping scope. See PlatformAgent CRD. -
Human sign-off for destructive ops. Cluster deletion, tenant offboarding, and broad IAM revocation always require explicit human approval, regardless of any “just do it” phrasing.
-
Unattended runs waive the prompt, not the checks — hardened against evasion.
approvals.cron_mode: approveskips the interactive approval prompt for scheduled runs, because no human is at the keyboard to answer it. The hardline floor (rm -rf /,mkfs, fork bombs, and nine more), the sudo-stdin guard, and anyapprovals.denyglobs still apply, and commands go through the Tirith content scan unlessapprovals.cron_scan: falseopts the profile out. In addition, the cron risk gate closes content-evasion residues: raw terminal escape and C0/C1 control sequence injection (_ESC) and pure-ASCII lookalike TLDs (e.g.kubernetes.io.evil-cdn.co) are refused unconditionally,execute_codeis unconditionally blocked during cron runs so autonomous watchdogs cannot reachsubprocessvia embedded Python, and agentic jobs declared as"high"risk (as well as unannotated dispatches, defaulting fail-closed to"high") on the roster apply a fail-closed read-only command policy (cron_command_policy_block) requiring every command segment to be an allowlisted inspection command while refusing mutating verbs, chained commands, substitutions, or file writes.lowrisk retains the configuredcron_mode(denylist-only floor). The Planning Agent also cannot execute code:code_executionis in itsdisabled_toolsets. These command-execution gates apply to agentic cron runs, where a model chooses commands through the tool surface. Theno_agentpollers below run no agent loop and issue no tool calls, so theirrisktier is an input classification, not a command gate. See Autonomous watchdogs. -
A pull-request comment can wake the agent, and write access is what decides whether it may.
github-repo-watcherpolls the agent’s own open pull requests and spawns a worker turn when a comment addresses it with/agent …or an@-mention. That is unattended ingress from a thread anyone with a GitHub account can write in, so the trust decision is the commenting account’s write access to the repository — resolved by the forge, and re-checked by the worker before it posts rather than left to the prompt. A comment from anyone else cannot direct the agent, but it is not kept away from the model either, and that distinction is the one to hold on to. It normally earns one canned refusal posted by the sweep, with no worker turn spent — bounded atPR_AGENT_MAX_PER_TICK(3) refusals a tick andPR_AGENT_MAX_REFUSALS_PER_PR(10) a pull request, past which the refusal is dropped rather than posted, silently, and in the per-pull-request case for good. A request the per-tick cap dropped is still unanswered, so it reaches the worker on a later dispatch; the only verdict available to it there is the same refusal. Untrusted comment text reaches the model routinely: the whole thread travels with any request on that pull request, because a question is only answerable against the discussion around it. An untrusted account can therefore put words in front of the model. What it cannot do is make the agent act on them. An unresolved permission lookup is held for the next tick rather than treated as either answer, and cannot be refused at all — a refusal is permanent, and a proxy fault must not silence a maintainer. Three things bound the blast radius even for a trusted reviewer: the request runs within the authority the agent already has and cannot widen it, redirect it at another repository, or overturn a refusal; a change goes on that pull request’s own branch through the samesubmit-suggestionpath and its guardrails, never tomainand never as a second pull request; andagent:ignoreon a pull request stops the agent posting there at all. Comment text is data, never instruction.docs/designs/pr-comment-conversation.mdis canonical for the trigger grammar, the marker scheme, and the per-tick and per-pull-request bounds. -
An issue can wake the agent too, and its text is sanitized before the model sees it.
github-repo-watcherpolls unaddressed open issues and files a card that dispatchesgithub-issue-resolver. Anyone with a GitHub account can open an issue, so the title, body, and every comment are unattended ingress on the same footing as the pull-request thread above — but the handling differs.resolver.pystrips control, zero-width, and bidirectional characters and rewrites counterfeit delimiters before wrapping the text in<untrusted_title>/<untrusted_body>/<untrusted_comment>boundary tags, so the demarcation is enforced in code rather than asked for in a prompt. An issue that asks the agent to change something is escalated tostatus:escalation-neededrather than acted on, but that is the agent’s reading of the issue and nothing grades it for them — what actually confines the resolver is that its investigation step is read-only and its only writes are a comment and a label. Issue text is data, never instruction.agents/platform/skills/github-issue-resolver/SKILL.mdis canonical for the boundary rules. -
stall-watchreads the controller status and event text of every cluster with a Cluster Agent and hands a stall to that agent through an alert. Every thirty minutes ano_agentscript runs the Cluster Agent’sstall_report.pyover the non-system namespaces of every running or reconciling cluster that has a Cluster Agent profile, in the management project or in any other project a Cluster Agent profile’s identity names (onespec.scopebrought in, or onemanage-clusteronboarded directly), through the shell sandbox; the management cluster is included unlessspec.scope.exclude.clusters(or, for one more release,RECONCILE_EXCLUDE) names it, in which case the watch neither reads it nor alerts for it, so the exclusion keeps every model turn off that cluster rather than moving one to another profile. The watch also does not read a namespace thatspec.harness.stallWatch.excludeNamespacesnames or that carries the annotationkubeagents.x-k8s.io/stall-watch: exempt, and the same annotation on an object leaves that object’s rows out of the alerts (spec.harness.stallWatch). Anyone who can patch a namespace or an object can set that annotation, so on a shared cluster a tenant can silence the watch for its own objects, and so can the agent where its permission set lets it patch them. The tick issues no model-chosen command: thekubectlreads are fixed by the script, they authenticate as the pod’s GSA under its permission set, and Secret contents are never read. What it reads is untrusted: condition reasons, spec paths, referent names and event messages are all text whoever can write to those namespaces chooses. The inject record it sends the Session KV server, and so the alert and the card the Planning Agent files from it, carry only object names, heuristics and durations, never that text. The Cluster Agent reads it again when it runsgke-stall-detectionon the card, as tool output, where its read-only skill and preflight bound what it does, and it may quote it in the report posted to the alert’s thread. Each alert starts two model turns: the Planning Agent’s, which sees the object names inside the brief it copies onto the card, and the Cluster Agent’s. At most three alerts are raised per tick, whatever the number of namespaces a tenant fills with stalls. The entry declaresrisk: highfor that reason. -
upgrade-readiness-watchreads cluster versions daily and, on a report day, the budgets, workloads, admission webhooks, pods, nodes and the other objects the readiness rules read, of every cluster in scope. Once a day ano_agentscript runs thefleet-upgrade-verificationskill’s version table through the shell sandbox:gcloudcluster and server-config reads over the management project and every project a Cluster Agent profile’s identity names. When a cluster is below a version its release channel offers that the watch has not reported, or reported more than a week ago, the same tick runs the readiness report with the read set a run on request has: per cluster in those projects, oneget-credentialsand sixkubectl gets (PodDisruptionBudgets, Deployments and StatefulSets; webhook configurations with their Services, EndpointSlices and Namespaces; DaemonSets, CronJobs, PersistentVolumeClaims, NetworkPolicies and Namespaces; every pod; the nodes through a jsonpath projection of name, labels, taints, cordon state, node info, allocatable and conditions; StorageClasses and PersistentVolumes), plusgcloud logging readover seven days of the Admin Activity log when a registered rule reads it, including clustersspec.scope.exclude.clusterskeeps a Cluster Agent from, since the report enumerates by project. Nothing is mutated; the report, which names the budgets and the fail-closed webhook configurations and Services it graded, is written under the Platform Agent profile’s data directory on the gateway pod, the kubeconfigs stay in the sandbox login’s home, and chat receives one line naming the blocked clusters. -
sa-role-watchsays when the agent’s GSA holds more thanread-only. Every hour ano_agentscript reads, through the shell sandbox and the credential broker, thePlatformAgentand its ServiceAccount in the agent’s namespace and thengcloud projects get-iam-policyfor the management project and every project in the resolvedspec.scope. It names in chat each role bound to the GSA the ServiceAccount’s Workload Identity annotation names, and to the one the broker impersonates when federation is armed, that theread-onlyset does not carry: when the role appears, again once a day from 12:00 UTC while it is held, and when it goes. The installer warns about an admin role in acustomset only when the set is chosen; this catches one granted any time after. Its drift lines carry only account emails, project IDs and role IDs: an annotation or CR value that does not look like a service account email is refused rather than quoted, and a conditional grant is marked as one without the condition’s free-text title, and a role bound toallUsersorallAuthenticatedUsers, which every account holds, is named for the agent’s accounts and marked public. Its failure lines quote one line of the failing command’s error output inside a code span, cut short and with backticks replaced so the text cannot leave it. So nothing a policy author writes, and no formatting a command’s output carries, reaches the Planning Agent’s turn that posts it, and the entry declaresrisk: low. It reads project-level bindings only, so a role granted on a folder, the organisation or a group the account belongs to is not seen, and acustomset chosen on purpose is reported like any other grant. It changes nothing. -
Bounded recovery. The agent retries a transient blocker through its recovery ladder (roughly five iterations or ~10 minutes) before escalating to a human instead of looping indefinitely; an authorization denial is reported at once, not retried.
-
Read-only log access by default. Every built-in permission set grants the agent
roles/logging.viewer, not admin — it cannot tamper with the audit-log sinks. (The Terraform engine adds its roles as non-authoritativegoogle_project_iam_memberbindings, so a legacyroles/logging.admingrant made outside it is not removed automatically; audit and revoke such grants yourself.) Stronger environments should route an immutable log copy to a separate security project (see User attribution). -
AgentPlugincreate/update is an administrative privilege. Treat the permission to create anAgentPluginas equivalent to running code inside the agent pod, because that is what it does. The plugin’s OCI image is mounted into the agent container and Hermes imports it, so the plugin executes with the agent’s ServiceAccount, its Workload Identity binding, and its access to the credential proxy. The controls below constrain what a plugin can declare in the CR; none of them sandbox the plugin code itself. RestrictagentpluginsRBAC to the same set of principals you would trust to change the agent’s container image.The controls that do apply, and their exact scope:
- Opt-in
agentReftargeting. A plugin must setspec.agentRefto aPlatformAgent.metadata.namein its own namespace. Plugins whoseagentRefdoes not match are ignored — a plugin cannot attach itself to every agent by omitting the field. spec.targetProfilechooses which agent’s toolset the plugin sits beside. It does not widen the trust boundary — plugin code already runs in the agent pod with its ServiceAccount, whichever profile loads it — but it does decide the company it keeps. A plugin left on the default profile loads into the Planning Agent, which is deliberately stripped of terminal, file, and code-execution tools. Targetingplatformloads it into the Platform Agent instead, alongsidegcloud,kubectl, and the GitOps write path, and makes its skills resolvable to the agent that holds them. Review a plugin that targets a privileged profile with that in mind, and note thatspec.configcannot reach theagentsubtree from either place, so a plugin still cannot raise its own retry or iteration budget.- Name restriction.
metadata.namemust match^[a-z][a-z0-9]*$(max 56 characters), enforced by a CEL rule on the CRD. The name becomes both the mount directory and the module identifier Hermes imports. - Config subtree allowlisting. Only the top-level keys
approvals,platforms, andplatform_toolsetsare merged fromspec.config; every other key is dropped and logged. This keeps a plugin out ofagent(includingagent.disabled_toolsets),leader_election,logging, andplugins. It does not make the merge safe in general — see the two caveats below. - Caveat: allowlisted subtrees still carry security weight.
approvalsgoverns approval gating andplatform_toolsetsgates which toolsets a platform surface exposes. A plugin may set values under both. Allowlisting bounds where a plugin can write, not how much authority it can grant itself. - Caveat: list merges are additive. When a plugin supplies a list under an allowlisted key, its entries are unioned into the operator’s list rather than replacing it. A plugin can therefore add a toolset to
platform_toolsetsbut cannot remove one the operator configured. spec.envoverrides operator-set variables. Plugin-supplied environment variables take precedence over variables of the same name set by the operator, and secret references resolve against any Secret in the agent’s namespace. Four names are exceptions: the operator appendsCREDENTIAL_PROXY_URL,AGENT_SHARED_STATE_SETUP,PATH, andPYTHONPATHafter the merge, so a plugin’s copy of any of them loses. The first keeps a plugin from redirecting the credential proxy; the second keeps it from switching off the container-startup setup that populates$HERMES_HOME(see Container entrypoint), which would surface as plugins mounted but never enabled, far from the plugin that caused it. Secrets referenced this way land in the agent container’s environment: this is a supported way to supply a plugin its own API token, not a preservation of the credential-proxy boundary, which only covers the credentials the proxy itself brokers. See Credential isolation.
- Opt-in
Secrets Encryption & Local State Security
Section titled “Secrets Encryption & Local State Security”GKE etcd Database Encryption (CMEK)
Section titled “GKE etcd Database Encryption (CMEK)”The install enforces Customer-Managed Encryption Keys (CMEK) for GKE database encryption:
- Automated Cloud KMS Setup: On a cluster the install creates, the
gke-clustermodule’senable_database_encryption(defaulttrue) creates a dedicated Cloud KMS keyring and crypto key, grants the GKE service agentroles/cloudkms.cryptoKeyEncrypterDecrypter, and creates the cluster encrypted. - Existing clusters: Terraform cannot mutate a cluster it did not create, so the installer (
install.sh) runs agcloudpre-step before the apply: it ensures the keyring, key, and service-agent binding exist and updates the live cluster’s database encryption. Clusters that are already encrypted are left alone. ALLOW_UNENCRYPTED_SECRETSOverride: When installing onto an existing cluster or local test environment where CMEK must stay off, exportALLOW_UNENCRYPTED_SECRETS=trueto skip that pre-step.
Local State Security (install.env)
Section titled “Local State Security (install.env)”The install configuration written during installer execution (install.env) is hardened as follows:
- File Permissions: State files are created with strict POSIX permissions (
umask 077andchmod 600), preventing non-owner access. Theterraform.tfvarsthe installer generates from it gets the same0600treatment, and is regenerated on every run so the two cannot disagree. PERSIST_SECRETS_ON_DISK: By default (PERSIST_SECRETS_ON_DISK=true), credentials entered during installation are stored ininstall.envfor non-interactive re-runs. SetPERSIST_SECRETS_ON_DISK=falseto prevent writing sensitive credentials to disk.- Secrets also live in Terraform state. Every credential passed through the composition is stored in plaintext in the Terraform state, which the installer keeps in a GCS bucket (
<project>-kube-agents-tfstate, versioned). Restrict that bucket’s IAM to the administrators who may read the credentials; see the composition README’s warning.
Where to go next
Section titled “Where to go next”- Credential isolation — how credentials are kept out of the agent sandbox container.
- Platform Agent — the persona and least-privilege stance.
- PlatformAgent CRD —
spec.securityand the permission set field. - User attribution — tracing an action back to the human who requested it.
terraform/modules/kube-agents-iam— where the IAM is laid down.docs/security-requirements.md— the provider-neutral security configuration model: the permission / interaction / authorization dimensions, what is current behaviour versus planned capability, and the acceptance criteria.