Skip to main content
kguardian turns the syscalls it observes into a SeccompProfile manifest you commit to git. Once you apply that CR, the controller on every node writes the profile file it describes and reports back in the CR’s status. kguardian never applies a profile on its own, never edits workloads, and never writes a file that is not backed by a CR you own — see policy as code.
Ownership split. You own the SeccompProfile object: its contents, its version history, and the decision to enforce. kguardian guarantees that the file it describes exists at a known path on every node, tells you when that is true, and tells you when what it observes has drifted from what you committed.
Worked samples of every stage below (an exported audit-mode CR, the same CR promoted to enforcing, a stricter batch-job profile, the exact file each controller writes to the node, and the workloads that reference them) are in examples/seccomp-profiles. They show the format only. Generate your own profiles from observed syscalls.

The lifecycle

1

Capture

The controller’s eBPF probe records syscalls per pod at the cluster’s capture tier (syscalls.captureLevel, default full). Only full is complete enough for a profile — see capture tiers.
2

Review

Each pod is attributed to its top-level controller (ReplicaSet → Deployment, Job → CronJob) and the broker keeps a monotonic union of every syscall seen for that workload. Inspect it in the UI or via GET /seccomp/profiles. A profile built from a partial tier is flagged.
3

Export

Export the SeccompProfile manifest (UI, or GET /seccomp/profiles/{ns}/{kind}/{name}/export). It arrives with defaultAction: SCMP_ACT_LOG and a workloadRef so kguardian can keep reporting on it.
4

Commit and apply

Review the YAML like any other manifest, commit it, apply it — by hand or through your GitOps pipeline. This is the only step that changes cluster state, and it is yours.
5

Reference

With seccomp.distribute=true, every controller writes kguardian/<namespace>/<name>.json to its node and the CR’s status turns Ready. Add the Localhost reference to the pod template once.
6

Promote

Once the denial signal confirms clean — the CR’s DenialsObserved condition is False and status.denials.observed is 0 — change defaultAction to SCMP_ACT_ERRNO in git and apply. The file is rewritten on every node; it applies to each pod on its next restart. An unset status.denials, or DenialsObserved: Unknown, means “no data”, not “clean” — do not promote on either of those. See promote to enforcing below.
7

Watch for drift and denials

Two signals matter after that, and they answer different questions. When the workload starts making a syscall your CR does not allow, the CR’s Drift condition and the UI say so — re-export, diff, commit. When the kernel actually denies (or, in audit mode, logs) a syscall against a loaded filter, that shows up as a denial — DenialsObserved, the kguardian_seccomp_denials_total metric, GET /seccomp/denials. See Denials vs Drift for why both exist.

Capture tiers

The controller traces syscalls at one of five tiers, set cluster-wide in Helm: full costs about the same CPU as low: the probe de-duplicates per pod network namespace inside BPF, so each syscall crosses into userspace once per pod. Choose a lower tier only if you never intend to ship profiles.
A workload can raise its own tier above the cluster default (never lower it) with a pod-template annotation. kguardian.dev/seccomp-record: "true" is an alias for full:
Let the workload run under representative load. For a CronJob, let several scheduled runs complete — one run rarely exercises every code path.
A profile built from a partial tier is an incomplete allowlist: it will deny syscalls the application makes. The completeness check covers every pod that ever contributed syscalls to the union — one historical low run keeps the workload incomplete. The UI and API mark such profiles (capture.complete: false) and name the tier and pods responsible; the exported YAML carries a # WARNING: partial capture header and the CR’s CaptureComplete condition stays False. Export is never blocked — the decision is yours — but raise the tier and let the union refill first.

Enable distribution

distribute adds a hostPath mount of <kubeletRoot>/seccomp to the controller DaemonSet, grants it read/watch on seccompprofiles plus patch on their status, and starts a cluster-wide watch on SeccompProfile objects. For each CR the controller renders the standard seccomp JSON from spec, compares it byte-for-byte with <kubeletRoot>/seccomp/kguardian/<namespace>/<name>.json, and writes atomically when they differ. Deleting the CR deletes the file — that is the one case where kguardian removes something from a node, and it only ever follows an explicit deletion of yours. installCRDs renders the CRD as part of the release (with helm.sh/resource-policy: keep) so helm upgrade picks up schema changes, which Helm’s crds/ mechanism never does. The trade-off is that helm uninstall leaves the CRD — and every SeccompProfile — in place; delete it by hand if that is what you want. Set it to false if you manage CRDs separately and apply charts/kguardian/files/kguardian.dev_seccompprofiles.yaml yourself. (The older AuditNetworkPolicy CRDs still ship in crds/; they predate this pattern and have not needed a schema upgrade.)

Review and export

