Fil‑C NixOS: a memory‑safe NixOS and finix
filnix builds Nixpkgs packages with Fil-C, so a memory error in them stops the program instead of corrupting its memory. Its NixOS is a whole system built that way: pkgs is the Fil-C package set, so systemd, PAM, D-Bus, OpenSSH and every service you enable are Fil-C builds, from the same Nixpkgs sources and NixOS modules you already know. Only the kernel, the bootloader and a short, written-down list of exceptions stay native, and the machine checks that.
The whole system
A process is either all Fil-C or all native. A Fil-C program can't load a native library, and a native program can't load a Fil-C one. So rather than swap programs one at a time, filnix swaps the package set: NixOS modules take their programs from pkgs, and pkgs is Fil-C. The exceptions are whole packages from a native Nixpkgs.
Try it
The base image is a whole Fil-C NixOS that logs demo in on the console and the serial line, with sudo; SSH takes keys only. With Nix on x86-64 Linux:
nix run github:mbrock/filnix#nixos-vm
Without Nix, each release has it as a compressed qcow2 of about 1.3 GB for QEMU, with -m 2048 or more. Its /etc/nixos is a flake pinned to the filnix revision it was built from, with the binary cache configured, so the first sudo nixos-rebuild switch downloads instead of compiling. A check boots that exact file before it ships; this is its console, after sudo filc-ps:

