Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. seccomp-BPF: filtering the syscalls a thread is allowed to execute.
  2. Landlock: filesystem access filtering.
  3. Capabilities: already covered in the process family (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.

  1. Privileged or constrained operations. Many require CAP_SYS_ADMIN or prior activation of no_new_privs.

  2. Irreversibility. Security restrictions are strictly monotonic: once applied, they cannot be weakened.

  3. 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 SeccompFilter API 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.

  4. 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 of sock_filter instructions, 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) — where SockFprog borrows a slice of sock_filter already compiled by the caller. Declarative compilation (filter rules → cBPF sock_filter bytecode) is logiclayer 1 (consistent with ADR-021: layer 0 does no logic). No bpf_compiler/declarative SeccompFilter type in layer 0. (Closes question Q4 of QUESTIONS-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() (process family) or hold CAP_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). LandlockRuleset is a RAII type whose methods (add_rule_path_beneath, restrict_self) and Drop call Landlock syscalls. It therefore resides in air-sys-syscall::security, and not in air-sys-types — the same rule as Mapping on the mem family 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 in air-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_self do 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 query landlock_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.

  1. Irreversibility. restrict_self is definitive.
  2. Stacking rulesets. Several successive rulesets add restrictions.
  3. NO_NEW_PRIVS prerequisite unless CAP_SYS_ADMIN.
  4. “path_beneath” semantics. A rule applies to a path and all its descendants.
  5. 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 combines create_ruleset + several add_rule_path_beneath + restrict_self in 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:

CategoryMain functions
seccompseccomp_set_mode_filter, seccomp_set_mode_strict
Landlocklandlock_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 an OwnedFd; its methods and its Drop call syscalls. Therefore does not live in air-sys-types (decision Q1 above; same rule as Mapping on the mem side).

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).