MastertheMesh
Solo · kagent · agentgateway · AgentRegistry · Kyverno · HITL · kind
Runs on kind · Enterprise

Impose Human Approval on an Agent the Developer Built

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

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

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

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:

DeclarativeBYO
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:

Solo Enterprise for kagent, Agents list: srenative Healthy with model Anthropic claude-haiku-4-5 and required tool sre-tools; sreremediate and sretriage both Healthy but showing No Tools and no model.

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:

Tool Servers view: sre-tools as a RemoteMCPServer with five discovered tools — describe_deployment, get_pod_logs, list_pods, restart_deployment and scale_deployment — each with its description.

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
KeyWhat 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
FieldWhat 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:

AgentIts toolsWhat 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)
StanzaWhat 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 1
approval-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 2
approval-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–6
ungate-*
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 & 8
label-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

AgentDeveloper's manifestStored in the cluster
srenative
Declarative, red
no requireApproval ["restart_deployment","scale_deployment"]
sreremediate
BYO, red
no such env var KAGENT_REQUIRE_APPROVAL=restart_deployment,scale_deployment
sretriage
BYO, 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:

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 its arguments and Approve / Reject buttons.

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:

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

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.

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:

Clusters view: the verdict cluster Healthy, 2 of 2 nodes, Kubernetes 1.35.0, 3 agents.

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.

Versions

Built and verified on:

Enterprise
Solo Enterprise for agentgatewayv2026.7.1
Solo Enterprise for kagent0.5.3
Enterprise AgentRegistry2026.6.1
arctlv2026.6.1
Kyvernov1.13.4
Gateway APIv1.5.1
MetalLBv0.14.9
Kubernetes (kind)v1.35.0