MastertheMesh
agentregistry · agentgateway · claude code
Part 2 of 2 · Reference · OSS and Enterprise

Claude Code plugins in AgentRegistry, part 2: inside the policy-review plugin

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

Part two. The plugin part one published, taken apart: two reviews over one rule engine, what each rule is grounded in, and why the same {{ }} is correct in kgateway and an error in agentgateway.

policy-lint cluster-review PreToolUse hook CEL vs Inja status.ancestors

Part one was about the pipeline: author a bundle, pin it as a Plugin pointer, let the registry scan it into an inventory, serve it as a marketplace.json through agentgateway, install it. The plugin that travelled down that pipeline was real, and this part is about what it does.

It reviews agentgateway and kgateway configuration two ways. Point it at a file or a whole directory and it checks the config before it ships. Point it at a cluster and it reads what the controllers are actually saying, including the one state that no amount of reading YAML will reveal.

The state that makes a live review worth having

A policy can be valid YAML, accepted by the API server, and attached to nothing at all.

ERROR CL013  AgentgatewayPolicy team-a/rate-limit is accepted but not attached
      Pending: Policy is not attached: HTTPRoute team-a/openai not found

Accepted and Attached are different questions, and a policy can be the first without being the second. It is valid, so nothing looks wrong. It is bound to nothing, so nothing enforces it. In kubectl get it is indistinguishable from a policy that works, and it explains most reports of "my policy does nothing".

Three things cause it, and they are worth checking in this order:

  1. The targetRef names an object that does not exist, or that was renamed.
  2. The target is in another namespace. These targetRefs carry no namespace field, so the target is always same-namespace.
  3. The policy kind and the gateway class belong to different editions, so neither control plane claims it.

None of that is visible in the file. All of it is in status, which is the argument for a reviewer that reads a cluster rather than only a repository.

One rule engine, two front ends

PathWhat it is
bin/soloreview.pyThe rule engine, and a YAML parser that records the line each field sits on. Imported, never run directly.
bin/policy-lintFront end for files and directories.
bin/cluster-reviewFront end for a live cluster, over kubectl.
bin/guard-applyThe hook handler. Turns an error-level finding into a blocked kubectl apply.
skills/, commands/One skill and one command per review, so the model knows which tool to reach for and how to report what comes back.
agents/policy-auditor.mdA read-only sub-agent that tables a whole tree instead of reviewing one file in prose.
examples/Clean and deliberately broken samples for both products.

Both front ends hand the engine the same thing: a list of objects, each with an apiVersion, a kind and a spec. One parses them out of YAML on disk, the other pulls them from a cluster. Because the engine cannot tell which, a finding is worded identically whether it came from a file in a pull request or from an object already carrying traffic, and the field rules run in both places without being written twice.

There are no dependencies outside the Python standard library, which is why the YAML parser is hand written. Two reasons. A plugin someone installs from a registry should not ask them to set up a virtualenv first. And a parser you control can record a line number per field path, which is the difference between pointing at policy.yaml:29 and waving at the document.

Reviewing a file, or a directory

The first thing either front end does is work out what it is looking at, from the API group alone:

API groupResolves to
agentgateway.devagentgateway, OSS
enterpriseagentgateway.solo.ioagentgateway, Enterprise
gateway.kgateway.devkgateway
gateway.networking.k8s.ioGateway API
anything elsenot reviewed, counted, and left alone

That last row is what makes a directory sweep usable. A bundle full of Deployments, Services and ConfigMaps is normal, a gateway reviewer has nothing useful to say about them, so it says nothing and reports how many it skipped. Pointing this at a repository root is safe.

Run it on something clean first, because a checker you have only ever seen fail is a checker you have not tested:

policy-lint examples/kgateway-clean.yaml
policy-lint 0.4.2
reviewed 1 file, 1 document

RESULT: NOTHING TO REPORT

That file is full of {{ }} and it passes, which is the point. Then a file with real problems in it:

policy-lint examples/agentgateway-review-me.yaml
policy-lint 0.4.2
reviewed 1 file, 3 documents

