A container engine addresses a container by a 64-character hex id and never
passes --runtime. The only routing rule a host that serves an engine can
usefully write is "*", so today one node is one backend with one set of
parameters: every pod on nexcage is a libcrun container with exactly what the
engine put in its bundle.
What a Kubernetes user should get from nexcage that crun does not give is a choice, per pod, among isolation settings the node’s administrator defined: a pod on crun with the engine’s own settings next to a pod on crun in a user namespace with capabilities dropped, next to (#316) a pod in an unprivileged Proxmox container on a chosen storage and bridge. That named choice is an isolation profile.
This decision is made before 1.0 because 1.0 freezes the configuration it changes (#317).
What an engine can vary per runtime handler, read in the engines’ source:
| Engine | Per handler | What reaches the runtime binary |
|---|---|---|
containerd 2.1 (io.containerd.runc.v2) |
BinaryName, Root, SystemdCgroup, …, and pod_annotations / container_annotations allowlists |
go-runc runs exec.CommandContext(ctx, BinaryName, args...) (command_linux.go), so argv[0] is BinaryName. The shim’s options have no field for extra arguments or environment (api/types/runc/options/oci.proto). The CRI plugin writes io.kubernetes.cri.* annotations (sandbox id, name, namespace, uid, container name, image) into every bundle, but not the handler’s name (internal/cri/annotations); pod annotations reach the bundle only through the handler’s allowlists |
| CRI-O 1.33 | runtime_path, runtime_root, allowed_annotations, monitor_env, … |
conmon builds the runtime’s argv with runtime_path as argv[0] (runtime_args.c) and execvs it; CRI-O’s own calls (features, --version) run runtime_path as well. CRI-O writes io.kubernetes.cri-o.RuntimeHandler into the sandbox bundle only (server/sandbox_run_linux.go) |
| podman | --runtime <path> |
the path, as argv[0] |
Everything an engine sends to nexcage on later commands — start, state,
kill, delete, exec, ps, update — is the id and global flags; the
bundle is read only at create.
From nexcage itself (#371, #372):
config.json from the file.Each is checked by the verification below, not taken on trust.
execs nexcage without exec -a
loses it (see failure modes).state from the config it
stored at create. True in libcrun 1.30.1 (libcrun_container_state reads
the config from the state directory and prints its annotations).libcrun_container_load_from_memory is
public API in the vendored libcrun.A profile is a name, one backend, and that backend’s parameters, defined in the configuration file on the node. Nothing in a bundle defines or changes one.
{
"profiles": {
"hardened": {
"runtime": "crun",
"crun": {
"user_namespace": "require",
"seccomp": "require",
"capabilities": { "drop": ["CAP_NET_RAW", "CAP_SYS_CHROOT", "CAP_MKNOD"] },
"limits": { "memory": "2G", "pids": 512 }
}
},
"pve-small": {
"runtime": "lxc",
"lxc": { "storage": "local-zfs", "rootfs_size_gb": 8, "bridge": "vmbr1", "unprivileged": true }
}
}
}
[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?), so it
can be a RuntimeClass handler’s name as it is.runtime is crun or lxc, as in a routing rule.crun parameters start with what #315’s acceptance test checks, and
nothing more:
| Parameter | Effect |
|---|---|
user_namespace: "require" |
A bundle without a user namespace and uid/gid mappings is refused, and so is one whose mappings map host uid or gid 0. A user namespace joined by path is checked as well as the mappings: they still have to be there and leave host uid and gid 0 out, although libcrun ignores them when it joins, and the joined namespace has to be a user namespace, not nexcage’s own, and leave host uid and gid 0 out, read from inside it; one that cannot be read is refused. The annotation run.oci.keep_original_groups (other than "0"), which would leave the container nexcage’s own supplementary groups, is refused (checkUserNamespace, checkJoined, refuseKeptGroups in src/core/profile.zig). Nothing is added: a user namespace changes who owns the root filesystem, which is the engine’s to arrange (Kubernetes hostUsers: false) |
seccomp: "require" |
A bundle without linux.seccomp is refused, and so is a filter that refuses no system call by itself: its default action and every rule allow, log, or leave the call to user space (SCMP_ACT_NOTIFY, SCMP_ACT_TRACE). Which calls it refuses is not judged. The annotation run.oci.seccomp_bpf_data, which has libcrun install a precompiled program instead, is refused (checkSeccomp, refusePrecompiledSeccomp in src/core/profile.zig). The message names the fix (securityContext.seccompProfile.type: RuntimeDefault) |
capabilities.drop |
Removed from every capability set of the container’s process. The names are the kernel’s, and a profile naming one the kernel does not have refuses the file; a bundle whose capabilities are spelt otherwise (cap_net_raw, 13), which libcap would read past a drop that compares names, is refused (checkCapabilityNames). A later exec --process gets no capability the container’s own process was denied: nexcage takes that from the config libcrun stored at create, not from the profile, which only create reads (decision 3) |
limits.memory, limits.pids |
The bundle’s limit, or the profile’s when the bundle has none or a larger one. create records the caps in the stored config (io.cageforge.nexcage.profile.memory, .pids), and a later update above them, or to no limit, is refused: an engine builds a resize from its pre-profile copy of the spec, as it does exec’s process |
no_new_privileges, AppArmor, read-only root and the rest can follow when
someone asks for them, under the same rule (decision 4). What a profile
leaves to the bundle today is listed in
ISOLATION.md.
lxc parameters are the keys the proxmox and network sections take
today (storage, rootfs_size_gb, unprivileged, ostype, bridge), with
the same meaning, so a profile is a named override of those sections. #316
decides what more an engine-driven LXC container needs.
Built for the command line, 2026-10-13 (#394). A person or a script names
the profile with --profile, for create and run. Four things differ
from the sketch above:
cores and memory are added. They are the container’s when the command
names none, and the most it may name. --cores or --memory above them is
refused, and a bundle’s are lowered to them.unprivileged takes only true. false refuses the file, because of
decision 4.crun under lxc, or
the reverse, refuses the file.create, by the profile’s name.
It always passes --pid-file, and pct create starts no process
(ADR-006).profiles section is read strictly: an unknown key, backend or
parameter, a value of the wrong type, or a name that is not a DNS label makes
nexcage refuse the whole file (exit 1, as for a file that is not JSON,
naming the key). A misspelt parameter must not silently weaken isolation
(#371).A profile is named by argv[0]: nexcage run as nexcage@<profile> — a
symbolic or hard link to the same binary — uses that profile. The node’s
administrator makes one link per profile and points the handler at it:
# containerd 2.x
[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.nexcage-hardened]
runtime_type = 'io.containerd.runc.v2'
[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.nexcage-hardened.options]
BinaryName = '/usr/local/bin/nexcage@hardened'
SystemdCgroup = true # as runc's handler has it: the kubelet's cgroup driver
# CRI-O
[crio.runtime.runtimes.nexcage-hardened]
runtime_path = "/usr/local/bin/nexcage@hardened"
runtime_type = "oci"
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata: { name: nexcage-hardened }
handler: nexcage-hardened
A pod with runtimeClassName: nexcage-hardened then runs under hardened.
--profile <name> names a profile for one command, for a person or a script.
When both are given and disagree, the command exits 2.@, not -. crun does the same with crun-<handler>, but nexcage’s
release files are called nexcage-0.16.0-amd64-crun, and a binary run under
that name must not be read as a profile called 0.16.0-amd64-crun. A program
name with no @ names no profile.io.cageforge.nexcage.profile
is reserved for nexcage to write (decision 5); a bundle that arrives carrying
it is refused, as libcrun refuses a run.oci.handler annotation that tries to
override the handler it was started with.runtime.routingRouting stays as it is, next to profiles, and decides only at create:
--profile or the program name;--runtime;container_config.default_runtime, LXC unless
the file sets it.--runtime together with a profile whose backend differs exits 2.
After create, the container decides, not the configuration (#372). Every
other command goes to the backend that has the container: libcrun’s status
file for the id first (a stat), then the Proxmox backend’s own record of it
(its state.json, another stat); with neither, the container does not exist,
and only --runtime lxc sends the id to the Proxmox cluster. The record is of
a name, so through it the id finds a container by name only; a VMID takes
--runtime lxc. Neither the
profile nor routing is consulted. A profile renamed, edited or removed, or a
rule changed, while containers run can then never send kill or delete to
the wrong backend or block them with “unknown profile”.
A routing rule that names a profile instead of a runtime would let a person’s container names choose profiles too. Nobody has asked for it, so it is not part of this decision; the shape leaves room for it.
A profile may narrow what the engine’s bundle grants and may never widen it:
What cannot be narrowed by editing the bundle is a requirement: the bundle is refused, and the message says what it lacked.
The engine’s bundle is what the cluster’s policy produced — PodSecurity
admission, the pod’s securityContext, the kubelet’s defaults. A profile that
could widen it would let whoever may pick a RuntimeClass step around that
policy. A profile that only narrows gives at least the bundle’s isolation and
never less, so which pods may use which class stays the cluster’s decision
(admission policy), and picking any class is never an escalation.
On LXC “narrow” has a different object: an engine-driven LXC container’s parameters come from the profile, not the bundle (#316). The same rule holds for what the bundle asks: it may not get more than the profile allows.
state reports the profilestate carries the annotation io.cageforge.nexcage.profile: <name> on
both backends; a container made without a profile has none.
libcrun_container_load_from_memory. libcrun stores that config in its state
directory and prints its annotations in state (assumption 2). That state
directory is also the record decision 3 looks for; nexcage writes nothing
else.state.json nexcage already writes,
next to io.cageforge.nexcage.node.The name records what was applied at create. If the profile’s parameters are
edited later, running containers keep what they were made with.
| What goes wrong | What happens | Why that is acceptable |
|---|---|---|
The nexcage@<profile> link is missing, or the handler’s path is misspelt |
The engine fails the pod with “no such file” | Visible at the first pod |
| A profile is named that the file does not define | create exits 2, naming it and listing the defined ones |
No fallback to weaker isolation |
A misspelt key or parameter in profiles |
The whole file is refused | Strict reading, decision 1 |
A wrapper script execs nexcage without exec -a "$0" |
The program name is lost; the container is made without the profile, by routing | Cannot be detected by nexcage. Documented next to the engine configuration, and the tests’ trace wrappers use exec -a. A wrapper can pass --profile instead |
| The bundle lacks what a requirement asks (no user namespace, or one that maps host root; no seccomp filter, or one that refuses no call) | create fails; the pod stays in ContainerCreating with the reason in kubectl describe |
For a missing user namespace or filter, and a filter that refuses no call, the reason names the pod field that fixes it: hostUsers: false, securityContext.seccompProfile.type: RuntimeDefault (src/core/profile.zig:582-594, :636). A mapping of host root, a joined namespace, run.oci.keep_original_groups and run.oci.seccomp_bpf_data are named as what was refused, with no field to set |
| A requirement breaks the pod’s sandbox (pause) container | The pod never starts | Assumption 4: #315’s test runs a whole pod, sandbox included, under hardened |
A bundle carries io.cageforge.nexcage.profile |
Refused | Otherwise state could report a profile that was not applied |
| A profile’s parameters are edited while its containers run | Running containers keep the old parameters; state shows the same name |
The name means “as defined at create”; documented |
| A profile is removed while its containers run | kill, delete and state still work |
Decision 3: only create reads profiles |
| A pod author picks a class the cluster did not mean them to | The pod gets that class’s profile | Never more than its bundle grants (decision 4); which pods may use which class is admission policy, outside nexcage |
| Alternative | Why not |
|---|---|
An annotation selects the profile (pod_annotations in containerd, allowed_annotations in CRI-O) |
The value comes from the pod’s author, not the node; containerd puts no handler identity in the bundle, so the annotation would be the only signal. It needs an allowlist per handler in each engine’s configuration, reaches only create, and CRI-O and containerd pass annotations differently. What it would add — a different profile per container in one pod — nobody has asked for |
A wrapper script per handler (exec nexcage --profile x "$@") |
Works, and is the fallback for an engine that cannot use a path with @. As the default it adds a second executable to keep and secure, and a shell on every runtime call, to do what the program name does |
Root / runtime_root per handler mapped to a profile |
Couples a state location to a policy; a mistake in one silently changes the other |
| An environment variable per handler | containerd cannot set one per runtime handler |
| Profiles replace routing | Breaks every configuration that routes by name today, including the shipped examples, for no gain to engines, which only ever use * |
| Profiles that may also widen (add capabilities, privileged) | Turns the choice of a RuntimeClass into a way around the cluster’s policy (decision 4) |
| A default profile applied when none is named | Implicit: the same binary would behave differently depending on a key elsewhere in the file. The plain program name keeps today’s behaviour |
Positive:
RuntimeClass, with no change to Kubernetes or the
engines.state says which profile a container was made with.Negative and costs:
docs/INSTALL.md and deploy/kubernetes/node/ get both.RuntimeClasses on one k3s node, nexcage and
nexcage-hardened, one pod each. From inside each container: compare
/proc/self/uid_map (identity versus a mapping);Seccomp: and CapBnd: in /proc/self/status;memory.max and pids.max.The hardened pod also runs its sandbox under the profile (assumption 4), and
a pod without seccompProfile is refused with the documented reason.
nexcage@hardened, nexcage and nexcage-0.16.0-amd64-crun as program
names: only the first names a profile;--runtime against a profile’s backend exits 2;profiles keys are refused (#371).runtime_path as argv[0]. The CRI-O
path is tested in #315 if the CI image can run CRI-O; otherwise that remains
the one engine path proved only by reading its source, and the docs say so.nexcage@<profile>.seccomp: "require" refuses a bundle without a filter. nexcage does not
apply a filter of its own. Since the hardening of 2026-10 a filter that
refuses no system call by itself, and a precompiled program in its place,
count as none (audit, F058).fill_handler_from_argv0 and libcrun_configure_handler — the same
choice made by program name, with a bundle annotation refused when it would
override itinternal/cri/annotations, api/types/runc/options/oci.proto,
go-runc 1.1.0 command_linux.go; CRI-O 1.33.0
server/sandbox_run_linux.go, conmon 2.1.13 src/runtime_args.c