The authoritative list of fields is api-reference.md,
which is generated from pkg/apis/policy/v1beta1/types.go - the stored version -
and therefore cannot describe a field that does not exist. This document is the prose companion: what
the fields do, which combinations contradict each other, and which are accepted
by the API server and acted on by nothing.
One thing to internalise before reading any example, here or elsewhere. The
Kubernetes API server does not reject an unknown field in a custom resource; it
prunes it. A policy written against the wrong field names applies cleanly,
reports no error, shows a green status, and enforces a fraction of what it says.
The blocks are learningConfig and enforcementConfig, not learning and
enforcement. Run pahlevan policy explain -f policy.yaml before applying
anything: it unmarshals strictly, so a field the CRD does not have is an error
there rather than silence in the cluster.
The shape of a policy
apiVersion: policy.pahlevan.io/v1alpha1
kind: PahlevanPolicy
metadata:
name: web-frontend
namespace: default
spec:
selector:
matchLabels:
app: web
matchExpressions:
- key: tier
operator: In
values: ["frontend"]
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: default
learningConfig:
duration: "10m"
minSamples: 100
autoTransition: true
enforcementConfig:
mode: Blocking
gracePeriod: "2m"
alertOnly: false
blockUnknown: true
exceptions:
- type: File
patterns: ["/var/run/secrets/kubernetes.io/serviceaccount/token"]
reason: "rotated after the learning window closes"
filePolicy:
deniedPaths: ["/etc/shadow"]
readOnlyPaths: ["/etc/nginx/nginx.conf"]
syscallPolicy:
deniedSyscalls: ["ptrace", "process_vm_readv"]
networkPolicy:
allowDNS: true
egressRules:
- ports:
- port: 5432
peers:
- ipBlock:
cidr: 10.43.12.7/32
selfHealing:
enabled: true
rollbackThreshold: 5
rollbackWindow: "5m"
Working policies for several shapes of workload live in
examples/policies/: a web application, a database, a
CI runner, a set of microservices, a process-filter example, and batch jobs,
which need a different pattern because a job exits before a learning window
closes.
Selecting workloads
spec.selector decides which pods a policy governs. It carries matchLabels,
matchExpressions and namespaceSelector, and a pod must satisfy all three.
An empty selector matches everything. That is worth saying out loud, because a policy whose selector was pruned by a typo becomes a policy that governs the entire cluster rather than one that governs nothing.
matchExpressions supports four operators: In, NotIn, Exists and
DoesNotExist. The CRD does not constrain the value: operator is a plain
string, and mode is the only enumerated field in the whole schema. The node
agent treats an operator it does not recognise as "does not match", so a
misspelled exists produces a policy that quietly governs no pods at all. There
is no error to find; the symptom is a workload with no ContainerProfile.
namespaceSelector matches against the namespace's own labels, not the pod's.
Kubernetes stamps every namespace with kubernetes.io/metadata.name, so
selecting a namespace by name works through matchLabels without labelling
anything by hand. It is a distinct type from the pod selector and carries only
matchLabels and matchExpressions, because a namespace selector that itself
carried a namespace selector would be meaningless.
Scoping matters more than it looks. The node agent lists PahlevanPolicy
objects across the whole cluster and applies the first one whose selector matches
the pod, regardless of which namespace the policy lives in. A policy in
default that selects app: web and sets no namespaceSelector will govern an
app: web pod in payments too, and the ordering among several matching
policies is the API server's list order rather than anything you control. If a
policy is meant to apply to one namespace, say so with namespaceSelector.
Namespace scoping fails closed in both directions. A pod in a namespace the
agent has no labels for does not match a scoped policy, and an agent that cannot
list namespaces logs cannot list namespaces; namespaceSelector will not match
and stops matching every scoped policy while continuing to apply the unscoped
ones.
Learning
learningConfig:
duration: "10m"
minSamples: 100
autoTransition: true
duration is the learning window. During it, the agent records, per cgroup,
every file the container opens (with the path resolved in-kernel, and reads kept
separate from writes), every egress destination it dials, every binary it
executes, every capability it exercises and every syscall it makes. That
recording is the baseline; nothing is written by hand.
duration is also the clock the node agent's per-container transition runs on.
A container becomes eligible for enforcement duration + enforcementConfig.gracePeriod
after its learning window opened. A Blocking policy that omits duration
therefore has a zero-length window, and its containers are flipped to enforcing
as soon as the grace period elapses, against whatever baseline happened to exist
by then. Set it deliberately.
minSamples and autoTransition drive the policy's own status phase, which is
a separate thing from the per-container transition above. autoTransition
allows the policy to leave Learning before duration has elapsed, and
minSamples is the floor that makes that safe: the phase advances early only
when at least minSamples samples have been collected and learning progress has
reached 80 percent.
requireReview holds the per-container transition open past
duration + gracePeriod: a container whose window has elapsed stays in
Learning until an operator sets reviewedAt, so the baseline it is about to
enforce gets looked at first rather than trusted on first use. It is checked
once, at the moment the container would otherwise transition, so changing the
baseline afterwards does not reopen the hold. Off by default; every existing
policy keeps transitioning exactly as before.
learningConfig:
duration: "10m"
requireReview: true
# set once the learned baseline has been reviewed:
# reviewedAt: "2026-10-01T12:00:00Z"
Two fields in this block are accepted and do nothing. windowSize is read only
by a sampling-window learner that the agent and the operator never construct, so
it has no effect in any deployment. lifecycleAware is stored and displayed and
nothing acts on it; a restarted container gets a new cgroup id and therefore a
new baseline whether it is set or not. Both are reported by
pahlevan policy explain.
Declaring what the window will not see
Learning is a window of wall-clock time, so anything the workload does once a
day is not in the baseline. A nightly batch, a weekly certificate renewal, a log
rotation, a backup that opens a path nothing else opens: a fifty minute window
never sees them, and under Blocking the kernel refuses them. That refusal is
correct from the kernel's side. The only evidence against the operation is that
the workload has never done it before, which is exactly what an attacker
produces too.
expectedBehavior is how an operator states the difference in advance.
apiVersion: policy.pahlevan.io/v1beta1
kind: PahlevanPolicy
spec:
learningConfig:
duration: "10m"
expectedBehavior:
files:
- path: /var/lib/app/nightly.db
write: true
- path: /etc/ssl/renewed.pem
networkDestinations:
- cidr: 10.43.12.7/32
port: 5432
executables:
- /usr/bin/pg_dump
capabilities:
- DAC_OVERRIDE
Each entry is merged into the kernel allow-set alongside what was learned, at
the moment the container starts enforcing, so the rare operation is permitted
when it finally happens. The alternatives this replaces are guessing a longer
duration and hoping the job runs inside it, or letting self-healing roll
enforcement back after the job has already been denied at 03:00.
The vocabulary is the one filePolicy and networkPolicy already use. A path
is absolute and fully resolved, as in allowedPaths. write: true grants the
write and the read it needs, which is what writeAllowedPaths does; omitting it
grants the read only, which is readOnlyPaths. A cidr is a single host, the
same rule an egressRules peer obeys. A capability is written with or without
the CAP_ prefix, as in capabilityFilter.
Three properties are worth being precise about, because they are what make a declaration safe to write.
It can only add. A declaration is merged into the allow list and never into
a deny list, so it cannot remove something the workload was actually observed
doing, and it cannot be used to quietly disable enforcement. Removing something
is what filePolicy.deniedPaths and the other deny lists are for, where removal
is visible as such.
It is never widened. The allow-set is a hash of the exact operation, so an
entry that cannot be represented exactly is refused at translation with a
warning naming the field, rather than being approximated into something broader.
Refused: a relative path, a wildcard path, a CIDR covering more than one host, a
port outside 1-65535, a capability name the kernel does not have, and protocol: UDP - the allow-set entry a declaration is seeded as carries only the address
and port and is written for TCP, so a UDP declaration would permit TCP to a
destination nobody declared and still deny the UDP that was. One refused entry
does not discard the others, so five good paths and one typo give five seeded
entries and one warning rather than a policy that declares nothing.
Those warnings are reported the way every other translation warning is: the node
agent logs them once per policy generation, naming the field. Note that
pahlevan policy explain cannot show them yet - it decodes policies strictly as
v1alpha1, so it rejects an expectedBehavior block outright rather than
explaining it.
It stays an assertion. A declared entry is reported separately from a learned one, on the governed container's profile:
$ kubectl get containerprofile web-0 -o yaml
status:
learnedFiles:
- /etc/nginx/nginx.conf
declaredFiles:
- /var/lib/app/nightly.db (write)
declaredExecutables:
- /usr/bin/pg_dump
learned* is what the container did. declared* is what somebody said it would
do. An auditor, or anyone asking how a path came to be permitted, needs to be
able to tell the two apart, and once an entry is in the kernel allow-set they
are indistinguishable - the map holds a hash, not a provenance. A declared write
carries a (write) suffix, because declaring a write is a much larger assertion
than declaring a read.
One constraint on where this can be written. expectedBehavior exists in
v1beta1 only; v1alpha1 has no field that means it. The API server prunes
unknown fields rather than rejecting them, so the block written under
policy.pahlevan.io/v1alpha1 applies cleanly, reports no error and declares
nothing - and a client that reads a v1beta1 policy as v1alpha1 and writes it
back erases the declaration from the stored object. Write policies that declare
behaviour against v1beta1, which is the stored version anyway.
Enforcement
enforcementConfig:
mode: Blocking
gracePeriod: "2m"
alertOnly: false
blockUnknown: true
mode is the only field in the CRD with an enum, and the enum is load-bearing.
Without it the API server accepts any string, and the agent maps anything it
does not recognise onto Monitoring, so a typo produces a policy that looks
applied and enforces nothing. The trap that made this an enum: mode: Off
unquoted is a YAML 1.1 boolean, arrives as false, and used to become
Monitoring silently, which is the opposite of switching a policy off. Write
mode: "Off" in quotes, or the API server will tell you.
Offmeans the workload is not governed at all. No learning, noContainerProfile, no enforcement. It is the operator saying "ignore this", and it must not cost a profile.Monitoringlearns and reports but never flips the kernel to denying. This is the safe starting point for a workload you do not fully understand.Blockinglearns, then denies anything outside the baseline in-kernel.
Changing the mode of a live policy is not symmetrical, which matters when you
are undoing something. Off is applied retroactively: on its next reconcile the
agent clears every enforcement bit for the containers that policy governs and
stops tracking them, dropping their in-memory baselines with it. Moving from
Blocking to Monitoring is not retroactive: it stops further containers being
flipped to enforcing and leaves the ones already enforcing exactly as they are,
until their pods restart onto new cgroups or self-healing rolls them back.
gracePeriod is held after the learning window closes and before enforcement
begins, so a workload whose startup behaviour differs from its steady state is
observed in both before anything is refused.
alertOnly and blockUnknown both interact with mode, and both resolve
downwards rather than upwards. alertOnly: true downgrades Blocking to
Monitoring. blockUnknown is a pointer precisely so that unset and false mean
different things: unset means "the default for the mode", which under Blocking
is true, while an explicit blockUnknown: false downgrades Blocking to
Monitoring. Default-deny of unlearned behaviour is the only enforcement the
kernel data plane performs, so a Blocking policy that does not block unknown
behaviour would enforce nothing, and pretending otherwise would be worse than
saying so.
Negative durations are not errors. learningConfig.duration and
enforcementConfig.gracePeriod below zero are treated as zero, with a warning.
Exceptions
enforcementConfig:
exceptions:
- type: File
patterns: ["/var/log/app/current"]
reason: "opened only during the nightly roll"
- type: Network
patterns: ["10.43.12.7:5432"]
- type: Syscall
patterns: ["mount"]
temporary: true
expiresAt: "2026-10-01T00:00:00Z"
An exception widens the allow-set with behaviour that never occurred during the
learning window. type is File, Network or Syscall; anything else is
reported and skipped.
The pattern format differs per type, because each one is keyed differently in
the kernel. File patterns are exact resolved paths and grant both read and
write. Network patterns are host:port with a literal IP address, because a
DNS name has no stable allow-set key. Syscall patterns are syscall names and
reach the generated seccomp profile rather than a BPF map.
temporary with expiresAt is applied until that moment and reported as
expired afterwards, so a temporary decision cannot become a permanent hole.
temporary without expiresAt never expires, and says so in a warning.
What the kernel does with a violation
Under the hood the data plane does not have an on/off switch. Each governed
cgroup carries a packed action in a per-hook mode map: bits 0 to 7 the action,
bits 8 to 15 a signal number, bits 16 to 31 an errno. One 32-bit value, one map
lookup on the hot path, and an absent entry reads as zero, which is Learn. The
kernel half is bpf/enforce.h and the userspace half is pkg/ebpf/action.go;
the two move together.
There are five actions:
| Action | Refuses | Learns | What it does |
|---|---|---|---|
Learn |
no | yes | Observes and widens the allow-set. |
Deny |
yes | no | Refuses with the configured errno, EPERM by default. |
Kill |
yes | no | Refuses and sends SIGKILL to the task that tried. |
Audit |
no | no | Reports what would have been refused, and allows it. |
Signal |
yes | no | Refuses and sends a configured signal. |
Audit is the one worth understanding in detail, because it is the only action
that reports a violation and lets it through. That is what makes it useful for a
rollout: somebody switching a workload they do not fully own needs to know what
would be denied before anything is. Learning mode is not a substitute, because
it widens the allow-set as it goes, so the very operation that would have been
denied is absorbed into the baseline instead of reported. Audit deliberately
does not learn, for the same reason: an audit pass that quietly added everything
it reported would report each violation once and never again. Its events carry a
would-deny flag rather than a denied flag, and it counts into
pahlevan_ebpf_would_deny_total rather than pahlevan_ebpf_denials_total, so a
planned dry run does not fire the alerts an outage fires.
Signal exists because of SIGSTOP. Freezing a process leaves its memory, its
open descriptors and its thread state intact for somebody to examine, where
SIGKILL destroys exactly the evidence an incident responder wants.
The errno is configurable for the same class of reason: a workload that copes
with ENOENT and treats EPERM as fatal is better served by the errno it can
handle. Zero means EPERM. An errno above 4095 is rejected before it is written,
because a value in that range is one the kernel reads as a pointer rather than
as an error, which turns a denial into undefined behaviour.
What a PahlevanPolicy can select today is the Learn and Deny ends of that
range: Monitoring installs Learn, Blocking installs Deny with EPERM,
and Off does not govern the container at all. Kill, Audit and Signal are
implemented in the kernel programs and in pkg/ebpf, and are reachable through
that Go API and through kernel probes, but no field in this CRD selects them yet.
They are documented here because their behaviour is what the counters and the
event flags already describe, and because a reader looking at
pahlevan_ebpf_would_deny_total deserves to know what produces it.
Every hook that can refuse an operation is an LSM hook: lsm/file_open,
lsm/socket_connect, lsm/bprm_check_security and lsm/capable. All four need
the BPF LSM active on the node. Without it the agent still runs and still
observes through the tracepoint, kprobe and uretprobe programs, but no policy on
that node refuses anything. See
troubleshooting for how to tell, and
lsm-support.md for the kernel side.
File policy
filePolicy:
allowedPaths: ["/app/data/current"]
deniedPaths: ["/etc/shadow"]
readOnlyPaths: ["/etc/nginx/nginx.conf"]
writeAllowedPaths: ["/var/log/app/current"]
executableFilter:
allowedExecutables: ["/usr/sbin/nginx"]
deniedExecutables: ["/bin/sh"]
Paths are exact and fully resolved. Enforcement keys on the path the kernel
resolves, which follows symlinks, so /etc/os-release grants nothing on a
distribution where it links to /usr/lib/os-release. Wildcards are not
supported: the allow-set is a hash of the exact path, so * is matched
literally and the rule never fires. A pattern containing *, ? or [ is
reported as such rather than silently doing nothing.
Reads and writes are separate entries in the allow-set, which is what makes the four list fields distinct rather than decorative:
allowedPathsgrants both. An operator naming a path without qualification means the workload may use it.deniedPathsrevokes both, and removes the path from the learned baseline, so a denial cannot be sidestepped by opening the path the other way.readOnlyPathsgrants the read and revokes the write.writeAllowedPathsgrants both.
That split is not theoretical. A web server reads /etc/passwd at startup, so
it lands in the baseline; keying on the path alone would have let anything
inside the container append a root-equivalent account to it afterwards.
executableFilter seeds and revokes entries in the exec allow-set, which is
enforced at bprm_check_security.
Two fields here are inert. defaultAction cannot express a third thing:
default-deny of unlearned behaviour is what Blocking already is, and Allow
is what Monitoring already means. executableFilter.requireSignature has no
effect because nothing verifies executable signatures and the kernel hook has no
way to.
Syscall policy
syscallPolicy:
allowedSyscalls: ["mount"]
deniedSyscalls: ["ptrace", "process_vm_readv"]
capabilityFilter: ["CAP_NET_BIND_SERVICE"]
processFilter:
commands: ["/usr/sbin/nginx"]
users: ["101"]
parentProcesses: ["nginx"]
Syscalls are confined by a generated seccomp profile, not by a BPF map. The
eBPF syscall program observes every syscall and cannot refuse one: seccomp is
installed at exec, so an already-running process cannot be retroactively
filtered. What Pahlevan does instead is emit, from the learned syscall set, a
default-deny profile (SCMP_ACT_ERRNO) that allows the learned syscalls plus a
small safety baseline of exit, exit_group, rt_sigreturn,
restart_syscall, brk, mmap, munmap, mprotect, futex, nanosleep,
clock_nanosleep and sched_yield, so a confined process can still start,
sleep, take signals and exit cleanly.
allowedSyscalls is unioned into that profile and deniedSyscalls is applied
last, so a denial wins over the learned set, over an explicit allow, and over
the safety baseline. An operator who explicitly denies a syscall meant it, and
quietly keeping it would make the profile misrepresent what the workload can
call. Neither list is validated against the syscall table: a typo is applied
literally rather than rejected.
The profile is only written when the agent runs with --seccomp-dir, and it
takes effect when a pod references it as a localhostProfile. Because seccomp
is installed at exec, that confinement applies to new pods of the workload, not
to the pod that was learned from. This is why
status.enforcementStatus.blockedSyscalls is always zero: the kernel does not
report seccomp denials back to the agent.
capabilityFilter names capabilities that may be exercised, written either as
CAP_NET_BIND_SERVICE or net_bind_service. A name that is not a capability is
reported rather than guessed at.
processFilter answers a different question from the allow-sets. The allow-set
answers "has this container run this binary"; the filter answers "is this
process allowed to run it". Both are enforced in bprm_check_security.
commandsnames binaries, which the exec allow-set already governs, so it is folded into that rather than becoming a fourth filter dimension. Entries must be absolute paths, because the kernel matches the resolved binary path; a relative entry is reported and ignored.usersandgroupsare numeric. The kernel sees uids, not names: it has no view of the container's/etc/passwd, and resolving a name on the node would resolve it against the wrong file entirely. A non-numeric entry is reported and ignored.parentProcessesis matched against the kernel'scommfield, which isTASK_COMM_LENbytes, so only the first 15 characters are compared. A longer name is accepted with a warning saying so.
syscallPolicy.defaultAction is inert, for the same reason as the file one.
Network policy
networkPolicy:
allowLoopback: true
allowDNS: true
egressRules:
- ports:
- port: 5432
peers:
- ipBlock:
cidr: 10.43.12.7/32
action: Allow
Enforcement happens at lsm/socket_connect, which governs egress only.
ingressRules are accepted by the API and ignored, with a warning that says why.
The egress allow-set is a hash of the exact (address, port) pair, and
everything that follows is a consequence of that:
- A CIDR must be a single host,
/32or/128. Anything wider has no representation in a hash map and is reported with the number of addresses it would have needed.ipBlock.exceptis ignored, because a single-host block has nothing to except. podSelectorandnamespaceSelectorpeers cannot be resolved to a fixed address at translation time and are reported rather than half-applied.- A rule with no ports seeds nothing, because there is no key without a port.
- A port range is enumerated entry by entry, so it is capped at 1024 ports per rule. A rule spanning the whole port space would insert 65535 entries per address into an LRU map and evict the learned baseline, which would break the workload in a way that looks nothing like the rule that caused it.
allowLoopback and allowDNS are per-cgroup flags checked ahead of the
allow-set rather than allow-set entries, because they name a class of
destination rather than an address. Seeding a guessed set of loopback addresses
would be both incomplete and impossible to withdraw.
egressRules[].action selects whether the rule's destinations are added to the
allow-set or removed from it. networkPolicy.defaultAction is inert.
Self-healing
selfHealing:
enabled: true
rollbackThreshold: 5
rollbackWindow: "5m"
Enforcement built from a learned baseline can be wrong when the baseline was incomplete, and a container that starts failing immediately after Pahlevan flips it to enforcing is, far more often than not, failing because of that. Rollback returns it to learning.
Only the window immediately after the enforce transition is examined. Past it the baseline is treated as settled, so a container that runs fine for an hour and then legitimately gets denied is not un-enforced by an attacker's noise.
enabled: falsemeans the container stays enforcing whatever happens, which is what turning self-healing off asks for.rollbackThresholdis the number of in-kernel denials inside the window that triggers a rollback. Because the count is bounded by the window, it is a rate. The default is 10.rollbackWindowis that window. The default is 5 minutes.
Pod distress triggers a rollback independently of the denial count: entering
CrashLoopBackOff, restarting, or going from ready to not ready, each measured
against a baseline captured at the instant enforcement began, so a pod that was
already crash-looping before Pahlevan touched it is not blamed on enforcement.
A rollback clears every enforcement bit, restarts the learning window, writes a
Warning Event on the pod with reason EnforcementRolledBack saying which
signal fired, and imposes a cooldown of 10 minutes multiplied by the rollback
count, so a container that keeps failing backs off instead of flapping. After
three enforcement attempts the container stays in learning permanently and the
agent logs enforcement attempt cap reached; container stays in learning (monitor only).
recoveryStrategy accepts Rollback, Relax and Maintenance; only
Rollback is implemented, and the other two are reported as unimplemented
rather than silently behaving like Rollback.
Observability
spec.observabilityConfig, with all of its metrics, tracing, logging and
visualization sub-blocks, is inert in its entirety. Telemetry is configured by
the agent's flags (--observability-exports, --otlp-endpoint,
--metrics-detail and the export and notification flags documented in
api-reference.md) and applies to the whole node rather
than per policy. Setting anything in this block produces a warning and changes
nothing.
Status
The status is written by the node agents and rolled up from the per-container
ContainerProfile resources.
status:
phase: Enforcing
conditions:
- type: Ready
status: "True"
reason: Initialized
learningStatus:
startTime: "2026-09-19T10:00:00Z"
samplesCollected: 15420
syscallsLearned: 62
filePathsLearned: 128
networkFlowsLearned: 4
progress: 100
enforcementStatus:
blockedTotal: 3
blockedFileAccess: 2
blockedExecs: 1
enforcingContainers: 4
totalContainers: 5
lastUpdated: "2026-09-19T10:30:00Z"
phase is one of Initializing, Learning, Transition, Enforcing,
Failed or RollingBack. Condition types are Ready, Learning,
Enforcing, Healthy and Error.
enforcementStatus counts in-kernel denials by kind: blockedFileAccess,
blockedNetworkConnections, blockedExecs and blockedCapabilities, with
blockedTotal as their sum across every container the policy governs.
blockedSyscalls is always zero and is kept for API compatibility, for the
seccomp reason given above. enforcingContainers and totalContainers say how
many of the selected containers have actually reached enforcement, which is the
number to look at when a policy says Enforcing and a workload is not being
enforced.
kubectl get pahlevanpolicy prints Phase, Learning (the learning progress
percentage), Blocked (blockedTotal) and Age.
The learned baseline itself lives in ContainerProfile, one per container, with
the learned syscalls, file paths, egress destinations, executables and
capabilities, the rollback history, and a reference to the generated seccomp
profile. pahlevan profile list and pahlevan profile get read them.
Checking a policy before applying it
pahlevan policy explain -f policy.yaml
pahlevan policy explain -f policy.yaml --strict
This translates the policy offline, with no cluster, prints what will be written
into the kernel allow-sets, and names every part that will not be enforced: an
ingress rule, a CIDR wider than a host, a glob, a DNS name, an over-wide port
range, an expired exception, an inert field. It also rejects a field the CRD does
not have, which the API server would otherwise prune without a word. --strict
exits non-zero, so a policy that quietly does less than it says can fail a CI
gate rather than a production incident.
In a running cluster the same warnings are logged once per policy generation by
the agent, under policy rule is not fully representable.