A developer writes an agent. Someone else decides it needs a human to approve its changes. Nobody wants to ask the developer to implement that, and nobody wants a control the developer can delete. It turns out you can add it from outside, with a ConfigMap and one policy.
The setup is ordinary. A developer builds an agent, gives it some tools, ships it. Then a review happens somewhere else: a security review, a change board, a scanner in CI, someone reading a diff. It comes back saying this one is fine, that one is touching production and needs a human in front of it.
Turning that into an enforced gate has one hard constraint, and it is not the one people usually start with. It is not "how do we prompt the agent to ask permission". It is this: anything the agent's own code does is something the agent's own code can stop doing. A confirmation step the developer implements is one they can remove, forget, or refactor away in a hurry on a Friday. If the control lives inside the agent, it is a convention, not a control.
So the control has to sit somewhere the developer does not own. Here is where that turned out to be.
The shape of it
Click to expand
Three moving parts, none of them written by me:
- A ConfigMap holding the names of agents that need a human.
- A Kyverno policy that reads it at admission and turns approval on.
- kagent, which pauses the tool and asks.
What I like about this is how little of it is code. There is no service to run, no queue to operate, no webhook of my own. The agent is not modified, rebuilt or republished, and it has no idea any of this happened.
The decision is a ConfigMap
Something has to hold the answer to two questions: which agents need a human? and for which tools? For a single cluster the simplest thing that works is a ConfigMap:
kubectl -n kyverno create configmap agent-risk-register \
--from-literal=red="sreremediate,srenative" \
--from-literal=default="green" \
--from-literal=gated='- server: sre-tools
tools:
- restart_deployment
- scale_deployment'
red names the agents. default is the answer for everything not named — set it to red and every agent needs approval unless explicitly cleared, which is the correct direction for a control. gated says which tools, keyed by MCP server.
Keeping gated here rather than in the policy is the difference between a control you can operate and one you cannot. The policy is cluster-wide admission control: changing it is a change-managed event, with a review and a blast radius covering every agent. Adding a tool to a register entry is a one-line patch to one ConfigMap. Those should not be the same act, and a platform with a few hundred tools across a dozen MCP servers is going to do the second one constantly.
It also means a server with fifty tools is one entry, not fifty:
- server: payments-tools
tools: ["*"]
The wildcard covers tools that server has not shipped yet, which is the case I care about more. A new tool appearing on an already-red server should not be a gap that stays open until someone notices.
The agent axis is optional. red: "*" puts every agent in scope, which
leaves the tool list as the only thing deciding anything:
red: "*"
gated: |
- server: sre-tools
tools:
- restart_deployment
Read that as: any agent that can call restart_deployment needs a
human, whoever built it and whenever it turns up. This is the version I would actually
run. Naming agents assumes you know which ones matter, and that assumption expires the
moment teams can ship their own — an agent deployed tomorrow by a team you have never
spoken to is gated at its first admission, with nobody having to remember to add it.
It stays narrow where it should: the tool list is still resolved against each
agent's own servers, so a billing agent that only talks to payments-tools
is untouched. Broad in agents, narrow in tools.
One thing worth getting right, and I did not at first: the control has to work in
both directions. Clearing red has to remove the mutation from
agents that already carry it, not just relabel them. Otherwise the agent keeps pausing
while the register says it should not, which is safe and also a lie, and the person
debugging it has no reason to look at an admission policy. Kyverno's preconditions are
a flat all/any and cannot express "out of scope or
nothing gated" in a single rule, so the removal ends up as its own set of rules rather
than a condition on the existing ones.
It is a ConfigMap rather than a label on the agent for one reason: the policy has to fire on create. The Agent resource is created by AgentRegistry, so a label could only be added afterwards — which leaves a period, however brief, where the agent is running and nothing has marked it. Reading a ConfigMap at admission closes that, and lets you mark an agent before it has ever been deployed.
One policy, applied before the resource is stored
Kubernetes lets a service sit in the path of every write to the API server. The API server pauses, hands the resource over, and stores whatever comes back. Kyverno lets you write that as a policy rather than a service.
Two properties are why it belongs here. It runs before the resource is stored, so there is no window where the agent runs unmodified. And the author does not get a say — the developer submits their manifest, and what gets stored is whatever the webhook returns.
The rule reads the register and sets one field. Note what is not in it: no agent name, and no tool name.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verdict-hitl-enrolment
spec:
background: false # only on write, never a background sweep
rules:
- name: approval-declarative
match:
any:
- resources:
kinds: [kagent.dev/v1alpha2/Agent]
namespaces: [kagent]
context:
- name: register # go and read the ConfigMap
configMap: { name: agent-risk-register, namespace: kyverno }
preconditions:
all:
- key: "{{ request.object.spec.type || '' }}"
operator: Equals
value: Declarative
any: # named red, OR red is "*", OR deny-by-default
- key: "{{ contains(split(register.data.red || '', ','), request.object.metadata.name) }}"
operator: Equals
value: true
- 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
- name: want
variable:
jmesPath: "parse_yaml(register.data.gated || '[]')[?server=='{{ element.mcpServer.name }}'].tools | [0] || `[]`"
# "*" takes the lot; otherwise keep only tools this agent has
- name: gated
variable:
jmesPath: "contains(`{{ want }}`, '*') && element.mcpServer.toolNames || element.mcpServer.toolNames[?contains(`{{ want }}`, @)]"
preconditions:
all:
- key: "{{ length(gated) }}"
operator: GreaterThan
value: 0
patchesJson6902: |-
- op: add
path: "/spec/declarative/tools/{{ elementIndex }}/mcpServer/requireApproval"
value: {{ gated }}
Two small things in there took the longest to get right.
{{ element.mcpServer.name }} is substituted before the JMESPath runs, so the register lookup stays an ordinary filter over a list. The obvious alternative, a ConfigMap key per server read by variable name, needs nested interpolation and is fragile.
The intersection with toolNames is not tidying. A CEL rule on the Agent CRD requires every requireApproval entry to also appear in toolNames, so if the register names a tool this particular agent does not have, the API server rejects the whole resource and the deploy fails. Intersecting is what lets one register cover a fleet of agents with different tool sets. And it has to be an exact match — contains() against a comma-joined string would let an entry called scale gate scale_deployment, which is the same class of bug as matching sre against sreremediate in the red list.
The BYO rule does the same lookup from the other end. kagent cannot see a BYO agent's tools, but the agent's own MCP_SERVERS_CONFIG lists the servers it connects to, by name — the same key the register is written against. So the policy reads the agent's own wiring to work out which register entries apply to it:
- 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[]"
The result, on three agents built from the same template:
NAME TYPE RUNTIME READY ACCEPTED VERDICT
srenative Declarative python True True red
sreremediate BYO True True red
sretriage BYO True True green
sretriage and sreremediate are byte-identical apart from their name. Everything that differs between them was done from outside.
What actually pauses the tool
This is the part I had wrong for a while, and it is the useful bit.
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 an approval card, and sends the decision back when someone answers.
So the agent reads the pods, reads the logs, forms a diagnosis, and then stops dead at the change:
Diagnosis: The checkout deployment is experiencing Out-of-Memory (OOM) kills.
Two pods are in CrashLoopBackOff with 47 and 44 restarts respectively, and logs
show heap usage climbing to 91% before allocation failures.
Now I'll restart the deployment to fix this issue:
[ restart_deployment requires approval ]
In the UI that is Approve and Reject in the chat, under the tool call and its arguments:
Click to expand
At that moment the tool has not run and the MCP server has no audit entry. Approve, and the call goes through with the decision recorded against it:
Click to expand
Reject instead and the agent is told, and stops without calling the tool.
The bit that surprised me
A kagent Agent has two shapes, chosen with spec.type. A Declarative agent hands kagent a system prompt and a tool list. A BYO agent hands it a container image — and spec.byo has exactly one field, deployment. No tool list.
So requireApproval, which lives on the tool list, has nowhere to go on a BYO agent. I took that to mean BYO agents could not use kagent's approval flow at all, and went off and built a gateway gate with an ext-auth service that held the request open until someone decided. It worked. It was also a custom Go service that somebody would have to support, which is the wrong shape for a platform feature.
The way out is that MCPToolset takes require_confirmation directly. Wire it to an environment variable and the platform decides:
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:
# 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
The agent names no tool and makes no decision about what is sensitive. It reads a variable. The same Kyverno policy sets it, so both agent types are covered by one decision and land in the same UI.
Two toolsets per server rather than one, because require_confirmation is a per-toolset setting: when ADK calls the callable form it passes the tool's own arguments, not the tool name, so a single predicate cannot tell which tool it is being asked about. Splitting by tool_filter is what puts the tool name where it can be used.
And the whole gateway apparatus went in the bin. Right answer, wrong for about a day.
Do not gate the reads
My first version required approval for every tool call. That is the obvious reading of "this agent needs approval", and it is worse than useless.
The gated agent could not run list_pods without a human clicking approve. It could not read a log. To get one restart approved, a reviewer had to approve five reads first, and every one of them looked exactly like the one that mattered. Gating reads does not make a reviewer careful. It teaches them to click yes. By the time the restart arrives they have stopped reading.
So the register names the tools that change state, and nothing else. The agent diagnoses freely and pauses only where it acts, which is the one moment a human has anything to add.
This is the other reason the tool list belongs in the register and not the policy. Which tools are dangerous is a judgement that gets revised — someone adds a tool, someone decides a read is actually sensitive, someone widens a server to "*" after an incident. Every one of those is a small operational decision, and if making it means editing cluster-wide admission control, it will either not get made or get made carelessly.
Approving from a pipeline
The UI is one client of kagent's API, not the only way in. There is no separate approvals endpoint — an approval is a follow-up message on the paused task, which is what the UI sends when you click.
Ask a gated agent to act, and the task comes back unfinished in input-required, carrying a confirmation id. To decide, send another message to the same task:
POST /api/a2a/kagent/srenative/
{
"jsonrpc": "2.0", "id": "2", "method": "message/send",
"params": { "message": {
"role": "user",
"taskId": "<taskId>",
"contextId": "<contextId>",
"messageId": "decide-1",
"parts": [{
"kind": "data",
"metadata": { "kagent_type": "function_response" },
"data": {
"id": "adk-c8a72ee8-5701-4449-b1c5-d961a093384c",
"name": "adk_request_confirmation",
"response": { "confirmed": true } // or false to reject
}
}]
}}
}
The task moves to completed and the tool runs. Because it is a plain authenticated HTTP call, anything can be the approver: a Slack action, a ServiceNow change record, a CI job.
What I would take from this
The agent had to know almost nothing. It was not modified, not rebuilt, not consulted. It reads a tool list and an environment variable, and someone else decides what those contain.
That is the general shape. If you want a control an application cannot remove, it has to sit at a boundary the application must cross and does not own. For agents, admission control is that boundary — the moment the resource is written is the one moment you are guaranteed to be in the path, and the author is guaranteed not to be.
Three practical notes if you build this. Gate the tools that change things and let the reads through, or you will train your reviewers to rubber-stamp. Write the register so its default is the safe one, because the day it matters is the day someone forgets to add an entry. And keep both halves of the decision, which agents and which tools, out of the policy — the policy should be the thing you never have to touch.
The lab is at agentic-verdict-hitl-kind — three agents, one ConfigMap, and an approval card you can click. It runs on kind against agentgateway 2026.7.1, kagent Enterprise 0.5.3 and AgentRegistry 2026.6.1.
Sources and further reading
- agentic-verdict-hitl-kind — the lab, with every manifest quoted above
- 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
- Kyverno mutation reference —
foreach,elementIndexand the JSON patch forms - Solo Enterprise for kagent docs