examples/agentgateway-review-me.yaml
  ERROR AGW001  agentgateway-review-me.yaml:20 traffic.entRateLimit is Enterprise-only
        in AgentgatewayPolicy agentgateway-system/rate-limit-teams
        Either switch this document to the Enterprise kind
        (EnterpriseAgentgatewayPolicy, group enterpriseagentgateway.solo.io)
        or drop the field.

  ERROR AGW006  agentgateway-review-me.yaml:29 expression reads jwt claims but the policy sets no jwtAuthentication
        in AgentgatewayPolicy agentgateway-system/rate-limit-teams
        Either add traffic.jwtAuthentication here, or confirm another policy
        on the same target authenticates the token first. Otherwise this
        fails closed: jwt.team == "ml-platform"

  ERROR AGW003  agentgateway-review-me.yaml:35 traffic.transformation.request.set.0.value contains {{ jwt.team }}
        in AgentgatewayPolicy agentgateway-system/rate-limit-teams
        Rewrite it as CEL. A literal needs its own quotes, as in "'prefix-'
        + request.headers['x-id']".

  ERROR AGW007  agentgateway-review-me.yaml:53 policies.auth.key holds a literal key (sk-not...[36 chars])
        in AgentgatewayBackend agentgateway-system/anthropic
        Replace it with secretRef naming a Secret in the same namespace.

RESULT: 6 ERROR, 4 WARN

Four of the six errors are shown, the rest trimmed for length. The last finding does not print the key. It is redacted to a prefix and a length, because a reviewer that echoes credentials into a CI log has made the problem worse rather than better.

The same {{ jwt.team }} is an error here and was fine in the previous file. Nothing about the two lines differs. The API group does.

The templating split

This is the one distinction worth carrying in your head, because getting it backwards produces confident wrong advice in both directions.

agentgatewaykgateway
EngineCELInja
A literal stringvalue: "'production'", inner quotes and allvalue: production
A request fieldvalue: request.pathvalue: '{{ request_header("x-id") }}'
{{ body("model") }}never expanded, ships the braces as textcorrect

