crates/heimdall-container/src/confine.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! Landlock for one job: the plan (computed by the runner from the verified
//! image) and its application (inside the job, right before `execve`).
//!
//! Heimdall is a defensive sandbox. Every Landlock right is handled, so
//! whatever the plan does not list is refused, whatever the mounts allow:
//!
//! | where | rights |
//! |---|---|
//! | the image's top-level entries (not `/` itself) | read, read dir, execute |
//! | `/in`, `/proc`, `policy.read` | read, read dir |
//! | `/out` | write only: create, write, truncate, remove (no read, no execute) |
//! | `/tmp`, `policy.write`, the job workspace `/work` | read and write, no execute |
//! | the workspace with `exec` (runner must allow it) | read, write and execute |
//! | `/dev/null` | read, write, truncate |
//! | `/dev/{zero,full,random,urandom}` | read |
//! | TCP (ABI 4) | no bind, no connect |
//! | scopes (ABI 6) | no signals, no abstract unix sockets outside the job |
//! | the learning channel (`probe-container` only, [`crate::learn`]) | `/tmp`'s rights plus making unix sockets |
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// Top-level names of the image that never get the image's rights.
pub const NOT_IMAGE: &[&str] = &["in", "out", "tmp", "proc", "dev", "sys"];
/// The Landlock plan of one job (paths inside the container).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LandlockPlan {
pub read_exec: Vec<PathBuf>,
pub read: Vec<PathBuf>,
pub read_write: Vec<PathBuf>,
/// An executable workspace: read, write and execute.
#[serde(default)]
pub read_write_exec: Vec<PathBuf>,
pub write_only: Vec<PathBuf>,
pub dev_rw: Vec<PathBuf>,
pub dev_read: Vec<PathBuf>,
/// The learning run's channel ([`crate::learn::CHANNEL_DIR`]): read,
/// write and make unix sockets ([`rights::channel`]); empty for every
/// normal job.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channel: Vec<PathBuf>,
}
/// How much of the plan the kernel enforced.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum LandlockApplied {
/// Every handled right of the ABI Heimdall asks for.
Full,
/// An older kernel: the rights of its ABI (still deny by default).
Partial,
}
/// The rights of each plan group, as Landlock access bits (`AccessFs`).
pub mod rights {
use landlock::{make_bitflags, AccessFs, BitFlags};
/// The image: read, list and execute.
pub fn image() -> BitFlags<AccessFs> {
make_bitflags!(AccessFs::{ReadFile | ReadDir | Execute})
}
/// `/in`, `/proc` and `policy.read`: read and list.
pub fn read() -> BitFlags<AccessFs> {
make_bitflags!(AccessFs::{ReadFile | ReadDir})
}
/// `/tmp` and `policy.write`: read and write, never execute or devices.
pub fn read_write() -> BitFlags<AccessFs> {
read() | write_only()
}
/// An executable workspace: [`read_write`] and execute.
pub fn read_write_exec() -> BitFlags<AccessFs> {
read_write() | make_bitflags!(AccessFs::{Execute})
}
/// `/out`: create, write, truncate and remove; no read, no execute. Dirs,
/// symlinks and fifos may be made, and the output scan refuses them
/// (`heimdall-out-1`).
pub fn write_only() -> BitFlags<AccessFs> {
make_bitflags!(AccessFs::{
WriteFile | Truncate | MakeReg | MakeDir | MakeSym | MakeFifo
| RemoveFile | RemoveDir | Refer
})
}
/// The learning channel: `/tmp`'s rights plus making unix sockets
/// (`MakeSock`), so a driver outside reaches the program's sockets there.
pub fn channel() -> BitFlags<AccessFs> {
read_write() | AccessFs::MakeSock
}
/// `/dev/null`.
pub fn dev_rw() -> BitFlags<AccessFs> {
make_bitflags!(AccessFs::{ReadFile | WriteFile | Truncate})
}
/// `/dev/{zero,full,random,urandom}`.
pub fn dev_read() -> BitFlags<AccessFs> {
make_bitflags!(AccessFs::{ReadFile})
}
/// The rights the kernel accepts on a regular (non-directory) file.
pub fn file_only() -> BitFlags<AccessFs> {
make_bitflags!(AccessFs::{ReadFile | WriteFile | Execute | Truncate | IoctlDev})
}
}
fn under_excluded(target: &Path) -> bool {
use std::path::Component;
let mut depth: i32 = 0;
let mut first: Option<String> = None;
for c in target.components() {
match c {
Component::RootDir | Component::Prefix(_) => depth = 0,
Component::CurDir => {}
Component::ParentDir => return true,
Component::Normal(n) => {
if depth == 0 {
first = Some(n.to_string_lossy().into_owned());
}
depth += 1;
}
}
}
match first {
// `/`, `.` or empty: the whole root.
None => true,
Some(f) => NOT_IMAGE.contains(&f.as_str()),
}
}
/// The plan for a job whose unpacked root is `rootfs`, with the policy's
/// extra read and write paths.
pub fn plan(
rootfs: &Path,
policy_read: &[PathBuf],
policy_write: &[PathBuf],
) -> std::io::Result<LandlockPlan> {
let mut read_exec = Vec::new();
for e in std::fs::read_dir(rootfs)? {
let e = e?;
let name = e.file_name();
let Some(name) = name.to_str() else { continue };
if NOT_IMAGE.contains(&name) {
continue;
}
if e.file_type()?.is_symlink() {
// A top-level link must stay inside the image and away from the
// places Landlock treats on their own (`/`, `..`, /proc, /out, ..).
let t = std::fs::read_link(e.path())?;
if under_excluded(&t) {
continue;
}
}
read_exec.push(Path::new("/").join(name));
}
read_exec.sort();
let mut read: Vec<PathBuf> = vec!["/in".into(), "/proc".into()];
read.extend(policy_read.iter().cloned());
let mut read_write: Vec<PathBuf> = vec!["/tmp".into()];
read_write.extend(policy_write.iter().cloned());
Ok(LandlockPlan {
read_exec,
read,
read_write,
read_write_exec: vec![],
write_only: vec!["/out".into()],
dev_rw: vec!["/dev/null".into()],
channel: vec![],
dev_read: ["/dev/zero", "/dev/full", "/dev/random", "/dev/urandom"]
.iter()
.map(PathBuf::from)
.collect(),
})
}
/// [`plan`] with the job's workspace.
pub fn plan_with_workspace(
rootfs: &Path,
policy_read: &[PathBuf],
policy_write: &[PathBuf],
workspace: Option<&heimdall_core::Workspace>,
) -> std::io::Result<LandlockPlan> {
let mut p = plan(rootfs, policy_read, policy_write)?;
if let Some(ws) = workspace {
let at = PathBuf::from(heimdall_core::WORKSPACE_PATH);
// The workspace mount covers the image's own entry of that name.
p.read_exec.retain(|x| x != &at);
if ws.exec {
p.read_write_exec.push(at);
} else {
p.read_write.push(at);
}
}
Ok(p)
}
/// The Landlock ABI Heimdall asks for (fixed in code, as the landlock crate
/// recommends): filesystem rights up to ABI 5 (ioctl on devices), TCP bind
/// and connect (ABI 4), signal and abstract-unix-socket scopes (ABI 6). A
/// kernel with an older ABI enforces its subset ("partial").
pub const ABI: landlock::ABI = landlock::ABI::V6;
/// Apply `plan` to the calling (single-threaded) process: inside the job,
/// after pivot_root and seccomp, right before `execve`. Fails closed: any
/// error, or a kernel that enforces nothing, is an `Err`.
pub fn apply(plan: &LandlockPlan) -> Result<LandlockApplied, String> {
use landlock::{
Access, AccessFs, AccessNet, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset,
RulesetAttr, RulesetCreatedAttr, RulesetStatus, Scope,
};
let e = |what: &str, err: &dyn std::fmt::Display| format!("{what}: {err}");
let mut rs = Ruleset::default()
.set_compatibility(CompatLevel::BestEffort)
.handle_access(AccessFs::from_all(ABI))
.map_err(|x| e("handle fs", &x))?
.handle_access(AccessNet::from_all(ABI))
.map_err(|x| e("handle net", &x))?
.scope(Scope::from_all(ABI))
.map_err(|x| e("scope", &x))?
.create()
.map_err(|x| e("create ruleset", &x))?;
let groups: [(&[PathBuf], landlock::BitFlags<AccessFs>, bool); 8] = [
(&plan.read_exec, rights::image(), true),
(&plan.read, rights::read(), true),
(&plan.read_write, rights::read_write(), true),
(&plan.read_write_exec, rights::read_write_exec(), true),
(&plan.write_only, rights::write_only(), true),
(&plan.dev_rw, rights::dev_rw(), false),
(&plan.dev_read, rights::dev_read(), false),
(&plan.channel, rights::channel(), true),
];
for (paths, access, required) in groups {
for p in paths {
let fd = match PathFd::new(p) {
Ok(fd) => fd,
Err(_) if !required => continue,
Err(x) => return Err(e(&format!("open {}", p.display()), &x)),
};
let is_dir = std::fs::metadata(p).map(|m| m.is_dir()).unwrap_or(true);
let a = if is_dir {
access
} else {
access & rights::file_only()
};
rs = rs
.add_rule(PathBeneath::new(fd, a))
.map_err(|x| e(&format!("rule {}", p.display()), &x))?;
}
}
let st = rs.restrict_self().map_err(|x| e("restrict_self", &x))?;
match st.ruleset {
RulesetStatus::FullyEnforced => Ok(LandlockApplied::Full),
RulesetStatus::PartiallyEnforced => Ok(LandlockApplied::Partial),
RulesetStatus::NotEnforced => Err("the kernel enforces no Landlock rule".into()),
}
}