List workloads with an observed syscall set, their capture completeness, and whether a CR already covers them:
cr: null means no SeccompProfile references this workload yet. Export one:
?name= renames the CR and ?defaultAction= changes the action; the UI’s editor stages syscall additions and removals before export. Review it, commit it, apply it:
The full status — per-node hashes, Ready / CaptureComplete / Drift conditions — is in the CRD reference.

Reference the profile

Wait for READY to show every node. A pod that schedules onto a node still missing the file fails to start with CreateContainerError until the controller there catches up (immediately on the watch event, or on the next resync).
Reference the path — at pod level, or on a single container. The name is the CR’s name, so it never changes when the contents do:
The kubelet reads the profile at container start. A changed file does nothing to running pods; it applies on the next restart. That is what makes rewriting in place safe — and what makes an over-tight enforcing profile surface later than you might expect.

Edit a profile

The CR is yours. Add a syscall the scanner never caught, remove one you want gone, change the action — in git, then apply. Every controller rewrites the file on the watch event; the app team changes nothing.
kguardian keeps observing underneath. If you removed a syscall the workload still makes, or it starts making a new one, the Drift condition flips to True with the names, and GET /seccomp/profiles shows them under cr.drift.missing. Re-export and diff to fold them in — the UI shows the add/remove lines against your current CR before the YAML.

Promote to enforcing

SCMP_ACT_LOG logs disallowed syscalls to the kernel audit log (type=SECCOMP in dmesg / auditd) and blocks nothing. Run a full usage cycle in this mode — for a CronJob, several runs — and confirm the workload is not tripping the profile before promoting.
Before this release, “confirm” meant reading node kernel logs by hand — the audit trail existed but kguardian never read it back. It now does: the controller runs a kprobe on the kernel’s audit_seccomp() (the function every loggable seccomp verdict passes through), attributes each one to a pod and workload, and the broker turns it into the same denial signal described in Denials vs Drift. The denial signal is what gates promotion now — it is the in-cluster, queryable, alertable replacement for “grep dmesg on every node.”
Check the CR before promoting:
Clear to promote — the broker checked this workload and found nothing in the retention window:
Not clear — a nonzero count is unambiguous:
Also not clear — Unknown means “no data”, never “clean”:
NoDenialData is the broker answering and having nothing to say: the CR predates this feature, seccomp.denials.enabled is off cluster-wide, or no node’s kprobe loaded (missing CONFIG_AUDITSYSCALL). Two sibling Unknown reasons exist for the same “no data” verdict for a different cause: NoWorkloadRef (the CR has none) and NoObservations (nothing reported for this workload at all yet) — the same pair CaptureComplete already uses for the equivalent situations. Before any of that, read refreshedAt. status.denials is a settled reading, not a live counter: every node polls the broker separately, so rather than have the fleet rewrite the CR on every denial, a node republishes the block only when its reading says something strictly stronger than the published one — a syscall or a verdict not listed there, or an order of magnitude more events — and otherwise leaves it alone until it is more than 15 minutes old. So observed trails live activity by up to 15 minutes, in one direction: a count climbing inside one order of magnitude, a count falling as the retention window prunes, and a workload going quiet all wait for that refresh. A cleared workload therefore reads clean late rather than early, which is the safe direction for a promotion gate — but GET /seccomp/denials is the live, per-event view when you want the current number. None of those three is the sharp edge, though. A broker that cannot be reached doesn’t turn DenialsObserved Unknown at all — the reconciler skips the status write entirely that pass and every condition, including DenialsObserved and status.denials, is left exactly as it was. A CR can sit on False/NoDenials/{"observed": 0} for hours into a broker outage and look precisely as clean as it did before the outage started, because nothing ever told it otherwise. This is the actual limit on using the CR alone as a promotion gate: it tells you the last answer the broker gave, not whether that answer is still current. If the stakes are high enough that a stale “clean” would matter, don’t stop at the CR — check the controller pods’ own logs for failed POSTs to the broker, and confirm the broker itself is healthy (GET /health) before you promote. Only once you have an explicit DenialsObserved: False with status.denials.observed: 0 should you change one line and apply:
A too-tight profile breaks the workload on its next restart, not immediately. Promote deliberately, and know that reverting is a one-line change in git. After promoting, watch DenialsObserved for a while longer: denials on a profile whose defaultAction now enforces mean the kernel is actively blocking or killing the workload, not just logging. That stays visible because the controller renders every enforcing profile with SECCOMP_FILTER_FLAG_LOG — without that flag the kernel does not audit SCMP_ACT_ERRNO verdicts at all, so a hand-written profile applied outside kguardian blocks in silence. Promotion changes the rendered file, so every node rewrites it and Ready briefly counts back up. A rewritten file does not reach a running container: seccomp filters load at container start, so the promoted profile — and its flag — applies from the pods’ next restart. The same holds for profiles promoted before kguardian rendered the flag: their files are rewritten on upgrade (expect Ready and Drift to flap while old and new controllers disagree during the rollout) and take effect on restart — see metrics and alerting below for turning that into a page rather than something you notice later.