A transformation copied from one product into the other is inert, and it is inert quietly: the policy applies, the controller accepts it, and the header goes out with the braces still in it. That is why the reviewer resolves the product before it applies a single rule, and why a linter that just greps for {{ is worse than nothing.

The CEL side has a second trap the reviewer flags separately. Because a transformation value is an expression, a bare word is read as an identifier rather than a string:

WARN  AGW004  traffic.transformation.request.set.1.value is the bare word 'production'
      CEL reads that as an identifier. For a literal string write
      "'production'" with the inner quotes.

Reviewing a cluster

There is no kubeconfig parsing here. It shells out to kubectl, so it inherits whatever authentication you already have, SSO and exec plugins included. What it will not do is assume what is installed:

kubectl api-resources --verbs=list -o name     # discovery, not assumption
kubectl get <each kind that is actually served> -o json --all-namespaces
kubectl get secrets -o jsonpath={..name}       # names only, never contents

Kinds the cluster does not serve are skipped, so the same command works against an OSS-only install, an Enterprise install, a kgateway install, or a cluster with none of them. Read-only by construction: every call is get, api-resources or version. Secrets are listed by name, so the tool cannot print a credential it read out of a cluster even by accident.

cluster-review --context kind-kgw-tls
cluster-review 0.4.2
context:  kind-kgw-tls
server:   v1.35.0
scope:    all namespaces
products: kgateway, Gateway API
objects:  1 Gateway, 1 GatewayClass, 1 HTTPRoute, 1 TLSRoute

  WARN  CL003  Gateway demo/shared has no address
        It is programmed but has no address assigned, so nothing outside the
        cluster can reach it. Usually a pending LoadBalancer.

  INFO  GW001  listener 'passthrough' is TLS Passthrough
        at spec.listeners.0.tls
        No L7 policy applies to a passthrough listener: the gateway cannot
        read the request. Expect JWT, authorization and transformation rules
        on it to do nothing.

RESULT: 1 WARN, 1 INFO

The header names the context before any finding, because a review of the wrong cluster that reads authoritatively is worse than no review. The passthrough note is the kind of thing only a live read produces: nothing is broken today, but any L7 policy later aimed at that listener will be silently inert, because the gateway never sees inside the TLS stream.

If a read fails on RBAC, the tool says which check it skipped rather than reporting a clean bill of health. A skipped check is not a passed check, and a reviewer that blurs the two is worse than one that admits its blind spot. In JSON that becomes a field you can assert on, which is what makes the distinction usable in a pipeline rather than only in prose:

cluster-review --context kind-kgw-tls --format json | jq '{skipped, counts}'
{
  "skipped": [],
  "counts": { "error": 0, "info": 1, "warn": 1 }
}

An empty skipped is the only thing that makes "error": 0 mean what it looks like it means.

One finding above needs context before it worries you. CL003 fires on every kind cluster, because a Gateway there is fronted by a LoadBalancer Service and kind has no controller to assign one, so the address stays <pending> forever. The Gateway is otherwise Accepted, Programmed and ResolvedRefs True and its listeners have routes attached, so the config is fine and only the address is missing. On kind, reach it with a port-forward and read the WARN as a note about your environment. On a real cluster the same WARN means what it says.

Thirteen of the checks need a cluster, because they need status or a second object to compare against:

What it catchesWhy it needs a cluster
Gateway class not accepted, Gateway not programmed, no addressControl plane state lives only in status.conditions.
Route rejected by its parentstatus.parents[].conditions, per parent.
Policy accepted but not attached, or not accepted at allstatus.ancestors[].conditions, quoted with the controller's own message.
A targetRef or secretRef naming something that is not thereNeeds the rest of the namespace to compare against.
Several policies of one kind on one targetNeeds every policy at once. Merge order decides which fields win.
An Enterprise policy on an OSS gateway classNeeds the Gateway to read its gatewayClassName.
One kind served by two API groupsTwo products or chart releases each believe they own the CRD, so upgrading either can break the other.

Where the rules come from

A reviewer is only worth installing if you can ask it why. Every rule here is grounded in one of three things and nothing else, and anything that could not be tied to one of them did not become a rule. --list-rules prints the catalogue with the reasoning attached.

The shipped CRD schemas. The Enterprise-only field list is a full-depth diff of the OSS and Enterprise schemas as installed: 1437 field paths against 2636. That makes traffic.entRateLimit in an AgentgatewayPolicy a fact about the API rather than an opinion, and it keeps the rule honest when a field moves between editions. Deriving it that way corrected three things I would otherwise have asserted from memory: backend.extAuth exists in both editions, and claimsToHeaders and entJwt are not agentgateway fields at all. The first is on the kgateway GatewayExtension; nothing named entJwt exists in either CRD.

The controller's own status. There is no need to infer whether something is working when the object says so. Accepted, Programmed, ResolvedRefs and Attached are read straight out, and the message is quoted rather than paraphrased.

Documented behaviour where a shape applies cleanly and then does something other than it looks like. The templating split above is the big one. So is parseAs: None in a kgateway TrafficPolicy, where the CRD's own field description says body processing is skipped entirely, which makes a body transformation next to it a silent no-op and a header template reading the body a 400.

Severity, and what blocks

SeverityMeansExit codeBlocks an apply
errorApplies cleanly and then misbehaves, or is rejected outright1yes
warnWorks, but probably not as intended, or gives up a safety property0no
infoA note, usually "confirm this with a real request"0no

Exit 2 is reserved for a path that could not be read or a cluster that could not be reached, which is deliberately a different answer from "clean". Both tools use the same three codes, so either can gate a pipeline without a wrapper around it.

What the hook actually does

The hook is the only part of the bundle that stops something happening. It is registered as PreToolUse on Bash, so Claude Code runs it before every Bash tool call, hands it the pending call as JSON on stdin, and uses its exit code to decide whether that call proceeds. This is enforcement at the tool-call boundary rather than instruction to the model: if the hook exits 2, kubectl is never invoked, and the model does not get a say in it.

In order, it:

  1. Reads the pending command from stdin. If that is not parseable, exits 0. A guard that trips over its own plumbing has to fail open, or it bricks every Bash call in the session.
  2. Checks whether the command is a kubectl apply. The pattern stops at a pipe or an &&, so it will not match an apply belonging to some other command further along the line. Anything else exits 0 and is invisible.
  3. Lets --dry-run straight through. Asking to see what would happen is exactly how you inspect a file this hook just complained about, so blocking it would corner you.
  4. Extracts the -f and --filename paths, ignoring - and any URL, since there is no local file to read in those cases.
  5. Runs policy-lint <path> --severity error on each one. Directories work, because the linter walks them.
  6. On exit 1, writes the findings to stderr and exits 2. On exit 0 it moves on. On exit 2 it also moves on: a file it could not read is not its call to block.
guard-apply blocked this apply: policies/ has error-level findings from
agentgateway-policy-review.

policy-lint 0.4.2
...
  ERROR AGW003  policy.yaml:35 traffic.transformation.request.set.0.value contains {{ jwt.team }}

Fix them, or re-run with --dry-run=server to inspect without applying.
Warnings and notes do not block.

stderr goes back to the model, so it sees why it was stopped and can fix the file rather than simply being refused.

Errors only, and that is the important design choice. An earlier version of this hook blocked on any failed check, and its checks were wrong in three ways, so in practice it refused a valid kgateway policy because Inja braces tripped a CEL rule, any bundle containing a Namespace because v1 was not in its list of known API groups, and every Enterprise policy because enterpriseagentgateway.solo.io was not in that list either. All three were applies that were completely fine. A guard that blocks correct work gets uninstalled, so advice no longer blocks anything.

Command reference

Both scripts are standalone and work outside Claude Code, which is what makes them usable in CI.

policy-lint ./policies                    # a whole tree, walked for .yaml and .yml
policy-lint ./policies/openai.yaml        # one file
policy-lint ./policies --severity error   # only what is broken
policy-lint ./policies --format json      # for a pipeline
policy-lint ./policies --rule AGW003      # check one thing
policy-lint --list-rules                  # the catalogue, with the reasoning

cluster-review                            # current context, every namespace
cluster-review --context prod-eu-west-1
cluster-review -n agentgateway-system     # when you lack cluster-wide read
cluster-review --format json
cluster-review --list-rules

Inside a session the two slash commands are namespaced by the plugin, so they take the qualified name:

/default.agentgateway-policy-review:policy-review ./policies/
/default.agentgateway-policy-review:cluster-review prod-eu-west-1

For a tree with more than a handful of files, the policy-auditor sub-agent is the better entry point: it runs the linter once over the whole tree in JSON and returns a table rather than reviewing file after file in prose.

Letting the harness run it

Both reviews are bin/ executables, so Claude Code asks before running them the first time. That is the right default for something installed from a registry, and the skills are written to report the refusal rather than route around it: decline the prompt and you get "not run", not a guess presented as a result. To stop being asked every session, allow the two scripts:

{
  "permissions": {
    "allow": [
      "Bash(${CLAUDE_PLUGIN_ROOT}/bin/policy-lint:*)",
      "Bash(${CLAUDE_PLUGIN_ROOT}/bin/cluster-review:*)"
    ]
  }
}

Read bin/soloreview.py first if you would rather see what you are approving. It is the whole rule set, and it makes no network calls.

Two bugs the tool found in itself

Neither of these came out of reading the code, which is the argument for shipping examples/ and for pointing a new reviewer at a cluster you already understand.

The registry inventory caught a skill with no description. The frontmatter read description: ... Read-only: reads status ..., and a bare colon-space ends a YAML plain scalar. Claude Code loaded the skill without a word of complaint. The registry recorded exactly what it could parse, which is the more useful answer, and the missing field in status.inventory is where it showed up.

Running the live review against a real SNI split found the reviewer half blind. The gateway in the output above separates two hostnames on one port, and its passthrough listener is served by a TLSRoute. The first version read only HTTPRoute and GRPCRoute, so it undercounted the routes and would have called a TLSRoute-only gateway orphaned. Fixed by reading all four route kinds.

What to take from this

Building a reviewer someone will keep installed

Back. Part 1: publish and install is how this plugin got into a Claude Code session in the first place: a commit-pinned Plugin pointer, an inventory scanned out of the bundle, and a marketplace.json served through agentgateway.

Versions

Built and verified on both editions:

OSS
kgatewayv2.4.2
agentgateway CRDsagentgateway.dev/v1alpha1
Gateway APIv1.6.1 (experimental channel, for TLSRoute)
Kubernetes (kind)v1.35.0
Claude Code2.1.226
Python3.14.6 (standard library only)
Enterprise
enterprise-agentgatewayv2026.7.0
AgentRegistry Enterprisev2026.7.1-49-gcfbe08f2 (built from source, pre-GA)
Gateway APIv1.4.0
Kubernetes (kind)v1.34.0
Claude Code2.1.226
agentgateway-policy-review0.4.2