One module
One small module does it for both systems: lib/filc-system.nix, exported as nixosModules.default and finixModules.default through two thin wrappers. It has no per-service switches and no lists of names. The only knob is the exceptions, written as Nix values, so a typo fails evaluation:
# NixOS, with the NixOS modules of filnix's pinned Nixpkgs
nixosConfigurations.host = filnix.lib.nixosSystem {
modules = [
{ filc.native = native: { inherit (native) tailscale; }; }
./configuration.nix
];
};
# finix
finixConfigurations.host = finix.lib.finixSystem {
inherit (nixpkgs) lib;
modules = [
filnix.finixModules.default
{ filc.enable = true; filc.native = native: { inherit (native) tailscale; }; }
./configuration.nix
];
};
# nix.conf: substitute instead of compiling the Fil-C world
extra-substituters = https://filc.cachix.org
extra-trusted-public-keys = filc.cachix.org-1:8rA7kXyu1HaJuMTsAKfA9fU/+r8YtLv5KiZ5hfDNZMk=
If a service needs a fix to run as Fil-C, the fix goes in its port, where every system gets it. Every option is in the options reference.
What stays native
The built-in exceptions are data, lib/native-exceptions.nix, each with the systems that need it and why; the table in the NixOS notes is generated from it. In short: the kernel and the bootloader, Nix (whose Fil-C build can't sandbox), the static setuid wrappers, and the Rust tools that NixOS 26.05 boots and activates with. The module asserts that each exists in the native Nixpkgs, so a rename there fails evaluation instead of quietly building a Fil-C package.
Rust and Go have no Fil-C target, and nothing written in them gets in unnoticed. Evaluation stops, and says what to write instead:
error: Refusing to evaluate package 'ripgrep-15.1.0' … because it has problems:
- filc (kind "broken"): Rust has no Fil-C target. Take the native build instead, with filc.native = native: { inherit (native) ripgrep; }; in your system's configuration, or leave it out.
This only changes meta, so derivations hash the same as in the plain Fil-C set. Haskell and Zig aren't detected yet.
Proving the negative
A whole system has too many programs to list, so the checks work the other way round: anything native must be an allowed exception.
| Check | Looks at | Where |
|---|---|---|
filc-ps | every running process: it must map libpizlo, the Fil-C runtime, unless its program is in the closure of a native exception; exit 1 otherwise | installed on every machine; the VM tests run it on every node |
filc-audit | every ELF file in the system closure: Fil-C (the Fil-C loader as interpreter, libpizlo as a needed library), runtime, kernel, or native and allowed | lib/filc-audit.sh; the finix test runs it |
Here is filc-ps at the end of the NixOS VM test, after alice logged in on tty1 through the Fil-C Linux-PAM (store hashes shortened). The Nix daemon starts on its socket's first connection, so nothing native is running at all:
Fil-C 1 /nix/store/…-systemd-x86_64-unknown-linux-gnufilc0-260.4/lib/systemd/systemd
Fil-C 1215 /nix/store/…-systemd-x86_64-unknown-linux-gnufilc0-260.4/lib/systemd/systemd-journald
Fil-C 1231 /nix/store/…-systemd-x86_64-unknown-linux-gnufilc0-260.4/lib/systemd/systemd-oomd
Fil-C 1382 /nix/store/…-systemd-x86_64-unknown-linux-gnufilc0-260.4/bin/udevadm
Fil-C 2785 /nix/store/…-systemd-x86_64-unknown-linux-gnufilc0-260.4/lib/systemd/systemd-logind
Fil-C 2808 /nix/store/…-dbus-x86_64-unknown-linux-gnufilc0-1.16.2/bin/dbus-daemon
Fil-C 3507 /nix/store/…-dhcpcd-x86_64-unknown-linux-gnufilc0-10.3.1/bin/dhcpcd
Fil-C 3818 /nix/store/…-shadow-x86_64-unknown-linux-gnufilc0-4.19.4/bin/login
Fil-C 4063 /nix/store/…-openssh-x86_64-unknown-linux-gnufilc0-10.5p1/bin/sshd
Fil-C 4969 /nix/store/…-systemd-x86_64-unknown-linux-gnufilc0-260.4/lib/systemd/systemd
Fil-C 5040 /nix/store/…-bash-interactive-x86_64-unknown-linux-gnufilc0-5.3p9/bin/bash
…
15 Fil-C, 0 allowed native, 0 not allowed, 0 unreadable
The test then starts a native sleep and checks that filc-ps fails. On finix the Nix daemon runs from boot, and it is the one allowed native process:
Fil-C 1 /nix/store/…-finit-x86_64-unknown-linux-gnufilc0-4.17/bin/finit
Fil-C 2900 /nix/store/…-mdevd-x86_64-unknown-linux-gnufilc0-0.1.8.2-bin/bin/mdevd
Fil-C 3063 /nix/store/…-sysklogd-x86_64-unknown-linux-gnufilc0-2.7.2/bin/syslogd
Fil-C 4180 /nix/store/…-dhcpcd-x86_64-unknown-linux-gnufilc0-10.3.1/bin/dhcpcd
Fil-C 4434 /nix/store/…-openssh-x86_64-unknown-linux-gnufilc0-10.5p1/bin/sshd
…
native 4204 /nix/store/…-nix-2.34.8/bin/nix
And the finix audit of the whole closure, from the same run:
ELF files: 2242 (plus 7296 kernel modules)
Fil-C: 1452 (64%)
Fil-C runtime: 304
native: 486 (0 unexpected)
The 304 runtime files are libpizlo, the loader and the glibc beneath them: native code that only Fil-C programs load. The 486 native files are almost all native Nix and its dependencies, plus userborn and the static setuid wrappers.
What it took
The systemd port is Nixpkgs' own 260.4, not upstream Fil-C's 256.4, because NixOS's modules expect 260. Getting it to run as PID 1 took plain posix_spawn instead of pidfd_spawn, ENOSYS from raw syscall wrappers so systemd's fallbacks run, no SIGBUS handler, and restoring the .note.dlopen notes the compiler drops, without which the initrd lacked libkmod and loaded no modules. Fil-C's glibc and runtime needed a reviewed batch of fixes too: directory walks, brk, settimeofday and a few other calls used raw syscall instructions, which Fil-C stops a program on rather than run machine code it hasn't checked, and the runtime refused the prctl calls that set a service's capabilities. On finix, keventd stopped on a real bug that Fil-C caught: it NUL-terminated what recv returned, so a uevent that filled its 1024-byte buffer wrote one byte past the end. The NixOS userland notes list it all, with what is still open.
Because PAM, its modules and every program that loads them are Fil-C together, a login gets a real PAM session, and sshd keeps PAM: no special cases for locked passwords or missing pam_limits.
What it costs
| Where | Cost | Why |
|---|---|---|
| everywhere | 1.5–4× the CPU time of the native build, and more memory: give a machine at least 2 GiB | bounds checks and the garbage collector |
| Rust, Go, Haskell, Zig | no Fil-C build; keep them native with filc.native | Fil-C compiles C and C++ only |
| NSS | no nscd, so names resolve from /etc files only (no nss-systemd or myhostname) | the Fil-C glibc has no nscd, and nsncd is Rust |
| D-Bus | dbus-daemon instead of dbus-broker | dbus-broker's Fil-C build fails its tests on real findings |
| QEMU guest agent | none | its dependencies don't build yet |
| PHP-FPM | each worker grows to about 150–250 MB | the garbage collector keeps more memory alive |
| nginx | no Fil-C build: use lighttpd | nginx's processes share pointers through shared memory, which capabilities don't survive |
| updates | security fixes arrive when filnix updates its Nixpkgs pin, not with your channel | packages are built and cached against that pin |
And a trap is still a crash. Fil-C turns an out-of-bounds write or a use after free into a stopped process, so an attacker who can trigger one can still restart your daemon in a loop. They just can't take it over.
One Fil-C daemon on a stock NixOS
There is no module support for mixing, but a service's package option can still point at a Fil-C build:
services.tor.package = filnix.legacyPackages.x86_64-linux.pkgsFilc.tor;
That suits daemons that load nothing from the rest of the system. A Fil-C program can't load the native PAM or NSS modules, so it is not for sshd or anything that authenticates users, and nothing checks it for you.
Read the modules
The modules are short, and the wiki draws them as structure rather than text: lib/filc-system.nix, lib/native-exceptions.nix, the wrappers nixos/default.nix and finix/default.nix, and the options reference. The NixOS notes, the NixOS userland notes and the finix notes have the rest.
A research project. Fil-C stops memory corruption in C and C++; it doesn't help with logic bugs, misconfiguration or the kernel. A whole Fil-C system now boots on both NixOS and finix, but it is new: expect packages that still need porting. The build recipes are in mbrock/filnix.