Skip to content
Conduit

skill

kat

Kubernetes Admission Tester

zemanlx/katcommunitylistedunreviewed

Set up

Skills are installed by placing their folder where your agent loads skills.

The download comes from github.com/zemanlx/kat itself, not from our server, so what you get is exactly what its author published.

Install instructions come from the README

Skills do not use an MCP config. Follow the steps in the README below.

README

synced Oct 22, 2026

kat - Kubernetes Admission Tester

CI License

kat is a lightweight, local testing tool for Kubernetes Admission Policies (ValidatingAdmissionPolicy and MutatingAdmissionPolicy). It allows you to write test cases using standard Kubernetes manifests and verify your policies' behavior without needing a running cluster.

kat supports Kubernetes 1.36 and later, the first release that serves both policy kinds as admissionregistration.k8s.io/v1. A Kubernetes minor version stays supported until it reaches its upstream end of life. Every supported minor is checked in CI against a real kube-apiserver (see Conformance with the API server).

Quick Start

Given a policy like this:

my-policy/
├── policy.yaml
└── tests/
    ├── my-policy.good-pod.allow.object.yaml
    └── my-policy.bad-pod.deny.object.yaml

The policy itself is a standard ValidatingAdmissionPolicy:

# policy.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: my-policy
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
    - apiGroups: [""]
      apiVersions: ["v1"]
      operations: ["CREATE", "UPDATE"]
      resources: ["pods"]
  validations:
  - expression: "has(object.metadata.labels) && 'owner' in object.metadata.labels"
    message: "All workloads must have an 'owner' label"
    reason: Invalid

The simplest test is just a Kubernetes object:

# tests/my-policy.good-pod.allow.object.yaml
apiVersion: v1
kind: Pod
metadata:
  name: good-pod
  labels:
    owner: platform-team
# tests/my-policy.bad-pod.deny.object.yaml
apiVersion: v1
kind: Pod
metadata:
  name: bad-pod
  # Missing required label

Run it:

kat .
ok  	my-policy	0.004s

That's it. kat discovers the policy, finds the tests, and evaluates them. The filename tells kat everything: which policy to test (my-policy), what to expect (allow or deny), and what the file contains (object). See File Naming Convention for the full grammar.

For deny tests you can also add a .message.txt file to assert the exact rejection message. It's optional, but recommended — it catches policies that fail for the wrong reason.

The output and flags mirror go test, so kat drops straight into existing CI. Use -v to see each test case, and note that kat exits non-zero when any test fails:

=== RUN   my-policy
=== RUN   my-policy/my-policy.bad-pod.deny.yaml
--- PASS: my-policy/my-policy.bad-pod.deny.yaml (0.00s)
=== RUN   my-policy/my-policy.good-pod.allow.yaml
--- PASS: my-policy/my-policy.good-pod.allow.yaml (0.00s)
PASS

When a test fails, kat reports why — including a diff for mutating policies:

--- FAIL: add-default-labels/add-default-labels.no-labels.yaml (0.00s)
    mutated object does not match expected:
    --- Expected
    +++ Actual
    @@ -2,7 +2,7 @@
     metadata:
         labels:
    -        environment: dev
    +        environment: development
FAIL	add-default-labels	0.008s
test summary: tests failed: 2

Installation

Download a prebuilt binary

No Go toolchain required. Prebuilt binaries for Linux, macOS, and Windows (amd64 and arm64) are published on the Releases page.

On Linux or macOS, download the archive for your platform, extract kat, and put it on your PATH:

# Pick your platform: linux/darwin and amd64/arm64
OS=linux ARCH=amd64
curl -fsSL "https://github.com/zemanlx/kat/releases/latest/download/kat_${OS}_${ARCH}.tar.gz" \
  | tar -xz kat
sudo mv kat /usr/local/bin/
kat -h

On Windows, download kat_windows_amd64.zip (or arm64) from the Releases page, extract kat.exe, and place it somewhere on your PATH.

Each release also ships a checksums.txt to verify your download:

curl -fsSL -O "https://github.com/zemanlx/kat/releases/latest/download/checksums.txt"
sha256sum -c checksums.txt --ignore-missing

Install with Go

go install github.com/zemanlx/kat@latest

Or build from source:

git clone https://github.com/zemanlx/kat.git
cd kat
go install

Usage

Run from the root of your repository — kat will automatically discover and execute all tests found in tests/ directories recursively:

kat .

Target one or more directories (a policy directory, a tests/ directory, or any parent):

# Run tests for a specific policy
kat ./policies/my-policy

# Run several policies at once
kat ./policies/my-policy ./policies/other-policy

To run a single test case, use -run to match it by name:

kat -run "my-policy.basic-test" ./policies/my-policy

Flags

  • -run <regex>: Run only tests matching the regex pattern.
  • -v: Verbose output (shows detailed execution steps).
  • -json: Emit newline-delimited JSON events, compatible with go test -json (works with tooling like gotestsum).
  • -k8s-version <major.minor>: Check new policies the way this Kubernetes version's API server does (default: the latest supported, currently 1.37; 1.36 is the oldest). It selects the creation-time CEL feature set, which is the previous minor's libraries, so a policy the server accepts still works after a rollback. Evaluating a request always uses the CEL libraries linked into this kat build (currently 1.37); that set cannot be narrowed. Structural validation uses those same linked rules.
kat -v -run "prod-.*-deny" .

kat exits 0 when all tests pass and non-zero when any test fails, so it can be used directly as a CI gate.

Project Structure & Discovery

kat is designed to fit naturally into existing Kubernetes repositories, including those using Kustomize.

The tool works by discovery:

  1. It looks for tests/ directories containing test files.
  2. It looks for policy and binding files in the parent directory of tests/.

Supported filenames include:

  • policy.yaml / policies.yaml
  • binding.yaml / bindings.yaml
  • Any file ending in .policy.yaml or .binding.yaml

Note: You can define multiple policies and bindings in a single file (separated by ---), or split them across multiple files. The tool loads all valid policy/binding resources found in the directory.

policies/
├── team-label-policy/
│   ├── kustomization.yaml  # (Optional) Kustomize file
│   ├── policy.yaml         # The AdmissionPolicy definition
│   ├── binding.yaml        # The AdmissionPolicyBinding
│   └── tests/              # Add this folder for kat
│       ├── team-label.has-label.allow.object.yaml
│       ├── team-label.missing.deny.object.yaml
│       └── ...

Running kat . at the root will automatically find the tests directory, associate it with the policy in the parent directory, and execute the tests.

Writing Tests

File Naming Convention

Pattern: <policy-name>.<test-name>.<expect>.<type>.yaml

The <policy-name> prefix must match the metadata.name of the policy being tested. If a directory contains only a single policy, kat automatically associates all tests with that policy.

PartValuesDescription
expectallow, deny, warn, auditExpected admission outcome (validating policies; omit for mutating — see Mutating Policies)
typeobject, oldObject, request, params, namespaceObject, authorizer, annotations, warningsWhat the file contains

Multiple files with the same <policy-name>.<test-name>.<expect> prefix are merged into a single test case.

Two Ways to Write Tests

You can provide test inputs as a single .request.yaml file or as separate files per field — or a mix of both.

All-in-One: .request.yaml

A .request.yaml file can contain the object, params, namespace context, and user info all in one place. This is the simplest way to write tests that need more than just an object.

# my-policy.dev-deploy.allow.request.yaml
operation: CREATE
namespace: development
object:
  apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: dev-deployment
    namespace: development
  spec:
    replicas: 1
    selector:
      matchLabels:
        app: test
    template:
      metadata:
        labels:
          app: test
      spec:
        containers:
        - name: nginx
          image: nginx
namespaceObject:
  apiVersion: v1
  kind: Namespace
  metadata:
    name: development
    labels:
      environment: dev
params:
  apiVersion: v1
  kind: ConfigMap
  metadata:
    name: policy-config
  data:
    maxReplicas: "10"
userInfo:
  username: "developer@example.com"

Available fields in .request.yaml:

FieldDescription
operationCREATE, UPDATE, DELETE, or CONNECT
objectThe object being admitted
oldObjectPrevious version (for UPDATE/DELETE)
paramsParameter resource (paramKind/paramRef)
namespaceObjectNamespace context with labels/annotations
userInfoUser making the request
namespaceShorthand for request namespace
nameShorthand for request name
resourceRequest resource {group, version, resource}, derived from the object's kind. Set it for CONNECT ({version: v1, resource: pods} + subResource: exec) or a CRD with an irregular plural
subResourceSub-resource being accessed (e.g., status)
optionsAdditional options for the request
dryRunrequest.dryRun seen by the policy (default false)

Split Files

For simpler tests, you can use separate files — each containing just one piece:

my-policy.deploy-test.allow.object.yaml      # The object being admitted
my-policy.deploy-test.allow.params.yaml      # Policy parameters
my-policy.deploy-test.allow.request.yaml     # Additional context (userInfo, namespace, etc.)

This keeps individual files small and readable. All files sharing the same base name (my-policy.deploy-test.allow) are merged into one test case.

Conflict detection: If the same field (e.g., object) is defined in both .request.yaml and a separate .object.yaml, kat reports an error.

