MastertheMesh
Blog · 2026-08-04

Adding human approval to an agent you did not write

TO
Tom O'Rourke
EMEA Field CTO · Solo.io

Add an agent's name to a ConfigMap, and every change it tries to make waits for a person. No approval logic in the agent, and nothing it can switch off.

Kyverno admission kagent requireApproval ADK confirmation AgentRegistry MCP

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

CI / review your pipeline Risk register ConfigMap Kyverno admission webhook kagent runs the agent Reviewer Enterprise UI 1 red-list the agent configmap/agent-risk-register red: sreremediate, srenative gated: sre-tools → restart_deployment scale_deployment 2 developer deploys the agent no approval logic in it 3 red? and which tools? 4 look up the MCP servers this agent uses Declarative → mcpServer.toolNames BYO → MCP_SERVERS_CONFIG only the tools it really has are gated 5 turn approval on before the resource is stored Declarative → requireApproval BYO → KAGENT_REQUIRE_APPROVAL the developer never sees this 6 read tools run freely list_pods, get_pod_logs 7 a gated tool PAUSES approval card in the UI 8 approve → the tool runs reject → it never executes One ConfigMap says who and what. One policy enforces it. kagent asks the human. Nothing else to run.

Click to expand

Three moving parts, none of them written by me:

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:

Solo Enterprise for kagent chat: the agent has read the pod logs showing OOMKilled, states its diagnosis, then presents the restart-deployment tool call with Approve and Reject buttons.

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:

The same tool call after approval: an Approved badge, the result showing restarted true and ready 3/3, and the agent reporting all three replicas ready while noting the underlying memory issue remains.

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

Tom O'Rourke is EMEA Field CTO at Solo.io, where he spends most of his time on the infrastructure between agents and the things they act on.