Layer 0 Spec — security Family
Technical specification — Version 1.0
Family overview
The air-sys-syscall::security module exposes the kernel sandboxing primitives: seccomp-BPF (syscall filtering) and Landlock (filesystem access filtering). Both mechanisms are at the core of the Air security model (ADR-010: entitlements and capability-based sandbox).
Family scope.
Three distinct subsystems:
- seccomp-BPF: filtering the syscalls a thread is allowed to execute.
- Landlock: filesystem access filtering.
- Capabilities: already covered in the
processfamily (capget,capset).
Position in the Air security model.
The Air launcher (layer 5, ADR-010) applies the following at the startup of each application:
- Capabilities according to the entitlements.
- Landlock filters to restrict filesystem access.
- seccomp filters to restrict syscalls.
- Isolation via namespaces.
Layer 0 provides the primitives; layer 5 orchestrates them.
Cross-cutting characteristics.
-
Privileged or constrained operations. Many require
CAP_SYS_ADMINor prior activation ofno_new_privs. -
Irreversibility. Security restrictions are strictly monotonic: once applied, they cannot be weakened.
-
Layer 0 = primitives only. For seccomp-BPF, layer 0 exposes no declarative API and no filter compiler: it loads an already-formed BPF program (cf. Q4, subsection 1). The declarative
SeccompFilterAPI and its compilation live in layer 1. Likewise, the high-level Landlock helpers (combining create/add/restrict, ABI masking) belong to layer 1; layer 0 exposes only the Landlock primitives. -
No wrapper for AppArmor / SELinux. These mechanisms are managed at the distribution level.
Subsection 1: seccomp
Layer 0 scope: the primitive only, not the filter compiler
Architecture decision (Q4, validated on 2026-05-31). The initial draft of this spec placed a declarative API (
SeccompFilter,SeccompRule,SeccompAction…) compiled to BPF internally in layer 0. That is too much for layer 0: compiling a declarative filter into BPF bytecode is logic (encoding ofsock_filterinstructions, computation of jump offsets, taking the architecture into account), not a syscall wrapper. In keeping with the raison d’être of layer 0 (“abstract without hiding”, a thin facade over the kernel), layer 0 exposes only the primitive: loading into the kernel an already-compiled BPF program, supplied by the caller. The declarative API and its compiler (SeccompFilter,SeccompRule,SeccompAction,SyscallArgumentCondition,ConditionOp,SyscallNumber) are specified and implemented in layer 1, and fall outside the scope of layer 0.
Boundary (Q4, decided — confirmed by the delivered code, 2026-06-14). Layer 0 exposes only the primitive
seccomp_set_mode_filter(&SockFprog, SeccompFilterFlags)— whereSockFprogborrows a slice ofsock_filteralready compiled by the caller. Declarative compilation (filter rules → cBPFsock_filterbytecode) is logic → layer 1 (consistent with ADR-021: layer 0 does no logic). Nobpf_compiler/declarativeSeccompFiltertype in layer 0. (Closes question Q4 ofQUESTIONS-implementation.md: the merged code implements exactly this primitive.)
Types: an already-formed BPF program
These types are pure (no syscall) and live in air-sys-types::security.
#![allow(unused)]
fn main() {
/// A classic BPF filter instruction — exact layout of `struct sock_filter`.
#[repr(C)]
pub struct SockFilter {
pub code: u16,
pub jt: u8,
pub jf: u8,
pub k: u32,
}
/// Complete BPF program to load — equivalent of `struct sock_fprog`.
/// Borrows a slice of instructions owned by the caller (zero alloc).
pub struct SockFprog<'a> { /* len: u16, filter: *const SockFilter, 'a marker */ }
impl<'a> SockFprog<'a> {
/// Builds a program from a slice of instructions.
///
/// # Errors
/// `EINVAL` if the slice is empty or exceeds `BPF_MAXINSNS` (4096).
pub fn new(instructions: &'a [SockFilter]) -> Result<Self, Errno>;
}
}
The BPF program itself is produced elsewhere (layer 1 declarative compiler, or any other means) and passed here as a slice. Layer 0 does not inspect it and does not transform it: it loads it as-is.
seccomp_set_mode_filter
#![allow(unused)]
fn main() {
pub unsafe fn seccomp_set_mode_filter(
prog: &SockFprog<'_>,
flags: SeccompFilterFlags,
) -> Result<(), Errno>;
bitflags! {
pub struct SeccompFilterFlags: u32 {
const TSYNC = 1;
const LOG = 2;
const SPEC_ALLOW = 4;
const NEW_LISTENER = 8;
const TSYNC_ESRCH = 16;
const WAIT_KILLABLE_RECV = 32;
}
}
}
Loads the BPF program prog into the kernel for the current thread
(seccomp(SECCOMP_SET_MODE_FILTER, flags, prog)).
unsafe — preconditions (# Safety):
- the caller must have called
set_no_new_privs()(processfamily) or holdCAP_SYS_ADMIN; - the BPF program must allow all the syscalls actually used by the Rust runtime and by the code executed after loading, failing which the thread/process will be killed;
- the operation is irreversible and strictly monotonic (one can only tighten).
TSYNC applies the filter to all threads of the process. NEW_LISTENER returns
a notification descriptor (seccomp_unotify) — leveraged in layer 1.
EINTR propagated as-is, never any automatic retry (ADR-021 conv. 2).
seccomp_set_mode_strict
#![allow(unused)]
fn main() {
pub fn seccomp_set_mode_strict() -> Result<(), Errno>;
}
Enables strict mode (SECCOMP_SET_MODE_STRICT): only read, write,
_exit/exit_group and sigreturn remain allowed. No program to supply,
no compilation — hence the absence of unsafe. Irreversible.
Performance. Loading a filter: ~50-200 µs. Per-syscall cost afterwards: ~50-100 ns for a simple filter.
Tricky tests. A seccomp violation typically kills the thread/process; the
tests run in isolated subprocesses (fork + observing the status via
waitid), with an explicit skip if the environment does not allow no_new_privs.
prctl_no_new_privs prerequisite
Covered by the process family (prctl), recalled here as a prerequisite to seccomp
without privilege. Irreversible.
Subsection 2: Landlock
Landlock is a modern Linux mechanism for restricting filesystem access at the path level, without privileges. Available since Linux 5.13.
The API is designed around “rulesets”: sets of rules that define which paths are accessible with which permissions.
landlock_create_ruleset
#![allow(unused)]
fn main() {
pub fn landlock_create_ruleset(
handled_access: LandlockAccessFs,
) -> Result<LandlockRuleset, Errno>;
pub struct LandlockRuleset { /* owns an internal OwnedFd */ }
impl LandlockRuleset {
pub fn add_rule_path_beneath(
&mut self,
path: BorrowedFd<'_>,
allowed_access: LandlockAccessFs,
) -> Result<(), Errno>;
pub fn restrict_self(&self) -> Result<(), Errno>;
pub fn as_fd(&self) -> BorrowedFd<'_>;
}
bitflags! {
pub struct LandlockAccessFs: u64 {
const EXECUTE = 1 << 0;
const WRITE_FILE = 1 << 1;
const READ_FILE = 1 << 2;
const READ_DIR = 1 << 3;
const REMOVE_DIR = 1 << 4;
const REMOVE_FILE = 1 << 5;
const MAKE_CHAR = 1 << 6;
const MAKE_DIR = 1 << 7;
const MAKE_REG = 1 << 8;
const MAKE_SOCK = 1 << 9;
const MAKE_FIFO = 1 << 10;
const MAKE_BLOCK = 1 << 11;
const MAKE_SYM = 1 << 12;
const REFER = 1 << 13; // Landlock v2 (Linux 5.19)
const TRUNCATE = 1 << 14; // Landlock v3 (Linux 6.2)
const IOCTL_DEV = 1 << 15; // Landlock v5 (Linux 6.10)
}
}
}
Placement (Q1, validated on 2026-05-31).
LandlockRulesetis a RAII type whose methods (add_rule_path_beneath,restrict_self) andDropcall Landlock syscalls. It therefore resides inair-sys-syscall::security, and not inair-sys-types— the same rule asMappingon thememfamily side: a type that calls a syscall lives in the wrappers crate, never in the pure-types crate. Only the syscall-free types (LandlockAccessFs) remain inair-sys-types.
Preconditions.
handled_access indicates which permissions this ruleset will manage.
The available permissions depend on the kernel’s Landlock ABI version.
Decision (Q6, validated on 2026-05-31): no automatic masking in layer 0.
landlock_supported_abi()merely reports the ABI version supported by the kernel.landlock_create_ruleset/add_rule_path_beneath/restrict_selfdo not silently mask unsupported access bits: if the caller passes a bit the kernel does not know, the kernel error (EINVAL) is propagated as-is. Quietly masking would be “magic” behavior, contrary to the “abstract without hiding” principle. It is up to the caller — or to a layer 1 helper (landlock_restrict_to_paths) — to querylandlock_supported_abi()and then mask the bits according to the target ABI.
Typical usage pattern.
#![allow(unused)]
fn main() {
let mut ruleset = landlock_create_ruleset(
LandlockAccessFs::READ_FILE | LandlockAccessFs::READ_DIR | LandlockAccessFs::EXECUTE,
)?;
let usr_fd = openat(DirFd::Cwd, c"/usr", OpenFlags::PATH | OpenFlags::DIRECTORY, Mode::empty())?;
ruleset.add_rule_path_beneath(
usr_fd.as_fd(),
LandlockAccessFs::READ_FILE | LandlockAccessFs::EXECUTE,
)?;
let home_fd = openat(DirFd::Cwd, c"/home/user", OpenFlags::PATH | OpenFlags::DIRECTORY, Mode::empty())?;
ruleset.add_rule_path_beneath(
home_fd.as_fd(),
LandlockAccessFs::READ_FILE | LandlockAccessFs::READ_DIR,
)?;
set_no_new_privs()?;
ruleset.restrict_self()?;
}
Particularities.
- Irreversibility.
restrict_selfis definitive. - Stacking rulesets. Several successive rulesets add restrictions.
- NO_NEW_PRIVS prerequisite unless
CAP_SYS_ADMIN. - “path_beneath” semantics. A rule applies to a path and all its descendants.
- ABI versions. Detect via
landlock_supported_abi().
Performance.
Creation: ~10-50 µs. Adding a rule: ~5-10 µs. restrict_self: ~50-100 µs. Per filesystem-access cost afterwards: ~100-500 ns.
High-level helpers
In layer 0, only the ABI version query is exposed — a pure read primitive, without masking (cf. Q6):
#![allow(unused)]
fn main() {
/// Reports the Landlock ABI version supported by the current kernel.
pub fn landlock_supported_abi() -> Result<u32, Errno>;
}
Moved to layer 1 (Q4/Q6). The
landlock_restrict_to_paths(paths)helper — which combinescreate_ruleset+ severaladd_rule_path_beneath+restrict_selfin a single call, and which can mask the bits according to the ABI — is convenience logic, not a syscall primitive. It is therefore specified in layer 1, alongside the declarative seccomp API. Layer 0 exposes only the building blocks (landlock_create_ruleset,add_rule_path_beneath,restrict_self,landlock_supported_abi); it is typically the Air launcher, via this layer 1 helper, that will apply an application’s filesystem entitlements.
security family summary
Exposed functions:
| Category | Main functions |
|---|---|
| seccomp | seccomp_set_mode_filter, seccomp_set_mode_strict |
| Landlock | landlock_create_ruleset, LandlockRuleset::add_rule_path_beneath, LandlockRuleset::restrict_self, LandlockRuleset::as_fd, landlock_supported_abi |
Total: ~6 main public functions in layer 0. Deferred to layer 1 (outside this scope): the declarative seccomp API (SeccompFilter / SeccompRule / SeccompAction / … + BPF compiler) and the landlock_restrict_to_paths helper.
Syscalls listed but not wrapped in phase 0:
bpf: generic API for BPF programs. Advanced.perf_event_open: profiling and monitoring. Outside phase 0.keyctl,add_key,request_key: kernel keyring. To be evaluated.pkey_alloc,pkey_free,pkey_mprotect: memory protection keys. Marginal.
Distribution of types between the two crates
In air-sys-types (pure, no syscall)
SockFilter,SockFprog— already-formed BPF program.SeccompFilterFlags— seccomp loading flags.LandlockAccessFs— filesystem access bits.
In air-sys-syscall::security (RAII calling syscalls)
LandlockRuleset— owns anOwnedFd; its methods and itsDropcall syscalls. Therefore does not live inair-sys-types(decision Q1 above; same rule asMappingon thememside).
Deferred to layer 1 (outside layer 0, decision Q4)
- Declarative seccomp API:
SeccompFilter,SeccompRule,SeccompAction,SyscallArgumentCondition,ConditionOp,SyscallNumber, and the declarative compiler → BPF.
Substantive decisions that emerged in the security family
1. Layer 0 = seccomp primitive only (Q4).
Layer 0 exposes neither the construction nor the compilation of filters: it loads an already-formed BPF program (seccomp_set_mode_filter) or enables strict mode (seccomp_set_mode_strict). The convenient declarative API (which “avoids common bugs”) is real and useful — but it is logic, hence specified in layer 1, on top of this primitive.
2. High-level helpers → layer 1 (Q4/Q6).
landlock_restrict_to_paths (combining create/add/restrict + optional ABI masking) covers ~90 % of use cases, but remains convenience built on the primitives: it lives in layer 1. Layer 0 provides only the Landlock building blocks and the landlock_supported_abi query (without masking, Q6).
2 bis. Syscall-calling RAII types outside air-sys-types (Q1).
LandlockRuleset (like Mapping on the mem side) calls syscalls in its methods and its Drop; it resides in air-sys-syscall::security, never in the pure-types crate.
3. No AppArmor / SELinux API.
These frameworks are configured at the distribution level.
4. Runtime detection of the Landlock ABI version.
Allows applications to adapt without recompilation.
Document license: MPL 2.0
Status: Technical specification of the air-sys-syscall::security module (layer 0).