Policy packs¶
Policy as code lets you write your own rules. A policy pack is a rule set kubeagent ships pre-written, compiled into the binary, and evaluated by the exact same engine — so you get a working set of checks with no file of your own to author.
kubeagent policy packs # list what ships
kubeagent policy packs --print reliability # print one, to read or fork
kubeagent scan --policy-pack reliability # evaluate it against a cluster
kubeagent scan --policy-pack security # or another one, or all three
# Nothing in a pack is critical, so a pack cannot fail a gate by default.
# This is the explicit act that makes it block:
kubeagent gate --policy-pack security --fail-on warning
$ kubeagent policy packs
cost 16 rules — resource requests and limits, retention and history limits, autoscaler ceilings and claim sizes
reliability 14 rules — probes, resource requests and limits, replica counts, disruption budgets and image tags
security 23 rules — privileged containers, host namespaces and paths, root filesystems, capabilities and service account tokens
Print one to fork it:
kubeagent policy packs --print <name>
--print writes the pack's YAML to stdout, unmodified:
$ kubeagent policy packs --print reliability
# kubeagent reliability pack.
#
# Every rule here catches, before a workload goes live, a failure kubeagent's
# detectors diagnose after it does: a container with no readiness probe, no
# memory limit, a floating tag, a single replica with no disruption budget.
#
# No rule is `critical`, deliberately. `gate` fails on critical by default, so
# adding this pack to a pipeline must not fail a build that passed yesterday.
# An operator who wants these to block raises --fail-on warning.
#
# Rule ids are namespaced with the pack name so they cannot collide with an
# operator's own rules when both are given.
- id: reliability.deploy-readiness-probe
match:
kind: Deployment
assert:
path: spec.template.spec.containers[*].readinessProbe
op: exists
level: warning
message: a container has no readiness probe, so its Service sends traffic before it can serve
# … the remaining thirteen rules follow the same shape. The complete list is
# in the table below, or run the command yourself for the full YAML.
--policy-pack <name> on scan or gate evaluates a pack against the
current cluster. It is repeatable, and combinable with --policy.
An unknown name is refused, naming what does exist:
$ kubeagent policy packs --print nope
kubeagent: unknown policy pack "nope" (want cost, reliability, security)
Guarantees¶
policy packs itself — the list and --print — contacts nothing at all: no
cluster, no kubeconfig, no network. It reads only what is compiled into the
binary.
Evaluating a pack (--policy-pack on scan or gate) is read-only toward
the cluster: a rule can only read a field, and there is no --fix path from
a rule, exactly as for a --policy file. Separately, a pack makes no LLM
call. Those are two separate promises — read-only describes what evaluation
does to the cluster, no-model-call describes what it does with the result —
and neither implies the other. --explain is the model path; a pack is not a
smaller version of it.
Opt-in, and what that buys¶
--policy-pack is opt-in, exactly like --policy: leave it off, and scan
renders exactly the bytes it rendered before this slice shipped. No policy
key appears in --output json, and no schemaVersion moves — scan stays at
schema version 1.2, gate at 1.1. Shipping a pack inside the
binary changes nothing about what an existing command line does; the rules
only run once --policy-pack names them.
When it is used, a pack joins the same evaluation --policy already drives:
pack rules and file rules populate one rule list, and their violations render
in the same POLICY section and the same JSON policy key. There is no
separate, pack-shaped output to learn.
No rule is critical¶
gate fails a build on a critical finding by default (--fail-on
critical). No rule in any shipped pack is critical — each pack's own
header comment says this is deliberate — so turning on --policy-pack in a
pipeline that passed yesterday cannot make it fail today. A test over the
whole registry keeps it that way, so it is a property of the pack format
rather than of the packs that happen to ship.
Read that as "opt-in to blocking", not as "not meant to block." Raising
--fail-on is the explicit, separate act:
Every explicitly-bad-value rule in the security pack is a warning, so that
one flag makes the pack block a build. Every "field is unset" rule is info
and stays advisory even then.
The reliability pack — fourteen rules¶
Paths are shortened below; every containers[*] is
spec.template.spec.containers[*].
| id | kind | assertion | level |
|---|---|---|---|
reliability.deploy-readiness-probe |
Deployment | containers[*].readinessProbe exists |
warning |
reliability.deploy-liveness-probe |
Deployment | containers[*].livenessProbe exists |
info |
reliability.statefulset-readiness-probe |
StatefulSet | containers[*].readinessProbe exists |
warning |
reliability.daemonset-readiness-probe |
DaemonSet | containers[*].readinessProbe exists |
info |
reliability.deploy-memory-limit |
Deployment | containers[*].resources.limits.memory exists |
warning |
reliability.statefulset-memory-limit |
StatefulSet | containers[*].resources.limits.memory exists |
warning |
reliability.deploy-cpu-request |
Deployment | containers[*].resources.requests.cpu exists |
info |
reliability.deploy-memory-request |
Deployment | containers[*].resources.requests.memory exists |
info |
reliability.deploy-image-not-latest |
Deployment | containers[*].image notMatches *:latest |
warning |
reliability.deploy-image-tagged |
Deployment | containers[*].image matches *:* |
info |
reliability.deploy-replicas-min-two |
Deployment | spec.replicas gte 2 |
warning |
reliability.deploy-pdb |
Deployment | relation hasPodDisruptionBudget |
warning |
reliability.cronjob-concurrency-policy |
CronJob | spec.concurrencyPolicy in Forbid, Replace |
info |
reliability.pvc-storage-class |
PersistentVolumeClaim | spec.storageClassName exists |
info |
The security pack — twenty-three rules¶
Paths are shortened below: T is spec.template.spec. The one CronJob rule
spells its path in full, because its pod template lives one level deeper.
| id | kind | assertion | level |
|---|---|---|---|
security.deploy-privileged |
Deployment | T.containers[*].securityContext.privileged notIn true |
warning |
security.deploy-privilege-escalation-unset |
Deployment | T.containers[*].securityContext.allowPrivilegeEscalation exists |
info |
security.deploy-privilege-escalation |
Deployment | T.containers[*].securityContext.allowPrivilegeEscalation notIn true |
warning |
security.deploy-run-as-non-root-unset |
Deployment | T.containers[*].securityContext.runAsNonRoot exists |
info |
security.deploy-run-as-non-root |
Deployment | T.containers[*].securityContext.runAsNonRoot notIn false |
warning |
security.deploy-run-as-root-uid |
Deployment | T.containers[*].securityContext.runAsUser gt 0 |
warning |
security.deploy-read-only-root-unset |
Deployment | T.containers[*].securityContext.readOnlyRootFilesystem exists |
info |
security.deploy-read-only-root |
Deployment | T.containers[*].securityContext.readOnlyRootFilesystem notIn false |
warning |
security.deploy-added-capabilities |
Deployment | T.containers[*].securityContext.capabilities.add[*] notIn seven host-level capabilities |
warning |
security.deploy-host-path-volume |
Deployment | T.volumes[*].hostPath notExists |
warning |
security.deploy-host-port |
Deployment | T.containers[*].ports[*].hostPort notExists |
warning |
security.deploy-host-network |
Deployment | T.hostNetwork notIn true |
warning |
security.deploy-host-pid |
Deployment | T.hostPID notIn true |
warning |
security.deploy-host-ipc |
Deployment | T.hostIPC notIn true |
warning |
security.deploy-seccomp-unset |
Deployment | T.securityContext.seccompProfile.type exists |
info |
security.deploy-seccomp-unconfined |
Deployment | T.securityContext.seccompProfile.type notIn Unconfined |
warning |
security.deploy-service-account-unset |
Deployment | T.serviceAccountName exists |
info |
security.deploy-automount-token-unset |
Deployment | T.automountServiceAccountToken exists |
info |
security.statefulset-privileged |
StatefulSet | T.containers[*].securityContext.privileged notIn true |
warning |
security.statefulset-host-path-volume |
StatefulSet | T.volumes[*].hostPath notExists |
warning |
security.daemonset-privileged |
DaemonSet | T.containers[*].securityContext.privileged notIn true |
warning |
security.daemonset-host-path-volume |
DaemonSet | T.volumes[*].hostPath notExists |
warning |
security.cronjob-privileged |
CronJob | spec.jobTemplate.spec.template.spec.containers[*].securityContext.privileged notIn true |
warning |
Why four properties get two rules each¶
exists catches a field nobody set. A value operator catches a field someone
set to the wrong thing. Neither catches both, because every operator except
exists and notExists skips an absent field — so a single notIn rule is
silent about a workload that never set the field at all.
The pack pairs them only where an absent field is itself unsafe:
| property | absent means | rules |
|---|---|---|
allowPrivilegeEscalation |
the Kubernetes default is true — unsafe |
paired |
runAsNonRoot |
nothing stops the container running as root — unsafe | paired |
readOnlyRootFilesystem |
a writable root filesystem — unsafe | paired |
seccompProfile.type |
unconfined — unsafe | paired |
privileged, hostNetwork, hostPID, hostIPC, capabilities.add |
the safe value — safe | one value rule |
hostPath, hostPort |
the safe case | one notExists rule |
Where absence is the safe default, a second rule would report a compliant
workload, so only the value rule ships. The -unset half of each pair is
info and the value half is warning, which is why raising --fail-on
warning blocks on the explicit misconfiguration without also blocking on
every unset optional field.
What the security pack cannot say¶
Five real gaps. They are written down rather than worked around, because each comes from a property the rule grammar does not have — and adding one would be an engine change, not a pack change.
- A bare
Podthat no controller owns is not checked. Every rule selects a workload kind.kubeagent scan's own detectors still see that pod; the pack does not. A controller-owned pod would otherwise repeat its workload's violation once per replica. - Hardening set at one level does not satisfy a rule written for the
other. The grammar has no OR, so each path names exactly one level and
cannot also accept the other. Both directions are real. A Deployment that
sets
runAsNonRootonce inspec.template.spec.securityContextstill reportssecurity.deploy-run-as-non-root-unsetfor each container. And a Deployment whose containers each set their ownseccompProfilestill reportssecurity.deploy-seccomp-unset, because that pair reads the pod-level field — Kubernetes accepts a seccomp profile at either level, so that workload is hardened and the rule still fires. This is the pack's most likely source of false positives. If your workloads harden at the other level, fork the pack and move those paths. capabilities.dropcannot be required to includeALL. That needs an existential quantifier — "some element equals ALL" — and[*]is universally quantified with no existential counterpart. The pack checks what was added instead, against a fixed list of seven host-level capabilities.- RBAC bindings, service account objects and Secrets are unreachable.
None is a kind a policy may select. A workload's reference to a service
account is reachable, and
security.deploy-service-account-unsetis that rule; the object it names is not.security.deploy-automount-token-unsetis bounded the same way: it reads the workload's own field, and cannot see that the service account behind it may already have opted out.Secretis absent deliberately — a violation carries evidence, and evidence drawn from a Secret would be secret material rendered into a report, a JSON document and a SARIF upload. - The added-capability list is curated, not exhaustive. A capability outside the seven passes. Fork the pack to extend it.
A registry allowlist is also not a rule kubeagent can curate: it does not know
which registry is yours, and a shipped rule naming one would be wrong for
everyone else. --print and forking are the answer.
The cost pack — sixteen rules¶
The cost pack is about a workload's claim on the cluster — what it
reserves, what it may grow to, and what it leaves behind.
Paths are shortened below: T is spec.template.spec. The two CronJob
rules that read a container spell their path in full, because the pod
template lives one level deeper; the three CronJob rules that read the
CronJob's own fields need no shortening.
| id | kind | assertion | level |
|---|---|---|---|
cost.deploy-ephemeral-storage-limit |
Deployment | T.containers[*].resources.limits.ephemeral-storage exists |
info |
cost.deploy-large-cpu-request |
Deployment | T.containers[*].resources.requests.cpu lte 8 |
info |
cost.deploy-large-memory-request |
Deployment | T.containers[*].resources.requests.memory lte 32Gi |
info |
cost.statefulset-cpu-request |
StatefulSet | T.containers[*].resources.requests.cpu exists |
info |
cost.statefulset-memory-request |
StatefulSet | T.containers[*].resources.requests.memory exists |
info |
cost.daemonset-cpu-request |
DaemonSet | T.containers[*].resources.requests.cpu exists |
info |
cost.daemonset-memory-request |
DaemonSet | T.containers[*].resources.requests.memory exists |
info |
cost.daemonset-ephemeral-storage-limit |
DaemonSet | T.containers[*].resources.limits.ephemeral-storage exists |
info |
cost.cronjob-cpu-request |
CronJob | spec.jobTemplate.spec.template.spec.containers[*].resources.requests.cpu exists |
info |
cost.cronjob-memory-request |
CronJob | spec.jobTemplate.spec.template.spec.containers[*].resources.requests.memory exists |
info |
cost.cronjob-successful-history |
CronJob | spec.successfulJobsHistoryLimit lte 10 |
info |
cost.cronjob-failed-history |
CronJob | spec.failedJobsHistoryLimit lte 10 |
info |
cost.cronjob-active-deadline |
CronJob | spec.jobTemplate.spec.activeDeadlineSeconds exists |
info |
cost.job-backoff-limit |
Job | spec.backoffLimit lte 10 |
info |
cost.hpa-max-replicas |
HorizontalPodAutoscaler | spec.maxReplicas lte 50 |
info |
cost.pvc-large-storage |
PersistentVolumeClaim | spec.resources.requests.storage lte 1Ti |
info |
Why every cost rule is info¶
reliability and security each carry warning rules; cost does not —
all sixteen are info. The difference is not caution, it is scope:
security's "privileged is bad" holds in every cluster, but cost's "eight
CPUs is too many" does not — a request that is generous in one cluster is
unremarkable in another, so a cost finding is budget-dependent in a way a
security finding is not. A pack that cannot know a cluster's budget must not
accuse it of overspending, only ask for confirmation. The consequence is
mechanical: cost cannot fail a gate at any --fail-on above info — not
even --fail-on warning, which is enough to make security's value rules
block.
What the cost pack cannot say¶
Three real gaps. They are written down rather than worked around, for the same reason the security pack's five are: each comes from a property the rule grammar does not have, and adding one would be an engine change, not a pack change.
- A limit set with no matching request is unsaid. Kubernetes defaults an unset request to the limit, so a container that sets a memory limit and no memory request reserves the ceiling rather than the expected use — probably the largest single cost defect in a typical cluster. The grammar has no cross-field relation, so no rule here can compare a container's own request to its own limit.
- A Deployment with no CPU or memory request is unsaid when
costruns alone. Thatexistsquestion is alreadyreliability.deploy-cpu-requestandreliability.deploy-memory-request;costasks it only for StatefulSet, DaemonSet and CronJob, the three kindsreliabilitydoes not cover it for, soreliabilityandcostnever report the same gap under two ids. Run both and the question is covered. - Absence across a whole namespace, and a comparison between two fields on
the same object, are both unexpressed. A rule asserts over objects that
exist, so "this namespace has no
ResourceQuota" cannot be a rule — there is no object to fail against. The same grammar has no way to compare one field to another on the same object, so "minReplicasequalsmaxReplicas" cannot be a rule either.
kubeagent has no prices¶
kubeagent has no prices. There is no billing data, no instance types, no node cost and no cloud API anywhere in the binary. The pack names shapes that usually cost money — an oversized request, an unbounded autoscaler ceiling, a retention window nobody trimmed — and claims nothing beyond that: it cannot tell you what anything costs, in any currency, and no rule here says it can. This is a third, separate claim from the two in Guarantees, above: it says nothing about whether a rule can write to the cluster or whether evaluating a pack calls a model.
Two semantics a rule author must know¶
These follow directly from how the general policy evaluator works, and every rule in every shipped pack is written with them in mind.
[*] produces one slot per element, and every slot must satisfy the
assertion. reliability.deploy-memory-limit asserts
spec.template.spec.containers[*].resources.limits.memory exists. A
Deployment with several containers where only one lacks a memory limit still
violates the rule: one object yields at most one violation per rule, from the
first slot that fails, but it takes only one failing slot to produce it.
Setting the limit on every container but one is not "mostly compliant."
exists violates on an absent field; notExists is satisfied by one; every
other operator skips it.
reliability.deploy-image-tagged asserts containers[*].image matches
*:* — looking for a colon, which is where a tag or digest would be. It
catches a bare image: nginx. It does not catch
image: registry.example.com:5000/app — a private registry host with a port
and no tag at all — because the host:port colon alone satisfies the glob. That
is a documented, accepted limitation of a glob-based check: it recognizes the
shape of a tag, not the field, since the API does not expose "image has a tag"
as a boolean of its own. Pair it with reliability.deploy-image-not-latest
(notMatches *:latest) for the case it does catch, and do not rely on
either rule alone to prove every image is pinned.
RBAC¶
A pack needs no grant beyond what a plain kubeagent scan — and kubeagent
rbac print — already report. The kinds the shipped rules
select (Deployment, StatefulSet, DaemonSet, CronJob, Job,
HorizontalPodAutoscaler, PersistentVolumeClaim) are all inside the policy
engine's selectable kinds, which are pinned to the same core rules
rbacprofile already grants. Turning on --policy-pack asks for no
permission a plain scan did not already have.
It does add request volume, though: evaluating any policy — a pack included —
builds its own dynamic client and lists every kind the loaded rules touch,
independently of whatever scan's typed collectors already read. For
reliability that is six List calls, one each for the five kinds its rules
select plus PodDisruptionBudget for the one relation rule. For security it
is four, one per workload kind, since it has no relation rule. For cost it
is seven, one per kind its rules select, since it has no relation rule
either. That extra, uncached read is how --policy has always evaluated a
rule set; a pack does not change it. See Least-privilege RBAC.
Forking a pack¶
Print a pack to a file, edit it, and run it as an ordinary --policy file:
kubeagent policy packs --print reliability > mine.yaml
# edit mine.yaml — remove rules, change a level, tighten a value
kubeagent scan --policy mine.yaml
--policy-pack and --policy may both be given, and pack rules load first —
so a duplicate id is reported against the pack as the earlier definition,
reading as "your file reuses a pack's id" rather than the reverse. A freshly
forked file keeps every id from the original pack until you change them, so
running the fork alongside the pack it came from collides:
$ kubeagent scan --policy-pack reliability --policy mine.yaml
kubeagent: mine.yaml: rule id "reliability.deploy-readiness-probe" is already defined in pack:reliability
Change the ids in the fork — or drop --policy-pack reliability once you are
running the fork instead of the original — before combining the two.
Contributing a pack¶
A pack is a subject, not a patch. Open an issue first and agree the subject belongs in kubeagent before writing any YAML — review will ask why this set of rules, and that is easier to answer before the rules exist.
Then:
# 1. write the pack
$EDITOR internal/policypack/packs/<name>.yaml
# 2. add its entry to the registry slice in policypack.go, keeping it sorted
$EDITOR internal/policypack/policypack.go
# 3. the gate
go test ./internal/policypack
Open a pull request with a CHANGELOG.md entry under ## [Unreleased], the
same as any other change.
CONTRIBUTING.md
carries the sign-off and commit-message conventions.
What the tests check¶
These assertions run over every registered pack, so every failure is predictable before you push:
| check | refuses |
|---|---|
| the pack loads | a registry entry naming a file that is not embedded — a typo or a rename, the mirror image of the orphan below — anything the policy loader rejects, such as an unknown key, a malformed rule id, a kind that is not selectable, an unknown level or an empty message, and a pack that loaded but holds no rules |
| ids carry the pack prefix | a rule id not beginning <pack>., which is what keeps --policy-pack and --policy from colliding when both are given |
| no rule is critical | a critical rule, which would fail a gate at its default --fail-on critical the day the pack was added |
| no host or address | :// or a bare IPv4 address anywhere in the YAML, and any dot in a rule message |
| every embedded file is registered | a packs/*.yaml with no registry entry — it would ship inside the binary while being invisible to the listing, to --policy-pack and to every other test — or two registry entries naming the same file, which would ship one pack's rules under two names |
| names are unique and usable | a duplicate name, anything outside lowercase letters, digits and interior hyphens, or a name too long for the listing column |
| summary shape | an empty summary, a multi-line summary, leading or trailing whitespace, a trailing period, or a leading capital |
The last three are about the registry rather than the rules, and they exist because nothing else can see it: the loader is handed bytes and never learns where they came from, and every other test iterates the registered packs — so anything missing from the registry is invisible to all of them.
What no test can check¶
These are the review, and they are why acceptance is not automatic:
- Is every rule true of the kind it selects? A path that does not exist on
that kind makes every operator except
existsandnotExistsskip the slot — the rule runs, reports nothing, and looks like a pass. Check each path against the API type, not against memory. - Does the subject belong?
reliability,securityandcostare three questions an operator already asks of a workload. A pack encoding one organisation's house style is a fork, not a pack — see Forking a pack. - Is every message a single clause with no dot? The dot ban is mechanical; a message that still reads well under it is not.
- Is every level right? Nothing is
critical. Beyond that, a rule firing on an explicitly wrong value is usuallywarning, and one firing on an unset field is usuallyinfo— each shipped pack explains its own choice in its header comment. - Does the pack say what it cannot say? All three shipped packs carry a section naming their own gaps. Claiming only what you deliver is the house style here, not a nicety.
Acceptance is curatorial¶
Passing the tests is necessary, not sufficient. A maintainer still reads every rule, and a pack that ships is kubeagent's curation whoever wrote it — kubeagent's name is on every rule an operator runs by name. If kubeagent would not vouch for a pack, kubeagent does not merge it.
Attribution goes in the pack's own header comment, which
kubeagent policy packs --print <name> emits verbatim. There is no author
field in the listing, and a contributed pack is not marked as one: a two-tier
listing would tell an operator to trust some shipped rules less than others,
which is the opposite of what accepting a pack means.
Two limits worth knowing before you start¶
A contributed pack ships on a kubeagent release. The registry is compiled
into the binary, the same as known-issues; there is no way to add a pack to
an installed kubeagent. If you need rules today rather than next release, fork
one instead — Forking a pack needs no release and no pull
request.
Nobody has walked this path yet. reliability, security and cost are
all kubeagent's own curation; a pack authored outside the project does not
exist yet. The route above is written and enforced, but it has not been used.
Not in this slice¶
Deliberately absent:
- Operator-contributed packs at run time. The registry is curated and
compiled into the binary, the same as
known-issues; there is no way to add a pack without a kubeagent release. - A pack on by default.
--policy-packis opt-in on every command that accepts it; nothing runs unless it is named. - Any change to the evaluator.
internal/policyis unchanged — a pack is YAML data read by the sameLoad/Evaluatea--policyfile already used.