kern runs untrusted images inside a sandbox. It will receive security reports; here is the model and how to report.
Please do not open a public issue for security bugs. Report privately via GitHub Security Advisories ("Report a vulnerability" on the repo) or email hello@getkern.dev. You will get an acknowledgement and a coordinated-disclosure timeline.
The structured view - assets, entry points, and the two trust levels (a kernel-enforced boundary versus a cooperative governor) as tables - is in docs/THREAT_MODEL.md. This section is the summary; the rest of this file is the per-mechanism detail behind it.
In scope. Kernel-enforced isolation must hold:
- A malicious OCI image or
--rootfsmust not read or write host files outside the rootfs (path traversal, cross-layer symlink escape, whiteout-through-symlink, tar traversal). - A box must not see or affect host processes, mounts, or other boxes.
- A box must not read or write kern's own runtime registry (a peer box's ssh host keys, secrets, and
recorded capability/seccomp posture) through any host-path input:
-v,--secret,--env-file,--rootfs, thekern buildcontext/-fDockerfile, orkern cp/kern save -o. - Resource limits must hold: fork bombs and OOM must be contained.
- seccomp must block the dangerous syscall set unconditionally.
Out of scope, by design. GPU limits are not shipped, so there is nothing here to trust or to attack yet. When they land, expect a cooperative governor for honest workloads rather than a boundary, and expect this file to say so with the bypasses named.
An unprivileged user namespace is itself kernel attack surface. kern's isolation is built on one, and userns has historically been a fertile source of kernel privilege-escalation CVEs. Running untrusted code in a box hands that code the in-kernel namespace surface to probe.
kern isolates with namespaces, seccomp and a pivoted root: millisecond start, one small binary, no VM, no daemon. That boundary is real and its attack surface is the host kernel, so a kernel privilege-escalation bug is an escape.
- Reach for kern when the code is yours or semi-trusted and you want speed, density and simplicity: CI jobs, build steps, dev sandboxes, your own agent's tool-calls under your supervision.
- Reach for a microVM (Firecracker, Kata) or gVisor when you run actively hostile, multi-tenant code from strangers sharing one host, and a hardware-virtualization boundary is worth the startup cost. That is not where kern competes.
- Namespaces: user, PID, network (loopback-only), UTS, IPC and mount.
pivot_rootinto the rootfs. The default root is a writable overlay whose scratch is discarded on exit;--read-onlyremounts it read-only, and the ordering (read-only only after the pivot) is compile-enforced by a typestate.- Least-privilege capabilities: 14 never-needed dangerous caps (module load, raw I/O,
SYS_TIME,SYSLOG,BPF,PERFMON, MAC and audit admin,SYS_BOOT,SYS_PTRACE, and more) are dropped from the effective, permitted, inheritable and bounding sets just before exec, so no setuid or file-capability binary in the image can wield them. (SYS_PTRACE'sptracesyscall is already seccomp-killed; dropping the cap also closes the/proc/<pid>/memcross-process read it would otherwise allow.)--cap-drop CAP/--cap-drop ALLdrops more;--cap-add CAPkeeps one that would otherwise go (add wins), and an unknown cap name is a hard error so a typo cannot silently leave a cap in place. Even a re-addedCAP_SYS_ADMINis held only over the box's own user namespace, and the always-on filter still blocks the escape syscalls it would unlock, so--cap-addcannot breach the host. - Always-on seccomp, 34 syscalls denied: 24 that hard-kill plus the 10 that return
ENOSYS, split described below; a rootless--privilegedbox denies 5 fewer. Do not take the number from this file, ask the binary:kern box <name> --image <ref> --show-configprintsseccomp_denied_syscallsfrom the live lists. The set: kexec (+_file_load), module load/unload,ptrace+process_vm_readv/writev, reboot, swap, the classic and new mount API including the whole reconfiguration family (mount_setattr,fspick,fsopen/fsconfig/fsmount,open_tree/move_mount), so a box cannot re-mount its own root writable, pluspivot_root,setns,unshare,bpf,clone3, io_uring's three,userfaultfd,perf_event_open, the keyring's three andsyslog. Wrong-arch syscalls are killed, and on x86_64 so is every x32-ABI syscall, closing the bypass where the x32 alias of a denied number slips past a number-only denylist. clone(2)is filtered on its ARGUMENTS, and it is the only rule of that shape. Denyingunshareandsetnsdoes not stop a workload making a namespace, becauseclonetakes the sameCLONE_NEW*flags, and a process that creates a nested user namespace is handed a full capability set by the kernel, bounding set included.clonecannot simply be denied by number, sincefork,vfork,posix_spawnandpthread_createare allclonewith no namespace bit, so the filter reads the flags out of the register they arrive in and kills only the sevenCLONE_NEW*bits.clone3puts the same flags in a struct behind a pointer, which BPF cannot dereference, so it is refused wholesale withENOSYS, the answer Docker and podman give for the same reason. Closed and verified on six platforms. The filter inspects call ARGUMENTS in exactly two places - thecloneflags above and thesocketdomain (below) - and matches every other syscall by number alone. Soioctlis allowed as a whole, not per-command (moby's default does the same), andpersonalityis left to the number-level allow: its risky flags weaken the box against ITSELF (ADDR_NO_RANDOMIZEdrops the box's own ASLR), never the host.socket(AF_VSOCK, …)is refused withEAFNOSUPPORT, in BOTH the denylist and the allowlist - the one place kern's default used to be wider than moby's, now closed. The network namespace does not contain vsock (it is not an IP address family), so on a host with avsocktransport loaded - WSL2, whereVMADDR_CID_HOSTreaches Windows-side services that never touch the box's loopback netns - a box could otherwise reach the host past its netns. It is a reachability gap, not a privilege escalation (it grants no capability the box lacks), andEAFNOSUPPORTis the exact errno a host with novhost_vsockreturns, so a workload that probes vsock falls back cleanly instead of dying. The rule keys on the low 32 bits ofargs[0](the domain) and is unaffected by--privileged. Verified with a discriminant: on a host where vsock works, the samesocket(AF_VSOCK)SUCCEEDS outside a box and returnsEAFNOSUPPORTinside one.- Device access is deny-by-default: the box's
/devis a fresh box-owned tmpfs shadowing the image's, with onlynull,zero,full,randomandurandombound in. Any other node is absent, and one the box fabricates is inert: a filesystem mounted in an unprivileged user namespace is flaggedSB_I_NODEV, so amknod'd node cannot be opened to reach a host device. The root,/devand every extra mount also carryMS_NODEV, so this does not rely on that implicit userns behaviour. - Landlock write-allowlist (
--landlock-rw <path>, opt-in, needs Linux 5.13+): a kernel LSM confines the box's writes to the named paths while the root stays read+exec, with symlinks openedO_NOFOLLOW. A real boundary, verified: a box with--landlock-rw /tmpwrites/tmpand is denied/etcand/root. Where the kernel lacks it, the box still runs with the namespace, seccomp and cgroup boundary. - Egress allowlist (
--egress-allow, opt-in, foreground): the box reaches the internet only through a kern-run filtering proxy. SSRF-guarded: a domain resolving to any non-public address is refused at connect time even if allow-listed. Honest residual: a domain sharing a CDN IP and SNI with an allowed one can be reached, so this is an application-layer allowlist for a semi-trusted workload, not a hard exfiltration boundary. Full model in docs/EGRESS.md.
The filter has two verdicts. Real escape vectors (kexec, module load/unload, the mount API, bpf,
ptrace, setns/unshare/pivot_root) hard-kill the caller with SIGSYS. Ten, in six
families that software merely probes for an optional fast path (io_uring's three, userfaultfd,
perf_event_open, the keyring's three, syslog, and clone3), return ENOSYS. They are
equally denied; the difference is only what the caller sees, and it is the difference between Redis
falling back to its epoll path and Redis dying. The two sets are asserted disjoint by a test.
The obvious objection is that a survivable denial is easier to enumerate than a fatal one. Measured inside a box on x86_64, kernel 7.0:
| syscall probed inside the box | result |
|---|---|
io_uring_setup (denied, degrade set) |
-1 ENOSYS, process survives |
| syscall number 998 (exists on no kernel) | -1 ENOSYS, process survives |
kexec_load, bpf (denied, kill set) |
killed by SIGSYS |
| the same calls with no kern filter (control) | a different errno, never ENOSYS |
So the errno discloses nothing: a filtered call is byte-identical to one this kernel does not implement. What is cheap to enumerate is the permitted set, and always was, since a permitted syscall runs and returns its own errno. Whether mapping a filter helps an attacker who already has code execution in the box is a separate and open question, recorded as unresolved in OPEN_ITEMS.md rather than argued either way here.
Two independent layers, and neither is the default cap drop - which does not remove CAP_SYS_ADMIN
(that cap is kept, held only over the box's own user namespace). First, the always-on filter kills
the mount API - mount, umount2, pivot_root, setns and the whole reconfiguration family - so a
box cannot re-mount its root writable OR umount the cgroup masks to reach the host hierarchy,
whatever caps it holds. Second, a child user namespace's capabilities are not effective over the
namespace that owns them, so even a kept or --cap-added CAP_SYS_ADMIN cannot act on the
host-owned cgroupfs and mounts. --no-seccomp waives the first layer by choice; the second stands
regardless, and --privileged (which relaxes mount/umount2 for nesting) is honoured only when the
box root maps to an unprivileged host uid. A third hardening, locking the mounts with MNT_LOCKED so
the first layer holds even under --no-seccomp, is deferred rather than shipped untested: it reorders
capability-sensitive setup that must be verified on real namespaces.
By default a full kern box cannot run inside another; it gets SIGSYS. --privileged relaxes
exactly five syscalls, unshare, setns, mount, umount2 and pivot_root, so a nested box
can create its own namespaces and rootfs. Everything else stays blocked, so a --privileged kern box
is materially stronger than a Docker --privileged container, which drops the filter wholesale. It
also skips /proc masking, because the kernel refuses a nested /proc mount under the locked masks.
Rootless-only, and gated on the effective mapping rather than the caller's euid: it is honoured
only when the box's root maps to an unprivileged host uid, decided by reading /proc/self/uid_map
after the namespace is set up, and refused outright as real root, where a relaxed mount could reach
the host-global /proc/sys knobs. Rootless, those knobs stay unwritable regardless: a --privileged
box can read /proc/sys but not write it, verified against core_pattern.
Inside the systemd user manager's tree, kern box caps directly in its delegated kern.slice;
where that is out of reach it falls back to a transient systemd-run --user --scope with
MemoryMax/TasksMax. Either way fork bombs and OOM are cgroup-enforced, verified by read-back.
Without a user manager a best-effort cgroup v2 path applies where the hierarchy is delegated, else it
is skipped gracefully: on a host with neither, containment is not guaranteed. --pids-limit N
sets pids.max, default 512, on the same terms.
--require-limits makes the uncapped fallback fatal. With it (or KERN_REQUIRE_LIMITS) a box
refuses to start, non-zero, unless the memory and pids caps are actually in force, read back from
the cgroup rather than merely written: the OOM / fork-bomb backstop, never a box that runs believing
it is capped when it is not. cpu/cpuset stay best-effort, as they carry no containment role.
--allow-uncapped (KERN_ALLOW_UNCAPPED) is the explicit inverse, for a host with no cgroup
delegation (nested CI): accept uncapped operation silently instead of the once-per-host warning. The
two are mutually exclusive; the default is unchanged (warn once, run uncapped).
kern exec and the box's caps. An exec'd command inherits them only where the box sits in a
delegated cgroup kern can write. On the rootless per-box-scope path (an SSH login on an edge board,
whose shell is a sibling scope) the kernel will not let it migrate into the box's transient scope, so
the exec'd command runs outside the box's caps; kern warns rather than leak that silently. The
box's own workload is always capped, and namespaces plus seccomp isolate the exec'd command
regardless. Each exec does not get its own capped scope on purpose: that would grant every exec the
box's full limit, so N execs could use N times the box's memory.
--security-profile untrustedis an opt-in bundle for code nobody has read: the seccomp allowlist (deny-by-default: only a reviewed set of syscalls is permitted, stricter than the always-on denylist),--cap-drop ALL, and--read-onlyroot, applied as a BASE that explicit flags still override.--cap-add ALLand--privilegedare refused under it (each would negate a constituent, leaving a box labelled untrusted that is not), and a SET-but-unrecognisedKERN_SECCOMPis a usage error rather than a silent downgrade. It prints its resolved constituents (the real seccomp mode and any surviving--cap-add), so the label cannot lie. It does not touch Landlock (which needs the workload's real write paths) or set--require-limits(which would break a cgroup-less host). A CLI/SDK flag, not a compose key: a compose service reaches the same posture through its individual keys andKERN_SECCOMP.--apparmor <profile>enters a pre-loaded AppArmor (LSM) profile on the box'sexec, layering kernel-enforced file/capability confinement over namespaces + seccomp - Docker's--security-opt apparmor=. The profile must be loaded on the host (root, once,apparmor_parser -r); a missing or unloadable profile fails the box closed rather than running it unconfined.kern execre-enters the box's own profile, so an exec is no less confined than the workload (parity with the caps + seccomp it already reapplies) - and a box whose posture predates this recording is refused rather than exec'd unconfined. The periodic--health-cmdprobe is a deliberate exception: it is kern's OWN command, not the workload, and runs OUTSIDE the profile (it reproduces the box's seccomp mode but not its AppArmor profile) so a restrictive profile cannot make a box permanently unhealthy by denying its own check. kern applies no default profile: without the flag the box keeps kern's own (usually unconfined) and its boundary is namespaces + seccomp + cgroups, as documented above.--user UID[:GID](or a name) drops the workload after all privileged setup and the capability drop. A name (--user memcache, composeuser:, or the image's ownUSER) is resolved against the image's/etc/passwd//etc/group. Only ids mapped into the box's user namespace work, so a non-root--userimplies the uid/gid-range mapping. It fails closed: if the id cannot be mapped the box refuses to start rather than silently running as in-box root. Note it sheds all capabilities, including any--cap-add.--tmpfs PATH[:size]mounts a freshNOSUID,NODEVtmpfs. Mounting one over the sandbox's own hardened/proc,/sysor/devis refused. The size is a real cap but counts against RAM.--net(--network host) shares the host network namespace: there is then no network isolation. The box can reach hostlocalhostservices, the host's networks, and every abstract-namespace UNIX socket (X11, some D-Bus sockets), and can bind host-visible addresses.--tunbinds/dev/net/tunin. The box holdsCAP_NET_ADMIN, but a child user namespace's capabilities are not effective over a namespace owned by the initial one, so even with--network hostit cannot reconfigure the host's interfaces (EPERM).-v src:dstbinds a host path in. A writable volume is a hole through the sandbox by design; use:ro. The two ends are resolved differently, on purpose. The source (host side) is the operator's own path: kern rejects a non-existent source andcanonicalizes it to an absolute, symlink-free path at parse time. The target (dst, inside the box root) is walked one component at a time withO_NOFOLLOW, refusing..and confined to the new root, so a hostile image that ships a symlink at the mount point cannot redirect the bind onto a host path. The bind is non-recursive, because a recursive one would clone host submounts that a:rovolume could then leave writable; the flip side is that a filesystem already mounted under the source keeps its own flags, so a pre-existing read-write submount there is not remounted read-only. A submount beneath the source that a process other than the operator can create is thus outside the:roguarantee - the source path is trusted as the operator's own. kern additionally refuses to expose its own runtime registry ($XDG_RUNTIME_DIR/kern) to a box. The rule is an inverted default: everything under the registry root is refused except an explicit box-data allowlist (logs/,scratch/), so a directory added later (aswaitexit/was) is non-mountable by omission rather than mountable by omission. Amount --bindalias whose path is elsewhere is caught by device+inode identity, not path alone. This closes the class at every host-path entry point, not only-v:--secret,--env-file,--rootfs, and thekern buildcontext /-fDockerfile route through the same check. The registry holds a peer box'sssh/host keys,secrets, andinstances/capability/seccomp posture records: a box able to READ them steals a peer's secrets, and one able to WRITE them forges a peer's recorded posture to elevate that peer'skern exec.-p [ip:]host:boxbinds127.0.0.1by default.-p 0.0.0.0:H:Bexposes the service to the LAN, a deliberate and warned-about choice. The forwarder runs in the host network namespace, the box stays in its own.kern execis restricted to the user who started the box. The exec'd process gets the same always-on seccomp filter, fail-closed, and the same dropped-cap baseline. A box's custom--cap-drop/--userare not reapplied, since they are not recorded per box, so an exec runs at the baseline rather than the tightened profile. The host boundary still holds.kern cpresolves the in-box path withopenat2(RESOLVE_IN_ROOT | RESOLVE_NO_MAGICLINKS), so every symlink and..is reinterpreted as if the box root were/: a hostile image cannot plant a link that makes the copy touch a host file (the CVE-2019-14271 class). Nothing is executed inside the box to do it. Regular files only, openedO_NONBLOCKso a planted FIFO cannot hang the copy, with a 4 GiB cap. The host side runs the same registry guard as-v, in both directions: a copy INTO a box refuses a source that resolves onto the registry (it cannot read a peer's key or posture record into the box), and a copy OUT of a box refuses a destination that lands on the registry. The destination check follows a symlink final component to where the write would actually land, so a symlink planted in a writable directory cannot redirect the write onto a peer's posture record.kern save -o <file>applies the same destination guard.kern pause/unpausewrite only the box's own cgroup and refuse when it has none.kern attachis read-only.
- Integrity: every blob is verified against its
sha256:digest before use, which defends against a compromised or MITM registry beyond TLS. The check runs before both the vetter and the extractor and both read the same verified file, so any disagreement between them can only be interpretive, never a difference in bytes. - Layer vetting, in-process. Absolute and
..paths, device nodes, escaping hardlink and symlink targets, a 2 GiB decompression-bomb cap and an entry-count cap are rejected before anything is written. The decision reads the raw tar headers at fixed offsets, nottar -tv's locale-dependent text, which a member name containing->could otherwise desync. Because the vetter andtar -xzfare two parsers, the principle is fail closed wherever they could disagree: a path set from two sources (GNUL/Kand PAXpath=), a PAX global override, a GNU sparse or multivolume member, a base-256 size too large for au64, and any unknown typeflag are all refused. The scan requires an all-zero tail, capped so a zero flood cannot make the check itself a DoS. A legitimate image built with an exotic-but-safe construct is refused rather than extracted. The byte-level parser is fuzzed. - Isolated staging, no-follow merge: each layer extracts into a fresh staging dir, then merges into the rootfs refusing to traverse any symlink, so the cross-layer escape class is closed structurally rather than by trusting tar. Whiteouts, including opaque dirs, are applied under the same guard, and the cache and scratch dirs are mode 0700 and user-owned.
- Image file modes are preserved as-is, so an image's
/tmpkeeps its sticky1777. Stated plainly: an image shipping a world-writable system dir leaves it world-writable inside the box. It is contained, being the box's own rootfs on a 0700 host scratch, never the host, and a setuid bit there is inert because the box root isMS_NOSUID. pushpacks the rootfs with ownership normalized to uid/gid 0 and setuid/setgid bits stripped, so an untrusted base cannot smuggle a privilege bit into what you publish.
- Auth follows the standard registry-v2 challenge, so any compliant registry works, anonymously or
with
kern login. - Every request is TLS-pinned:
--proto =https,--proto-redir =httpswhere redirects are followed, a bounded--max-redirsand a--URL terminator, so a hostile registry cannot downgrade a fetch tohttp://orfile://or smuggle a--leading URL into a flag. - Credentials never touch argv. They are stored
0600in a0700dir, base64-encoded for obfuscation only (the mode is the protection), read from the terminal with echo off, and fed tocurlthrough a-K -stdin config, so no same-uid process can read them from/proc/<pid>/cmdline. Control characters are stripped so a crafted credential cannot inject a curl directive. - Realm pinning (CVE-2020-15157 class). For a Bearer challenge the stored password goes to the
advertised token realm only if that host is the registry host or a subdomain of its parent
domain; otherwise the token is fetched anonymously, with a warning. The realm host is parsed
exactly as curl dials it, userinfo and port stripped, so
realm="https://trusted:0@evil.com/"cannot masquerade as trusted, and a multi-label public suffix (co.uk) is never a trustable parent. A cross-host redirect during upload is refused.
A vgpio: profile deliberately widens the box's device surface: it binds the listed peripherals
(/dev/i2c-*, /dev/spi*, /dev/gpiochip*, camera and audio, and /sys dirs for pwm, adc, 1-wire
and leds) into the box. Only the listed devices are exposed, deny-by-default still holds for
everything else, and the source paths are canonicalized and re-checked to stay under /dev/. Two
honest limitations:
- GPIO is chip-granular, not per-line. Requesting any
pinsbinds every/dev/gpiochipN, and that character device exposes all of the controller's lines via ioctl.pins = [17]does not restrict the box to line 17; the kernel has no per-line mount boundary. The pin list is cooperative metadata, not a security boundary. --read-onlykeeps a vGPIO box's/syswritable, because LED and PWM control are writes. The root filesystem is still read-only.
Grant a vgpio: profile only to workloads you would trust with that hardware.
A vdisk: profile mounts a size-capped volume at /vdisk/<name>. Rootless it is a RAM-backed
tmpfs: the size is a real quota (ENOSPC past it) but it counts against RAM, so pair a large vdisk
with --memory; kern warns at 1 GiB and above. The mount is created inside a fresh box-owned
/vdisk tmpfs with symlinks neutralized, so a hostile image shipping /vdisk as a symlink cannot
redirect it. A disk-backed ext4-on-loop backend is used instead when kern runs privileged, configured
LO_FLAGS_AUTOCLEAR and unwound immediately on any setup failure so a half-built vdisk cannot leak a
loop device or a stray mount. iops and bandwidth limits are recognised but not yet applied, and
are reported rather than silently dropped.
--secret delivers a value as /run/secrets/<name>, mode 0400, without it landing in the image
or the environment. Three forms: NAME=value (inline, and visible in the host's ps, so prefer
a file or stdin for real secrets), NAME=- (read from kern's stdin, never in argv), and SRC[:NAME]
(a host file; a world-writable source is refused and a group-readable one warned). The name is
validated to a single path component and duplicates are rejected.
The bytes are read on the host before the fork; inside the box they are written to a RAM-backed
tmpfs, so a secret never touches the persisted overlay upper and is gone when the box exits. A
hostile image shipping /run/secrets as a symlink is neutralised, and each file is created
O_EXCL | O_NOFOLLOW inside the box-owned tmpfs so the write cannot be redirected out.
--ssh PORT runs a throwaway sshd inside the box and publishes it via the ordinary rootless
forwarder. It is for interactive box access, not a hardened bastion.
- Keys never touch the image. Without
--ssh-key, kern generates a throwaway ed25519 keypair in the owner-only runtime dir. The host key,authorized_keysand config live on the box's/runtmpfs, remounted read-only after setup. sshd is pubkey-only and dies with the box's PID 1. - Needs a group mapping, because sshd's privilege separation calls
setgroups, which a single-uid user namespace forbids. So--sshimplies the uid/gid-range mapping vianewgidmap; withoutnewuidmaplogin will not complete and kern says so. The image must shipopenssh-server. - Honest scope: the forked sshd, and the shells it spawns, run WITHOUT the box's seccomp filter and with the pre-drop capability set, because they are forked before both steps. Those caps are namespaced and largely inert against the host, but the SSH subtree is strictly more privileged than the box's main workload. Standing sshd up also runs the image's own binaries pre-seccomp, so a hostile image could ship a malicious one: that is the interactive-trust surface you opted into.
- It logs in as (namespaced) root even with
--user, since sshd is forked before the drop. That root is your own uid mapped to 0 in the box with no host privilege, but a--user-restricted box is still reachable as root over SSH. With--netthe sshd binds the host loopback directly.
- Named volumes live under
~/.local/share/kern/volumes. The name is charset-validated to a single component and the resolved path is canonicalized and confined under the volumes dir, so a planted symlink cannot redirect the bind. - Per-volume quota is real only when the box runs privileged (ext4-on-loop); otherwise it falls
back to a plain directory and kern says the quota is not enforced, never silently drops it. The
size is clamped to 64 TiB at create time and again when read back, so a hand-edited
meta.jsoncannot drive a multi-exabytemkfs. The first privileged mount seeds the fresh image from the unenforced backend, so upgrading does not hide files already written. - Network volumes (
nfs://,smb://,sshfs://) mount rootless via FUSE. Host and path are strictly validated (no shell metacharacters, control characters, or a leading-a tool would read as an option) and everything is spawned via argv, never a shell. A mount that cannot reach its server is killed after 25 s and unmounted when the box exits.sshfsusesStrictHostKeyChecking=accept-new, so an active MITM at first contact could impersonate the server: pin the host key beforehand on untrusted networks.
The watchdogs run host-side, forked before the box's unshare(CLONE_NEWPID), the only
position from which they can reliably signal the box's ns-init. An in-box process cannot reach them:
the foreground --timeout pipe is FD_CLOEXEC, severed at the workload's exec, and the target pid
comes from the trusted fork() return or the host-only registry, never from anything the box can
write. So an untrusted workload cannot forge a pid to make the host signal an arbitrary process.
The foreground watchdog pins its target with a pidfd taken while the box is alive, so a delayed
signal cannot land on a reused pid.
Known, bounded limitation: --health-action restart re-reads PID 1 from the registry and SIGKILLs
it, and during a restart gap that pid could in principle be reused by another process of the same
user before the kill lands. The window is sub-quantum and not attacker-targetable, since an
unprivileged kill only reaches same-uid processes and an in-box workload cannot create host-namespace
processes to steer the reuse. It is not a cross-tenant boundary.
The claims above are asserted by four adversarial suites in pentest/, which ask the
kernel what is true rather than asking kern to report on itself: that a published port cannot tunnel
into a host service, that --ssh does not hand out the host's shell, that kern exec does not
escape the box, that a box cannot raise its own memory.max and sees no cgroup above its own, that a
device not granted does not cross, and that a SIGKILLed supervisor does not leave a host port held.
cargo build --release
sh pentest/run-with-local-registry.sh ./target/release/kern pentest/pentest-ports.shThat wrapper serves the test image from your own loopback, so nothing here needs a registry account
or a network. Exit status is 0 only if every asserted property held; a host that cannot answer a
question reports SKIP with the reason and never counts it as a pass. Measured results, and what is
deliberately not wired into CI, are in pentest/README.md.
The code on main is what's supported; security fixes land there.