A customer asked whether we could apply human-in-the-loop decisions to kagent agents dynamically, so their developers do not have to write that logic and the platform and security teams decide what needs approving. This lab is how.
That gives centralised control and governance. Approval is decided once, by the teams accountable for it, and applies to every agent in the cluster: one place to set it, one place to read it back, and the same answer for an agent written last year as for one deployed this morning. It is auditable for the same reason. Which agents can restart a deployment without a human? becomes a question you answer from one Policy, rather than by reading the source of every agent anyone has shipped.
So that is where the decision lives: a policy your CI writes, holding which MCP servers and tools need approving and which agents are in scope. Kyverno reads it at admission and turns on kagent's own approval flow, for BYO and Declarative agents alike. The agent is not modified, not rebuilt, and cannot switch it off. Approvals then appear natively in the Solo Enterprise UI, or over kagent's API.
Three agents, all Enterprise Solo: kagent, AgentRegistry and agentgateway on a single kind cluster.
The flow
Click to expand
Four things to set up, once. Then adding an agent to the list is one command.
Step 1 — the agents
Two SRE agents, scaffolded with the arctl CLI and published to Enterprise
AgentRegistry. Both are ADK agents with the same five tools from an MCP server: three
that read (list_pods, get_pod_logs,
describe_deployment) and two that change things
(restart_deployment, scale_deployment).
Neither contains approval logic, a confirmation prompt, or any notion that some of its tools are more dangerous than others.
Same flags for both, different name:
arctl init agent sretriage --framework adk --language python \
--model-provider anthropic --model-name claude-haiku-4-5 \
--output-dir ./artifacts
arctl init agent sreremediate --framework adk --language python \
--model-provider anthropic --model-name claude-haiku-4-5 \
--output-dir ./artifacts
The names have no hyphens. arctl init agent takes lowercase
letters and digits only, so sre-triage is rejected outright.
That writes the project, a Dockerfile, an agent.yaml for
the catalogue, and the modules the agent imports. Then arctl build --push
publishes the image and arctl apply registers it:
arctl build ./artifacts/sretriage --push
arctl build ./artifacts/sreremediate --push
arctl apply -f ./artifacts/sretriage/agent.yaml
arctl apply -f ./artifacts/sreremediate/agent.yaml
The only file worth editing is the agent itself. Here is the whole ADK agent:
pythonartifacts/AGENT_TEMPLATE.py — rendered into both projects, only the name differs
import json
import os
from google.adk import Agent
from google.adk.models.lite_llm import LiteLlm
from google.adk.tools.mcp_tool.mcp_toolset import (
MCPToolset,
StreamableHTTPConnectionParams,
)
from .prompts_loader import build_instruction
# Set the OTel service name before the providers are initialised, so traces for
# this agent are attributable per agent.
os.environ.setdefault("OTEL_SERVICE_NAME", "__NAME__")
from google.adk.telemetry.setup import maybe_set_otel_providers # noqa: E402
maybe_set_otel_providers()
INSTRUCTION = """
You are an SRE assistant for a small Kubernetes estate. You have tools from the
sre-tools MCP server to inspect and repair workloads.
Work in this order:
1. Look before you touch. Use list_pods to find unhealthy pods, get_pod_logs to
read why they are failing, and describe_deployment to check replica counts and
images.
2. Say what you found. State the diagnosis in one or two sentences, citing the
specific evidence from the logs or the restart counts.
3. Then act, if acting is warranted. restart_deployment and scale_deployment
change the running system. Before calling either, say in one sentence what you
are about to change and why it follows from the diagnosis.
4. Report what changed. After a tool returns, summarise the new state.
Some tool calls take a while to come back. That is normal; wait for the result
rather than retrying or assuming failure.
If a tool call comes back refused or denied, report the reason you were given,
verbatim, and stop. Do not retry it, do not work around it, and do not try a
different tool to achieve the same effect.
"""
def create_model():
return LiteLlm(model="anthropic/claude-haiku-4-5")
def _gated_tools():
raw = os.environ.get("KAGENT_REQUIRE_APPROVAL", "")
return {t.strip() for t in raw.split(",") if t.strip()}
def build_mcp_tools():
gated = _gated_tools()
raw = os.environ.get("MCP_SERVERS_CONFIG", "")
try:
servers = json.loads(raw) if raw else []
except ValueError:
servers = []
timeout = float(os.environ.get("MCP_CONNECT_TIMEOUT", "60"))
terminate = os.environ.get("MCP_TERMINATE_ON_CLOSE", "true").lower() != "false"
def conn(url):
return StreamableHTTPConnectionParams(
url=url, timeout=timeout, terminate_on_close=terminate
)
toolsets = []
for srv in servers:
url = srv.get("url") or ""
if not url:
continue
if not gated:
# Nothing is gated for this agent: one plain toolset, no confirmation.
toolsets.append(MCPToolset(connection_params=conn(url)))
continue
if "*" in gated:
# The whole server is gated, so there is no ungated half to build.
toolsets.append(
MCPToolset(connection_params=conn(url), require_confirmation=True)
)
continue
# Everything the platform did NOT name — runs straight through.
toolsets.append(
MCPToolset(
connection_params=conn(url),
tool_filter=lambda tool, ctx=None: tool.name not in gated,
)
)
# The tools the platform DID name — ADK pauses and kagent asks a human.
toolsets.append(
MCPToolset(
connection_params=conn(url),
tool_filter=lambda tool, ctx=None: tool.name in gated,
require_confirmation=True,
)
)
return toolsets
mcp_tools = build_mcp_tools()
root_agent = Agent(
model=create_model(),
name="__NAME___agent",
description="SRE assistant: triage and remediate unhealthy workloads via the sre-tools MCP server.",
instruction=build_instruction(INSTRUCTION),
tools=mcp_tools if mcp_tools else [],
)
The only part of that worth dwelling on is _gated_tools(). It reads
KAGENT_REQUIRE_APPROVAL and gates whatever it names. It never
names a tool itself. The developer wires up the mechanism and has no say in
what it applies to, which is the whole point: the variable is empty until a platform
team decides otherwise.
They differ only in how kagent runs them, and that decides which field the policy sets later:
| Declarative | BYO | |
|---|---|---|
| What you give kagent | a system prompt and a tool list | a container image |
| Who runs the ADK loop | kagent, on its Python ADK runtime | your image |
| Built with | kubectl apply |
arctl init agent, published to AgentRegistry |
| Approval turned on by | requireApproval on the tool list |
KAGENT_REQUIRE_APPROVAL in the pod env |
All three deployed and healthy. Look at the Required Tools and Model columns, because that is the whole distinction in one screen:
Click to expand
srenative, the declarative agent, shows its model and
sre-tools as a required tool — kagent knows what it is made of. The two
BYO agents show No Tools and no model at all. They have exactly the
same five tools and the same model; kagent simply cannot see inside a container it was
handed.
That is why the policy in step 3 needs two rules. There is nothing on a BYO agent
for requireApproval to attach to.
For the declarative agent, kagent enumerates the tools from the MCP server:
Click to expand
Worth noticing what is not there: nothing marks
restart_deployment or scale_deployment as dangerous. MCP has
no notion of it and the tool server does not get a vote. That judgement belongs to the
platform team, which is why it lives in their config rather than here.
yamlyaml/agents/declarative-native.yaml — as the developer writes it, with no approval
apiVersion: kagent.dev/v1alpha2
kind: Agent
metadata:
name: srenative
namespace: kagent
spec:
type: Declarative
declarative:
runtime: python # which ADK implementation to run
modelConfig: default-model-config
systemMessage: |
You are an SRE assistant. Look before you touch...
tools:
- type: McpServer
mcpServer:
apiGroup: kagent.dev
kind: RemoteMCPServer
name: sre-tools
toolNames:
- list_pods
- get_pod_logs
- describe_deployment
- restart_deployment
- scale_deployment
# no requireApproval — the developer did not add one
Step 2 — record who needs approval, and for which tools
One ConfigMap. This is the entire integration point for whatever review process you already run — a security review, a change board, a scanner in CI, someone reading a diff:
apiVersion: v1
kind: ConfigMap
metadata:
name: agent-risk-register
namespace: kyverno
data:
red: "sreremediate,srenative"
default: "green"
gated: |
- server: sre-tools
tools:
- restart_deployment
- scale_deployment
| Key | What it answers |
|---|---|
red |
Which agents need a human. Comma-separated names, or * for
every agent — see gating on tools alone. |
default |
The answer for anything not named. Set it to red and every
agent needs approval unless explicitly cleared. |
gated |
Which tools need approval. The platform team owns this list, not the agent's author. |
What gated is for
"This agent needs approval" is not specific enough to act on. An SRE agent that
needed a human before it could run list_pods would be useless, and a
reviewer clicking through five read-only requests to reach the one that matters is a
reviewer who has stopped reading. So the register names tools, not just agents.
It is a list of entries, one per MCP server:
apiVersion: v1
kind: ConfigMap
metadata:
name: agent-risk-register
namespace: kyverno
data:
gated: |
- server: sre-tools # the MCP server, by name
tools: # which of ITS tools need a human
- restart_deployment
- scale_deployment
- server: payments-tools # a second server, its own list
tools:
- issue_refund
| Field | What it means |
|---|---|
server |
The name of an MCP server. For a declarative agent that is the
RemoteMCPServer its tool stanza points at; for a BYO agent it is
the name in its own MCP_SERVERS_CONFIG. Same name
either way, which is why one entry covers both kinds of agent. |
tools |
Tool names on that server that need a human. Anything not listed runs
straight through. ["*"] means every tool the server
exposes. |
Two properties fall out of keying on the server rather than the agent. An entry applies to every red agent that uses that server, so onboarding a dangerous MCP server is one edit rather than one per agent. And an agent is only gated on tools it actually has — a register listing four servers does nothing to an agent that uses one of them.
Editing the register
Every change to the decision is a patch to this one object. Nothing else moves, and no agent is touched:
A tool on a server already in the register. gated is a single
ConfigMap key holding a YAML document, so send the whole thing — an apply is the
readable way to do that.
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: ConfigMap
metadata:
name: agent-risk-register
namespace: kyverno
data:
red: "sreremediate,srenative"
default: "green"
gated: |
- server: sre-tools
tools:
- restart_deployment
- scale_deployment
- delete_pod
EOF
Do not try this as kubectl patch --type merge with the
YAML pasted across several lines. A JSON string cannot contain a literal newline,
so the API server rejects it with
invalid character '\n' in string literal. Patch works if you escape
them onto one line, which is why an apply is easier to read.
A second MCP server, with its own tool list. Agents that do not use it are unaffected.
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: ConfigMap
metadata:
name: agent-risk-register
namespace: kyverno
data:
red: "sreremediate,srenative"
default: "green"
gated: |
- server: sre-tools
tools:
- restart_deployment
- scale_deployment
- server: payments-tools
tools:
- issue_refund
- cancel_subscription
EOF
["*"] gates every tool the server exposes, so a server with fifty
tools is one line. It also covers tools that server has not shipped yet, which
is the case worth having: a new tool appearing on an already-gated server should
not be an opening.
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: ConfigMap
metadata:
name: agent-risk-register
namespace: kyverno
data:
red: "sreremediate,srenative"
default: "green"
gated: |
- server: payments-tools
tools: ["*"]
EOF
A one-line value like this one is short enough to patch, if you prefer:
kubectl -n kyverno patch configmap agent-risk-register --type merge \
-p '{"data":{"gated":"- server: payments-tools\n tools: [\"*\"]\n"}}'
Bring another agent under the register. It picks this up at its next admission, and its own repository is not involved.
kubectl -n kyverno patch configmap agent-risk-register \
--type merge -p '{"data":{"red":"sreremediate,srenative,billingagent"}}'
Flip the default so an agent nobody has reviewed is gated rather than free.
The red list then becomes a list of exceptions.
kubectl -n kyverno patch configmap agent-risk-register \
--type merge -p '{"data":{"default":"red"}}'
Name no agents at all. red: "*" puts every agent in scope, so the
only thing that decides anything is the tool list — see
gating on tools alone.
kubectl -n kyverno patch configmap agent-risk-register \
--type merge -p '{"data":{"red":"*"}}'
Gating on tools alone, with no agent named
Naming agents assumes you know which ones matter, which stops being true the moment teams can ship their own. The register can drop the agent axis entirely:
apiVersion: v1
kind: ConfigMap
metadata:
name: agent-risk-register
namespace: kyverno
data:
red: "*" # every agent, named or not
default: "green"
gated: |
- server: sre-tools
tools:
- restart_deployment
* puts every agent in scope, so the only thing deciding anything is the
tool list. Read it as: any agent that can call
restart_deployment on sre-tools needs a human, whoever built
it and whenever it arrives. An agent deployed tomorrow by a team you have
never spoken to is gated at its first admission, and nobody had to add it to a
list.
The tool scoping still applies per agent, which is what makes this usable rather than blunt:
| Agent | Its tools | What it gets |
|---|---|---|
sretriage |
the five sre-tools tools |
pauses on restart_deployment, everything else runs |
sreremediate |
the same five | identical treatment, and it is a BYO container |
| a billing agent | payments-tools only |
untouched — the register says nothing about its server |
So red: "*" is broad in the axis you want it broad in, agents, and
narrow in the axis you want it narrow in, tools. default: red reaches the
same place from the other direction: everything is red unless something takes it off
the list. Use * when the decision genuinely is not about agents;
use default: red when it is, and you want the unreviewed case to fail
closed.
Both directions are live, not one-way. Clearing red, or dropping a
server from gated, removes the gating from agents that already have it
rather than leaving it in place with a green label.
Read it back at any point — this is the whole decision, in one object:
kubectl -n kyverno get configmap agent-risk-register -o yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: agent-risk-register
namespace: kyverno
data:
default: green
red: sreremediate,srenative
gated: |-
- server: sre-tools
tools:
- restart_deployment
- scale_deployment
In a pipeline
Both edits are the platform team's, so they belong in the platform team's pipeline, not the agent's. Onboarding a new MCP server means one job that says which of its tools need a human before any agent is allowed to use them:
yaml.github/workflows/agent-risk.yml
name: agent risk register
on:
push:
paths: [platform/risk/**] # owned by the platform team, not by agent repos
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Publish both halves of the decision. Reviewed like any other change: a pull
# request, an owner, an audit trail.
#
# create --dry-run | apply rather than patch, because `gated` is a multi-line
# value and a JSON merge patch cannot carry a literal newline.
- name: Publish the risk register
run: |
kubectl -n kyverno create configmap agent-risk-register \
--from-literal=red="$(paste -sd, platform/risk/red-agents.txt)" \
--from-literal=default=green \
--from-file=gated=platform/risk/gated.yaml \
--dry-run=client -o yaml | kubectl apply -f -
The file the job publishes is the whole platform-side decision:
platform/risk/gated.yaml
- server: sre-tools
tools:
- restart_deployment
- scale_deployment
- server: payments-tools
tools: ["*"] # every tool, including future ones
Nothing in that pipeline reads or writes an agent, and nothing in an agent's own pipeline can affect it. An agent team merging a change cannot take itself off the list, because the list is not in their repository.
Step 3 - Apply a Kyverno policy to apply the gating
Kubernetes lets a service sit in the path of every write to the API server. When someone creates a resource, the API server pauses, hands it over, and stores whatever comes back. Kyverno lets you express that as a policy instead of writing the service.
Two properties make it the right place for this: it runs before the resource is stored, so the agent never runs unmodified; and the author does not get a say, so the developer cannot remove it.
One ClusterPolicy. It names no agent and no tool — it reads both from
the register, then sets whichever field applies:
yamlyaml/kyverno/20-verdict-hitl.yaml — the whole policy, comments and all
# THE CONTROL — one policy that puts a human in front of an agent whose developer
# never agreed to it and cannot remove it.
#
# It reads one ConfigMap, the risk register, and for any agent named there it turns
# on kagent's own approval flow. The approval then appears in the kagent UI. No
# gateway, no approval service, nothing extra to run.
#
# NOTHING IN THIS FILE NAMES A TOOL. The register answers both questions:
#
# red which agents need a human. `*` means every agent, which turns this
# into a purely tool-driven control: any agent that uses a gated tool
# needs approval, and no agent is ever named.
# gated which tools need approval, per MCP server
#
# So adding the hundredth tool, or a whole new MCP server, is a ConfigMap edit and
# the policy never changes. That matters because the policy is cluster-wide
# admission control: editing it is a change-managed event, editing a register entry
# is not.
#
# Both kinds of kagent agent are covered, because the field to set differs:
#
# spec.type: Declarative kagent knows the tool list, so add requireApproval to
# it. kagent pauses the tool itself.
#
# spec.type: BYO kagent sees only a pod, so instead set the env var
# KAGENT_REQUIRE_APPROVAL. The agent's ADK toolsets read
# it and pass ADK's require_confirmation, which pauses
# the tool and emits the SAME confirmation kagent renders.
#
# Either way the result is identical from the outside: the tool does not run, and a
# human approves or rejects in the kagent UI (or over its API).
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verdict-hitl-enrolment
annotations:
policies.kyverno.io/title: Require human approval for agents named red
policies.kyverno.io/category: Platform
policies.kyverno.io/description: >-
Agents named on the red list of the agent-risk-register ConfigMap have human
approval turned on, for the tools that same register marks as needing it,
surfaced in the kagent UI. Applied at admission, so it is in force before the
agent serves a request. No tool name appears in the policy.
spec:
background: false # admission-time only; this policy mutates
rules:
# ── Declarative agents: add requireApproval to the tool stanza ─────────────
#
# The register's tool list is intersected with the agent's own toolNames, which
# is not cosmetic. A CEL rule on the Agent CRD requires every requireApproval
# entry to appear in toolNames, so naming a tool this agent does not have would
# produce a resource the API server REJECTS — the deploy fails instead of the
# agent being gated. Intersecting lets one register cover a whole fleet of
# agents with different tool sets, each getting only the entries that apply.
- name: approval-declarative
match:
any:
- resources:
kinds: [kagent.dev/v1alpha2/Agent]
namespaces: [kagent]
context:
- name: register
configMap: { name: agent-risk-register, namespace: kyverno }
preconditions:
all:
- key: "{{ request.object.spec.type || '' }}"
operator: Equals
value: Declarative
any:
- key: "{{ contains(split(register.data.red || '', ','), request.object.metadata.name) }}"
operator: Equals
value: true
# red: "*" — every agent is in scope, so the register gates on TOOLS alone.
# Without this clause a `*` would match no agent name, the agent would be
# labelled green, and nothing would be gated: a silent fail-open for the
# most natural thing to write.
- key: "{{ contains(split(register.data.red || '', ','), '*') }}"
operator: Equals
value: true
- key: "{{ register.data.default || 'green' }}"
operator: Equals
value: red
mutate:
foreach:
- list: "request.object.spec.declarative.tools"
context:
# What the register says about THIS stanza's MCP server.
# `{{ element.mcpServer.name }}` is substituted before the JMESPath
# runs, so this stays an ordinary filter rather than a dynamic map
# key — dynamic ConfigMap keys need nested interpolation and are
# fragile across Kyverno releases.
- name: want
variable:
jmesPath: "parse_yaml(register.data.gated || '[]')[?server=='{{ element.mcpServer.name }}'].tools | [0] || `[]`"
# `tools: ['*']` gates every tool the server exposes; otherwise take
# the intersection. The backtick literal is what makes the match
# exact — contains() against a raw CSV string would let an entry
# named `scale` match `scale_deployment`.
- name: gated
variable:
jmesPath: "contains(`{{ want }}`, '*') && element.mcpServer.toolNames || element.mcpServer.toolNames[?contains(`{{ want }}`, @)]"
preconditions:
all:
- key: "{{ element.type || '' }}"
operator: Equals
value: McpServer
# Servers the register says nothing about are left exactly as the
# developer wrote them.
- key: "{{ length(gated) }}"
operator: GreaterThan
value: 0
# Idempotence: re-applies arrive as admission UPDATEs.
- key: "{{ element.mcpServer.requireApproval || `[]` | length(@) }}"
operator: Equals
value: 0
patchesJson6902: |-
- op: add
path: "/spec/declarative/tools/{{ elementIndex }}/mcpServer/requireApproval"
value: {{ gated }}
# ── BYO agents: set the env var its ADK toolsets read ──────────────────────
#
# kagent cannot see a BYO agent's tools, but the agent's own MCP wiring is right
# there in its env: MCP_SERVERS_CONFIG lists the servers it connects to, by
# name. That is the same key the register is written against, so this is the
# same lookup as the declarative rule — the policy reads which servers this
# agent actually uses and gates only those.
#
# patchesJson6902, never patchStrategicMerge: the Agent CRD has no merge key for
# spec.byo.deployment.env, so a strategic merge replaces the WHOLE list and
# silently drops the registry-injected MCP wiring and the model key.
- name: approval-byo
match:
any:
- resources:
kinds: [kagent.dev/v1alpha2/Agent]
namespaces: [kagent]
context:
- name: register
configMap: { name: agent-risk-register, namespace: kyverno }
# The MCP servers this agent connects to, by name.
- name: mine
variable:
jmesPath: "parse_json(request.object.spec.byo.deployment.env[?name=='MCP_SERVERS_CONFIG'].value | [0] || '[]')[].name"
# Every gated tool across those servers, flattened, because one agent can
# connect to more than one.
- name: gated
variable:
jmesPath: "parse_yaml(register.data.gated || '[]')[?contains(`{{ mine }}`, server)].tools[]"
preconditions:
all:
- key: "{{ request.object.spec.type || '' }}"
operator: Equals
value: BYO
# Nothing in the register covers this agent's servers — leave it alone.
- key: "{{ length(gated) }}"
operator: GreaterThan
value: 0
# Idempotence: without this, every reconcile appends a duplicate.
- key: "{{ request.object.spec.byo.deployment.env[?name=='KAGENT_REQUIRE_APPROVAL'] | length(@) }}"
operator: Equals
value: 0
any:
- key: "{{ contains(split(register.data.red || '', ','), request.object.metadata.name) }}"
operator: Equals
value: true
# red: "*" — every agent is in scope, so the register gates on TOOLS alone.
# Without this clause a `*` would match no agent name, the agent would be
# labelled green, and nothing would be gated: a silent fail-open for the
# most natural thing to write.
- key: "{{ contains(split(register.data.red || '', ','), '*') }}"
operator: Equals
value: true
- key: "{{ register.data.default || 'green' }}"
operator: Equals
value: red
mutate:
patchesJson6902: |-
- op: add
path: "/spec/byo/deployment/env/-"
value:
name: KAGENT_REQUIRE_APPROVAL
value: "{{ join(',', gated) }}"
# ── Taking an agent OFF the register must actually un-gate it ──────────────
#
# Without these two rules the control is one-way. Clearing `red`, or dropping a
# server from `gated`, leaves the previous mutation in place: the agent keeps its
# requireApproval or its env var, keeps pausing, and is labelled green while doing
# it. Safe, in that it fails closed, but it makes the register a lie.
#
# These fire only on the green path, so they can never race the add rules.
- name: ungate-declarative
match:
any:
- resources:
kinds: [kagent.dev/v1alpha2/Agent]
namespaces: [kagent]
context:
- name: register
configMap: { name: agent-risk-register, namespace: kyverno }
preconditions:
all:
- key: "{{ request.object.spec.type || '' }}"
operator: Equals
value: Declarative
- key: "{{ contains(split(register.data.red || '', ','), request.object.metadata.name) }}"
operator: Equals
value: false
- key: "{{ contains(split(register.data.red || '', ','), '*') }}"
operator: Equals
value: false
- key: "{{ register.data.default || 'green' }}"
operator: NotEquals
value: red
mutate:
foreach:
- list: "request.object.spec.declarative.tools"
preconditions:
all:
- key: "{{ element.mcpServer.requireApproval || `[]` | length(@) }}"
operator: GreaterThan
value: 0
patchesJson6902: |-
- op: remove
path: "/spec/declarative/tools/{{ elementIndex }}/mcpServer/requireApproval"
- name: ungate-byo
match:
any:
- resources:
kinds: [kagent.dev/v1alpha2/Agent]
namespaces: [kagent]
context:
- name: register
configMap: { name: agent-risk-register, namespace: kyverno }
preconditions:
all:
- key: "{{ request.object.spec.type || '' }}"
operator: Equals
value: BYO
- key: "{{ contains(split(register.data.red || '', ','), request.object.metadata.name) }}"
operator: Equals
value: false
- key: "{{ contains(split(register.data.red || '', ','), '*') }}"
operator: Equals
value: false
- key: "{{ register.data.default || 'green' }}"
operator: NotEquals
value: red
mutate:
foreach:
# Only ever one matching entry, so removing by elementIndex cannot shift
# the index of another entry this same foreach still has to visit.
- list: "request.object.spec.byo.deployment.env"
preconditions:
all:
- key: "{{ element.name || '' }}"
operator: Equals
value: KAGENT_REQUIRE_APPROVAL
patchesJson6902: |-
- op: remove
path: "/spec/byo/deployment/env/{{ elementIndex }}"
# ── ...and so must dropping the SERVER, while the agent stays red ───────────
#
# The two rules above only fire on the green path, so they miss the other way an
# agent stops needing approval: it is still red, but its MCP server is no longer
# in `gated`, or its tools were narrowed. Kyverno's preconditions are a flat
# all/any and cannot express "green OR nothing-gated" in one rule, so that case
# is its own rule rather than a nested condition.
#
# Scope is not checked here on purpose: if the register gates nothing on this
# agent's servers then it should carry no gating, red or green.
- name: ungate-declarative-unlisted
match:
any:
- resources:
kinds: [kagent.dev/v1alpha2/Agent]
namespaces: [kagent]
context:
- name: register
configMap: { name: agent-risk-register, namespace: kyverno }
preconditions:
all:
- key: "{{ request.object.spec.type || '' }}"
operator: Equals
value: Declarative
mutate:
foreach:
- list: "request.object.spec.declarative.tools"
context:
- name: want
variable:
jmesPath: "parse_yaml(register.data.gated || '[]')[?server=='{{ element.mcpServer.name }}'].tools | [0] || `[]`"
- name: gated
variable:
jmesPath: "contains(`{{ want }}`, '*') && element.mcpServer.toolNames || element.mcpServer.toolNames[?contains(`{{ want }}`, @)]"
preconditions:
all:
- key: "{{ element.mcpServer.requireApproval || `[]` | length(@) }}"
operator: GreaterThan
value: 0
- key: "{{ length(gated) }}"
operator: Equals
value: 0
patchesJson6902: |-
- op: remove
path: "/spec/declarative/tools/{{ elementIndex }}/mcpServer/requireApproval"
- name: ungate-byo-unlisted
match:
any:
- resources:
kinds: [kagent.dev/v1alpha2/Agent]
namespaces: [kagent]
context:
- name: register
configMap: { name: agent-risk-register, namespace: kyverno }
- name: mine
variable:
jmesPath: "parse_json(request.object.spec.byo.deployment.env[?name=='MCP_SERVERS_CONFIG'].value | [0] || '[]')[].name"
- name: gated
variable:
jmesPath: "parse_yaml(register.data.gated || '[]')[?contains(`{{ mine }}`, server)].tools[]"
preconditions:
all:
- key: "{{ request.object.spec.type || '' }}"
operator: Equals
value: BYO
- key: "{{ length(gated) }}"
operator: Equals
value: 0
- key: "{{ request.object.spec.byo.deployment.env[?name=='KAGENT_REQUIRE_APPROVAL'] | length(@) }}"
operator: GreaterThan
value: 0
mutate:
foreach:
- list: "request.object.spec.byo.deployment.env"
preconditions:
all:
- key: "{{ element.name || '' }}"
operator: Equals
value: KAGENT_REQUIRE_APPROVAL
patchesJson6902: |-
- op: remove
path: "/spec/byo/deployment/env/{{ elementIndex }}"
# ── Record the decision, for humans reading the cluster ────────────────────
# The label is a record, not the mechanism. Do not key enforcement off it.
- name: label-red
match:
any:
- resources:
kinds: [kagent.dev/v1alpha2/Agent]
namespaces: [kagent]
context:
- name: register
configMap: { name: agent-risk-register, namespace: kyverno }
preconditions:
any:
- key: "{{ contains(split(register.data.red || '', ','), request.object.metadata.name) }}"
operator: Equals
value: true
# red: "*" — every agent is in scope, so the register gates on TOOLS alone.
# Without this clause a `*` would match no agent name, the agent would be
# labelled green, and nothing would be gated: a silent fail-open for the
# most natural thing to write.
- key: "{{ contains(split(register.data.red || '', ','), '*') }}"
operator: Equals
value: true
- key: "{{ register.data.default || 'green' }}"
operator: Equals
value: red
mutate:
patchStrategicMerge:
metadata:
labels:
risk.platform.solo.io/verdict: red
- name: label-green
match:
any:
- resources:
kinds: [kagent.dev/v1alpha2/Agent]
namespaces: [kagent]
context:
- name: register
configMap: { name: agent-risk-register, namespace: kyverno }
preconditions:
all:
- key: "{{ contains(split(register.data.red || '', ','), request.object.metadata.name) }}"
operator: Equals
value: false
- key: "{{ contains(split(register.data.red || '', ','), '*') }}"
operator: Equals
value: false
- key: "{{ register.data.default || 'green' }}"
operator: NotEquals
value: red
mutate:
patchStrategicMerge:
metadata:
labels:
risk.platform.solo.io/verdict: green
What each part of the policy does
Every stanza, and what it is doing (17 rows)
| Stanza | What it does |
|---|---|
background: false |
Run only when a resource is being written, never as a background sweep over existing resources. This policy changes things, so a sweep would rewrite resources nobody is touching. |
match.any.resources |
Narrows it to kagent Agent resources in the kagent
namespace. Everything else in the cluster passes through untouched. |
context: register |
Loads the ConfigMap and binds it to the name register, so the
lines below can read register.data.red and
register.data.gated. This is how a policy pulls in data to decide
with — it can also call the Kubernetes API or an external service, which is how
you would swap the ConfigMap for a real system. |
preconditions.all |
The guard: if it does not pass, the rule does nothing.
{{ request.object }} is the resource being submitted, so
spec.type is the Declarative-or-BYO field.
This is what sends each agent to the right rule. |
preconditions.any |
Is this agent in scope? Either its name is on the red list, or
default is red so everything is. all and
any are both evaluated and AND-ed, so it reads as "must be the right
type, and must be in scope". |
contains(split(...)) |
Exact membership of the red list. Splitting to a list first matters:
contains() on the raw string would make an agent named
sre match sreremediate. |
parse_yaml(...)[?server=='...'] |
Reads the gated block and picks the entry for the MCP server this
tool stanza points at. {{ element.mcpServer.name }} is filled in
before the expression runs, so this is an ordinary filter over a list rather than
a lookup by variable key. |
contains(`...`, '*') |
The wildcard branch. If the register said tools: ["*"] for this
server, take the agent's whole toolNames list; otherwise fall through
to the intersection. One entry can gate a server with fifty tools. |
toolNames[?contains(`...`, @)] |
Keeps only the tools this agent actually has. Two reasons it matters: one
register can cover a fleet of agents with different tool sets, and a CRD rule
requires every requireApproval entry to appear in
toolNames — naming a tool the agent lacks would make the API server
reject the whole resource. The backtick list is what makes the match exact; a
substring test would let an entry named scale gate
scale_deployment. |
length(gated) > 0 |
Skip servers the register says nothing about, so their stanza is stored exactly as the developer wrote it. |
mutate.foreach{{ elementIndex }} |
Walks the agent's tool list one entry at a time. element is the
current entry and elementIndex its position, so the patch lands on
the right one. A fixed index would eventually hit the wrong entry, since the list
order is not yours to control. |
patchesJson6902 |
A JSON Patch: surgical operations on one path, leaving everything else alone. Required for lists here — a strategic merge has no merge key for them and would replace the whole list, silently dropping the agent's other settings. |
Rule 1approval-declarative |
Puts the register's tools for this server into requireApproval.
kagent owns the tool list for a declarative agent, so that is all it takes, and
kagent pauses the tool itself. |
Rule 2approval-byo |
For a BYO agent there is no tool list to annotate, so it appends
KAGENT_REQUIRE_APPROVAL to the pod env. It still gates only the right
servers: MCP_SERVERS_CONFIG in the agent's own env names the servers
it connects to, which is the same key the register is written against. The agent's
ADK toolsets read the variable and ask ADK to pause those tools — the same
confirmation, reached a different way. |
contains(split(...), '*') |
The red: "*" branch, so the register can gate on tools with no
agent named. It is a separate clause rather than a looser match because a
wildcard that silently matched nothing would label every agent green and gate
nothing at all. |
Rules 3–6ungate-* |
The reverse direction. Taking an agent off red, or dropping its
server from gated, removes the mutation. Without these the control
is one-way: the agent keeps pausing while being labelled green, which is safe but
makes the register untrue. There are four because Kyverno's preconditions are a
flat all/any and cannot express "out of scope
or nothing gated" in one rule. |
Rules 7 & 8label-red,
label-green |
Write the decision as a label so it shows in
kubectl get agent. A record, not the mechanism. |
The risk.platform.solo.io/verdict label is written by the same policy,
by the label-red and label-green rules, as a
patchStrategicMerge on metadata.labels. It appears nowhere in
any file a developer owns. It is a record of the decision, not the thing enforcing it,
so do not key anything off it.
You can watch Kyverno add it without changing anything. Submit the developer's own manifest with a server-side dry run: admission runs, the webhook mutates, and nothing is persisted.
kubectl apply -f yaml/agents/declarative-native.yaml --dry-run=server -o yaml
The YAML it prints back has two fields the submitted file does not:
metadata:
labels:
risk.platform.solo.io/verdict: red # <- added by the webhook
spec:
declarative:
tools:
- mcpServer:
requireApproval: # <- and so was this
- restart_deployment
- scale_deployment
That is the whole mechanism in one command. The API server hands the resource to Kyverno on its way in, keeps whatever Kyverno hands back, and the developer never sees either field.
Seeing which agents are gated, and on what
The label tells you the decision. What it does not tell you is what that decision resolved to, and there is a difference worth being able to see: an agent can be labelled red and gated on nothing at all, because the register says nothing about the MCP servers that particular agent happens to use.
Because the mutation is stored on the resource, one query answers it for both agent types at once. No tooling, and nothing to install:
COLS='NAME:.metadata.name'
COLS=$COLS',TYPE:.spec.type'
COLS=$COLS',VERDICT:.metadata.labels.risk\.platform\.solo\.io/verdict'
COLS=$COLS',DECLARATIVE:.spec.declarative.tools[*].mcpServer.requireApproval'
COLS=$COLS',BYO:.spec.byo.deployment.env[?(@.name=="KAGENT_REQUIRE_APPROVAL")].value'
kubectl -n kagent get agents.kagent.dev -o custom-columns="$COLS"
NAME TYPE VERDICT DECLARATIVE BYO
srenative Declarative red [restart_deployment scale_deployment] <none>
sreremediate BYO red <none> restart_deployment,scale_deployment
sretriage BYO green <none> <none>
Two columns because the field differs by type, and each agent fills in exactly one of them. That is the whole state of the control, readable from the API.
Checking a change before you make it
The query above describes the register as it is. It cannot tell you what a change
would do, and on a cluster with more than three agents that is the question that
matters: widen a server to "*", or set red: "*", and how many
agents just started waiting for a human?
Since the policy is a file, run it against a copy of the agents rather than on them.
scripts/preview.sh does the assembly, because there is a bit of it: the
agents have to be split out of the List that kubectl returns,
and the register has to be stubbed for every rule.
$ ./scripts/preview.sh --red '*'
AGENT TYPE VERDICT WOULD PAUSE ON CHANGE
------------------------------------------------------------------------------
srenative Declarative red restart_deployment,scale_deployment no change
sreremediate BYO red restart_deployment,scale_deployment no change
sretriage BYO red restart_deployment,scale_deployment NEWLY GATED
One agent changes, and it says which. --default and
--gated-file ask the same question about a posture or a tool list you have
not written yet, and clearing the list reports the other direction, so a de-escalation
can be checked too:
$ ./scripts/preview.sh --red ''
AGENT TYPE VERDICT WOULD PAUSE ON CHANGE
------------------------------------------------------------------------------
srenative Declarative green (nothing) GATING REMOVED
sreremediate BYO green (nothing) GATING REMOVED
sretriage BYO green (nothing) no change
It invokes the same policy file the cluster runs, so it is a rehearsal rather than a second implementation of the rules. Nothing is written either way.
What the developer wrote, and what the cluster stored
| Agent | Developer's manifest | Stored in the cluster |
|---|---|---|
srenativeDeclarative, red |
no requireApproval | ["restart_deployment","scale_deployment"] |
sreremediateBYO, red |
no such env var | KAGENT_REQUIRE_APPROVAL=restart_deployment,scale_deployment |
sretriageBYO, green |
identical code to sreremediate |
untouched |
Read it back:
kubectl -n kagent get agent -L risk.platform.solo.io/verdict
NAME TYPE RUNTIME READY ACCEPTED VERDICT
srenative Declarative python True True red
sreremediate BYO True True red
sretriage BYO True True green
Step 4 — approve in the Enterprise UI
Ask a red agent to fix something. It reads the pods, reads the logs, reaches a diagnosis — none of that is gated — and then stops at the change, with Approve and Reject in the chat:
Click to expand
Everything above the buttons is the agent working normally: the pod logs with the
OOM evidence, and a diagnosis citing the 47 and 44 restarts. Then it stops. At this
point the tool has not run, the MCP server has no audit entry, and
checkout is still one replica short.
Click Approve and the call goes through:
Click to expand
The card keeps an Approved badge against the call, the result comes
back "ready": "3/3", and the agent carries on — here going further than it
was asked and pointing out that a restart is a temporary fix while the memory limit is
the real problem.
Reject instead and the agent is told, and stops without calling the tool.
Approving from a pipeline instead
The UI is one client of kagent's API, not the only way in. An approval is a follow-up message on the paused task, which is exactly what the UI sends when you click.
Reach the API and mint a token. The Enterprise control plane authenticates against Keycloak, so the call carries a bearer token from the same realm the UI uses:
kubectl -n kagent port-forward svc/kagent-controller 8083:8083 &
TOKEN=$(curl -s -X POST \
"http://keycloak.$LB.sslip.io/realms/agentregistry/protocol/openid-connect/token" \
-d grant_type=password -d client_id=kagent-cli-password \
-d username=admin-user -d password=password | jq -r .access_token)
Ask a gated agent to act. The task comes back unfinished, and the response carries the two ids the approval needs — the task itself, and the confirmation being requested:
curl -s -X POST http://localhost:8083/api/a2a/kagent/srenative/ \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":"1","method":"message/send","params":{"message":{
"role":"user","messageId":"ask-1",
"parts":[{"kind":"text","text":"restart the checkout deployment in shop"}]}}}'
task state : input-required
taskId : b4909bd6-d1bf-4f62-ac7d-2698f65b9c14
contextId : 5337a401-8891-4a34-97a5-c74a0b290503
confirmation : adk-30bb05f8-0b69-4300-8481-49b1df57ce29
state: input-required is the pause. The tool has not run. Now decide, on
the same task: same taskId and contextId, and a
function_response part answering that confirmation:
curl -s -X POST http://localhost:8083/api/a2a/kagent/srenative/ \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{
"jsonrpc": "2.0", "id": "2", "method": "message/send",
"params": { "message": {
"role": "user",
"taskId": "b4909bd6-d1bf-4f62-ac7d-2698f65b9c14",
"contextId": "5337a401-8891-4a34-97a5-c74a0b290503",
"messageId": "decide-1",
"parts": [{
"kind": "data",
"metadata": { "kagent_type": "function_response" },
"data": {
"id": "adk-30bb05f8-0b69-4300-8481-49b1df57ce29",
"name": "adk_request_confirmation",
"response": { "confirmed": true }
}
}]
}}
}'
task state : completed
reply : The checkout deployment has been successfully restarted.
All 3 replicas are now ready and running.
"confirmed": false is the reject, and the whole payload is otherwise
identical. The tool never runs.
scripts/approve.sh walks all of it for you:
$ ./scripts/approve.sh srenative "restart the checkout deployment in shop" approve
task state : completed
Agent: Done. All 3 replicas are now ready (3/3).
audit entries : 1
$ ./scripts/approve.sh srenative "restart the checkout deployment in shop" reject
task state : completed
Agent: The restart was rejected by the user. I cannot proceed.
audit entries : 0 <-- the tool never ran
How the pause actually works
Both agent types end up in the same place, through the same mechanism.
ADK, the agent framework, supports marking a tool as needing confirmation. When the agent reaches such a tool, ADK stops before calling it and emits a confirmation request. kagent renders that as the approval card, and sends the decision back when a human answers.
- For a Declarative agent, kagent owns the tool list, so
requireApprovalis all it needs. - For a BYO agent, kagent only sees a pod. The agent's own
toolsets read
KAGENT_REQUIRE_APPROVALand pass ADK'srequire_confirmation, producing the identical confirmation.
The BYO side is a few lines in the agent, and note what they do not contain: any tool name, or any decision about what is sensitive. That comes from the platform:
pythonartifacts/AGENT_TEMPLATE.py — the tools are built with ADK's confirmation hook
def _gated_tools():
"""Tool names the PLATFORM decided need approval."""
raw = os.environ.get("KAGENT_REQUIRE_APPROVAL", "")
return {t.strip() for t in raw.split(",") if t.strip()}
def build_mcp_tools():
gated = _gated_tools()
servers = json.loads(os.environ.get("MCP_SERVERS_CONFIG", "") or "[]")
toolsets = []
for srv in servers:
if not gated:
toolsets.append(MCPToolset(connection_params=conn(srv["url"])))
continue
# everything the platform did NOT name — runs straight through
toolsets.append(MCPToolset(
connection_params=conn(srv["url"]),
tool_filter=lambda tool, ctx=None: tool.name not in gated,
))
# the tools it DID name — ADK pauses and kagent asks a human
toolsets.append(MCPToolset(
connection_params=conn(srv["url"]),
tool_filter=lambda tool, ctx=None: tool.name in gated,
require_confirmation=True,
))
return toolsets
Two toolsets per server rather than one, because
require_confirmation is a per-toolset setting. Splitting by
tool_filter is what lets the tool name decide.
Run it
Needs ANTHROPIC_API_KEY, SOLO_LICENSE_KEY, and
gcloud auth login for the Enterprise charts.
./scripts/quick.sh up # ~20 min cold
./scripts/quick.sh status # prints the UI URL and how each agent is gated
When it is up, the cluster registers itself in the UI with its agent count:
Click to expand
Then compare the two BYO agents. Same code, same tools, same prompt — only the register differs:
./scripts/ask.sh sretriage "checkout is unhealthy — diagnose and fix it" # just does it
./scripts/ask.sh sreremediate "checkout is unhealthy — diagnose and fix it" # waits for you
Then change the decision and watch the same agents behave differently. Every variant is in editing the register — a tool, a whole server, a new agent, or the default posture. Nothing else is touched:
# scaling still needs a human, restarts no longer do
kubectl -n kyverno patch configmap agent-risk-register --type merge \
-p '{"data":{"gated":"- server: sre-tools\n tools: [scale_deployment]\n"}}'
./scripts/07-verdict.sh # re-admit, so the running agents pick it up
./scripts/quick.sh status # shows what each agent ended up gated on
The policy runs when the Agent resource is written, so an agent that is already
running picks up a change on its next admission. In a pipeline the register is read at
first deploy and there is nothing extra to do; on a running cluster,
./scripts/07-verdict.sh re-admits them for you.
Related
- End-user and platform approval gates for MCP agents — approvals held at the gateway instead, for when the approver must not be the person chatting to the agent.
- Curating the tools an agent can reach — the other answer to a risky agent: narrow the tool set rather than gate it.
- AgentRegistry identity and access policies — who is allowed to publish an agent in the first place.
- Solo Enterprise for kagent docs
- Kyverno mutation reference
Versions
Built and verified on:
v2026.7.10.5.32026.6.1v2026.6.1v1.13.4v1.5.1v0.14.9v1.35.0