Expected Outcomes

Allow — the object is admitted:

# my-policy.good-pod.allow.object.yaml

Deny — the request is rejected. Add a .message.txt to verify the exact error:

# my-policy.bad-pod.deny.message.txt
All workloads must have an 'owner' label

Warn — admitted with warnings. Add a .warnings.txt:

# my-policy.old-api.warn.warnings.txt
Deprecated API version, migrate to apps/v1

Audit — admitted with audit annotations. Add an .annotations.yaml:

# my-policy.flagged.audit.annotations.yaml
audit-annotation-key: "violation detected"

A failed validation under a binding with the Audit action is recorded, as by the API server, in the validation.policy.admission.k8s.io/validation_failure annotation:

validation.policy.admission.k8s.io/validation_failure: '[{"message":"configmaps must have an app label","policy":"require-app-label","binding":"require-app-label-report","expressionIndex":0,"validationActions":["Warn","Audit"]}]'

An empty .warnings.txt asserts the request gets no warnings, and an .annotations.yaml containing {} asserts it gets no audit annotations.

Mutating Policies

For mutating policies, provide a golden file (.gold.yaml) with the expected output:

my-policy.add-labels.object.yaml    # Input object
my-policy.add-labels.gold.yaml      # Expected object after mutation

Mutating policies always admit the request (they mutate, never deny), so omit the <expect> token — name the case after what it mutates (here, add-labels) rather than allow/deny. The assertion is the .gold.yaml diff, not the admission outcome.

If the actual mutation result differs from the golden file, the test fails with a diff. This also works with all-in-one .request.yaml files — just place a .gold.yaml alongside it.

Authorizer Mocking

Mock Kubernetes Authorizer responses (SubjectAccessReview) for policies using authorizer in CEL:

# my-policy.check-perms.deny.authorizer.yaml
- group: ""
  resource: "pods"
  namespace: "default"
  verb: "create"
  decision: "allow"

Any check not explicitly mocked returns "NoOpinion".

Operations

  • CREATE (default): Provide object (via .object.yaml or in .request.yaml).
  • UPDATE: Provide both object and oldObject. Operation is inferred automatically.
  • DELETE: Provide only oldObject. Operation is inferred automatically.
  • CONNECT: Set operation: CONNECT in .request.yaml.

Operation inference works the same way whether fields are in separate files or consolidated in .request.yaml. If you set operation: explicitly and it conflicts with what would be inferred from the fields present, kat reports an error.

Write, Run, Validate

Authoring a test is a short loop:

  1. Write — copy an object that matches the policy's matchConstraints, name the file <policy-name>.<test-name>.<expect>.<type>.yaml, and add any companion file (.message.txt, .warnings.txt, .annotations.yaml, .gold.yaml).
  2. Run — kat -v <policy-dir> (or kat -run "<test-name>" <policy-dir> for one case).
  3. Validate — confirm the run exits 0. A failure prints a diff showing exactly what to fix.