Metrics and alerting

Denial capture is on by default (seccomp.denials.enabled, default true) — it needs no configuration to start attributing kernel verdicts to pods and storing them in the broker:
The broker keeps rows for broker.seccomp.denials.retention.days (default 30) and exposes three Prometheus metrics on the existing /metrics endpoint: kguardian_seccomp_denials_total (a counter, labelled workload_namespace/workload_kind/workload/action — deliberately not by syscall, which would multiply cardinality by roughly the syscall table size), kguardian_seccomp_denial_workloads (distinct workloads with at least one denial in the retention window), and kguardian_seccomp_denial_rows_total (rows ingested since broker start). /metrics never queries Postgres on the scrape path — it reads a cached aggregate, refreshed every broker.seccomp.denials.metricsIntervalSeconds (default 15s, 5s floor). That timer is deliberately separate from retention.intervalSeconds above (default 3600s): KguardianSeccompEnforcingProfileDenials fires on a 5-minute increase() window, and if the cache only refreshed on the retention loop’s hourly cadence, the metric backing that alert could lag reality by most of an hour — turning a “page right now” alert into one that notices well after the fact. Lower metricsIntervalSeconds for faster alerting if 15s isn’t tight enough for your stakes.
Three independent toggles have to agree before an alert can fire, and it is easy to enable only one of them and see nothing happen:
  1. broker.metrics.serviceMonitor.enabled (or your own scrape config) — without a scrape, the metrics exist in-process but Prometheus never sees them.
  2. broker.networkPolicy.allowMetricsFrom — only relevant if broker.networkPolicy.enabled is also true. That NetworkPolicy is ingress-only and default-deny by construction; a Prometheus that isn’t listed here is blocked at the network layer before the scrape toggle above even matters. Add your Prometheus’s namespace/pod selector here the same way you would for any other broker scraper.
  3. broker.metrics.prometheusRule.enabled — renders the alert rules themselves. A prometheus-operator installation needs to select this PrometheusRule the same way it selects the ServiceMonitor (usually a shared release: kube-prometheus-stack label on both — set it via broker.metrics.serviceMonitor.labels and broker.metrics.prometheusRule.labels).
The shipped alerts:

Things to know

  • One file per CR. kguardian/<namespace>/<name>.json, rewritten in place. No hash-named copies accumulate; version history lives in git, where the CR does.
  • The observed set only grows. kguardian keeps a monotonic union — a syscall the workload stops making stays observed. Your CR is the place to narrow; cr.drift.extra lists what your CR allows that was never observed.
  • Per-pod, not per-container. Attribution is by network namespace, which all containers in a pod share. An injected sidecar adds its syscalls to the workload’s observed set. The profile is a safe superset; per-container profiles are not supported.
  • workloadRef is optional. Without it the CR is still distributed, but CaptureComplete and Drift are Unknown — kguardian has nothing to compare against. Several CRs may reference one workload; the summary shows the newest and a crCount.
  • Pods from an older controller have no recorded capture level, report as unknown, and count as incomplete. A CronJob between runs or a workload scaled to zero is complete if every contributing pod was full.
  • New nodes get every CR’s file as soon as the controller there starts.
  • Mixed architectures. A workload on both amd64 and arm64 nodes gets both in spec.architectures. Syscall names are resolved per architecture at controller startup.
  • The CLI still works. kubectl kguardian gen seccomp writes the same profile as plain seccomp JSON for use outside the CR flow.
  • Denials age out; the observed syscall set never does. Unlike the monotonic syscall union above, seccomp_denials rows are pruned after broker.seccomp.denials.retention.days (default 30). A workload with a clean DenialsObserved today may simply have aged out an old denial, not necessarily never produced one — check lastSeen in GET /seccomp/denials if that distinction matters for an audit.
  • Denial capture doesn’t need syscalls.captureLevel: full, or even seccomp.distribute. It reads the kernel’s own verdict on a loaded filter, not traced syscalls, so it captures denials at any capture tier, and for a profile applied outside kguardian’s distribution path entirely (hand-applied, or shipped by another tool). Pod-to-workload attribution works the same way it does for the observed syscall set — it needs no SeccompProfile CR or workloadRef — but rolling the denial signal up onto a specific CR’s status.denials still needs that CR’s workloadRef to match, the same as CaptureComplete and Drift do.