Gotchas & Constraints

  • kat takes directories, not single files — use -run to target one case.
  • Only the deny token flips the expectation; warn/audit still expect the request to be allowed and rely on their companion file for the real assertion.
  • Make sure the object actually matches the policy's matchConstraints (apiGroup/version/resource/operation) — otherwise the policy never fires. kat applies the policy's matchConstraints and the binding's matchResources the way the API server does: resourceRules (each rule on its own), excludeResourceRules, resourceNames, scope, objectSelector and namespaceSelector. A request that doesn't match is allowed without running any CEL.
  • matchPolicy: Equivalent is evaluated as Exact (there's no API discovery offline). A namespaceSelector only filters when the test provides a namespaceObject.
  • A mutating policy that mutates the object requires a .gold.yaml.
  • Defining the same field in both .request.yaml and a split file is an error.
  • Assertions are exact: deny message equals .message.txt (trimmed), warnings match by line/index, audit annotations match the listed keys exactly.
  • Only admissionregistration.k8s.io/v1 is supported; v1beta1 is a hard error.
  • As in the API server, matchConditions cannot reference variables, and namespaceObject is null inside them. A condition that fails to evaluate denies the request under failurePolicy: Fail (the default) and skips the policy under Ignore.
  • A policy with no binding file is evaluated as if it were bound once: Deny and no params for a validating policy, and an empty binding for a mutating policy. A cluster ignores a policy that has no binding.
  • A paramRef that uses selector instead of name is not supported offline, and the case fails instead of guessing which objects match. When the params object has a name, it must be paramRef.name, and when it has a namespace, it must be where the server looks (paramRef.namespace, else the request's). failurePolicy: Ignore does not hide these errors. kat does not know whether a paramKind is namespaced, so it does not report the server's errors for a namespaced paramRef on a cluster-scoped request, or for paramRef.namespace with a cluster-scoped paramKind.
  • When a binding's paramRef finds no params, parameterNotFoundAction: Deny denies the request before any expression runs, and Allow skips the policy.
  • CEL list and map literals must be homogeneous, as in the API server. Build mixed-type values with typed literals such as Object.spec.containers{name: "a", ports: [...]}.
  • Policies and bindings are defaulted and validated as the API server does when they are created: a MutatingAdmissionPolicy without reinvocationPolicy, an unknown reason, or a CEL expression that doesn't compile fails every test of the policy with "the API server would reject the policy: …" and the server's error.
  • Creation-time CEL is the API server's NewExpressions environment for -k8s-version (compatibility version one minor older). Admission evaluates with StoredExpressions from the linked libraries, which includes every feature that library knows and cannot be downgraded to an older apiserver. Libraries the server doesn't offer (such as math. and base64. from cel-go) are not available. Structural validation is the linked version's rules.
  • A policy with reinvocationPolicy: IfNeeded that changes the object is run once more, as the API server does, so a non-idempotent mutation is applied twice.
  • Several bindings of one policy are all evaluated; the first denial wins.
  • A JSON patch whose result is not a valid object of a built-in kind (an unknown field, a wrong type) denies the request with the API server's Internal error occurred: …, regardless of failurePolicy. Custom resources are not checked, as on the server.
  • An object or oldObject of a built-in kind must be valid as written. The API server drops unknown fields before admission runs and rejects a field of the wrong type, so kat fails such a case with an input error instead of evaluating something no cluster would see.
  • Objects are not defaulted after mutation (for example a Pod's imagePullPolicy), so a .gold.yaml contains only what the policy itself set.
  • ApplyConfiguration merges use the built-in OpenAPI schema embedded in kat. A kind that is not in that schema, including a custom resource, is merged schemaless: every list is atomic. That matches a CRD without a structural schema. A CRD that has a structural schema is merged by its list-map keys on a real apiserver, and kat does not.

Conformance with the API server

The conformance/ module checks kat against a real kube-apiserver (started by envtest) for every case in test-policies-pass/ and test-policies-fail/, on every supported Kubernetes version. Run it with ./hack/conformance.sh; see AGENTS.md for details.

For AI agents

This repo ships machine-readable guidance so coding agents can author tests reliably. The links below are absolute so an agent can fetch them directly, even from another repo:

  • AGENTS.md — repo orientation (build/test/lint commands, discovery, conventions). Raw: https://raw.githubusercontent.com/zemanlx/kat/main/AGENTS.md
  • skills/write-kat-tests/SKILL.md — a portable skill with the authoritative filename grammar and copy-ready templates for every test archetype. Raw: https://raw.githubusercontent.com/zemanlx/kat/main/skills/write-kat-tests/SKILL.md

Using kat's skill in your own repo

If your repository uses kat to test admission policies, point your own coding agent at this skill so it writes correct tests without you cloning anything. Add a short note to your repo's AGENTS.md (or the equivalent agent instructions file):

## Testing admission policies

This repo uses [kat](https://github.com/zemanlx/kat) to test Kubernetes admission
policies. When creating or editing `kat` test cases, follow the authoritative skill:
https://raw.githubusercontent.com/zemanlx/kat/main/skills/write-kat-tests/SKILL.md
(filename grammar and templates under
https://github.com/zemanlx/kat/tree/main/skills/write-kat-tests/reference).
Always finish by running `kat <dir>` and confirming exit code 0.

Agents that support project skills can instead vendor the skill locally by copying skills/write-kat-tests/ into their skills directory (e.g. .claude/skills/, .github/skills/, or .agents/skills/).

Features

  • Standard Kubernetes YAML — no new DSL to learn
  • Full CEL Support — uses official Kubernetes CEL libraries for 100% accurate evaluation
  • Comprehensive Policy Support — ValidatingAdmissionPolicy and MutatingAdmissionPolicy
  • All Operations — CREATE, UPDATE, DELETE, CONNECT
  • Golden File Testing — verifies mutated objects against expected output
  • Rich Context — userInfo, namespaceObject, matchConditions, authorizer mocking
  • Parameter Testing — paramKind/paramRef with ConfigMaps or custom resources

Examples

Check the test-policies-pass directory for a comprehensive set of examples covering:

  • Basic validation and mutation
  • Parameters and ConfigMaps
  • Namespace-based logic
  • CONNECT operations (kubectl exec)
  • Match conditions
  • Warnings and Audit annotations
↑↓ navigate openesc close