use super::*;
use crate::trust_intercept::TrustInterceptor;
use nono::AccessMode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct InitialCapability {
pub(super) path: std::path::PathBuf,
pub(super) access: AccessMode,
pub(super) is_file: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InitialCapabilityMatch<'a> {
Sufficient(&'a InitialCapability),
Insufficient(&'a InitialCapability),
None,
}
pub(super) struct RateLimiter {
capacity: u32,
tokens: u32,
rate: u32,
last_refill: std::time::Instant,
}
impl RateLimiter {
pub(super) fn new(rate: u32, burst: u32) -> Self {
Self {
capacity: burst,
tokens: burst,
rate,
last_refill: std::time::Instant::now(),
}
}
pub(super) fn try_acquire(&mut self) -> bool {
let now = std::time::Instant::now();
let elapsed = now.duration_since(self.last_refill);
let new_tokens = (elapsed.as_millis() as u64)
.saturating_mul(self.rate as u64)
.saturating_div(1000);
if new_tokens > 0 {
self.tokens = self.capacity.min(
self.tokens
.saturating_add(u32::try_from(new_tokens).unwrap_or(u32::MAX)),
);
self.last_refill = now;
}
if self.tokens > 0 {
self.tokens -= 1;
true
} else {
false
}
}
}
fn read_tgid(tid: u32) -> u32 {
std::fs::read_to_string(format!("/proc/{}/status", tid))
.ok()
.and_then(|s| {
s.lines()
.find(|l| l.starts_with("Tgid:\t"))
.and_then(|l| l["Tgid:\t".len()..].trim().parse::<u32>().ok())
})
.unwrap_or(tid)
}
pub(super) fn handle_seccomp_notification(
notify_fd: std::os::fd::RawFd,
child: Pid,
config: &SupervisorConfig<'_>,
initial_caps: &[InitialCapability],
rate_limiter: &mut RateLimiter,
denials: &mut Vec<DenialRecord>,
mut trust_interceptor: Option<&mut TrustInterceptor>,
) -> Result<()> {
use nono::sandbox::{
classify_access_from_flags, continue_notif, deny_notif, inject_fd, notif_id_valid,
read_notif_path, read_open_how, recv_notif, resolve_notif_path, respond_notif_errno,
validate_openat2_size, SYS_OPENAT, SYS_OPENAT2,
};
let notif = recv_notif(notify_fd)?;
let path = match read_notif_path(notif.pid, notif.data.args[1]) {
Ok(raw_path) => {
match resolve_notif_path(notif.pid, notif.data.args[0], &raw_path) {
Ok(resolved) => resolved,
Err(e) => {
debug!(
"Failed to resolve dirfd-relative path '{}': {}",
raw_path.display(),
e
);
let _ = deny_notif(notify_fd, notif.id);
return Ok(());
}
}
}
Err(e) => {
debug!("Failed to read path from seccomp notification: {}", e);
let _ = deny_notif(notify_fd, notif.id);
return Ok(());
}
};
if !notif_id_valid(notify_fd, notif.id)? {
debug!("Seccomp notification expired (first TOCTOU check)");
return Ok(());
}
let access = match notif.data.nr {
SYS_OPENAT => {
classify_access_from_flags(notif.data.args[2] as i32)
}
SYS_OPENAT2 => {
let how_size = notif.data.args[3] as usize;
if !validate_openat2_size(how_size) {
debug!(
"openat2 size {} outside accepted range, denying malformed request",
how_size
);
let _ = deny_notif(notify_fd, notif.id);
return Ok(());
}
match read_open_how(notif.pid, notif.data.args[2]) {
Ok(open_how) => classify_access_from_flags(open_how.flags as i32),
Err(e) => {
warn!("Failed to read open_how struct for openat2, denying: {}", e);
let _ = deny_notif(notify_fd, notif.id);
return Ok(());
}
}
}
other => {
warn!("Unexpected syscall {} in seccomp handler, denying", other);
let _ = deny_notif(notify_fd, notif.id);
return Ok(());
}
};
let child_pid = child.as_raw() as u32;
let notifying_tgid = if notif.pid == child_pid {
child_pid
} else {
read_tgid(notif.pid)
};
let procfs_context = ProcfsAccessContext::new(notifying_tgid, Some(notif.pid));
let resolved_path = match resolve_procfs_path_for_child(&path, Some(procfs_context)) {
Ok(resolved) => resolved,
Err(e) => {
debug!("Failed to resolve procfs path '{}': {}", path.display(), e);
let _ = deny_notif(notify_fd, notif.id);
return Ok(());
}
};
let canonicalized =
std::fs::canonicalize(&resolved_path).unwrap_or_else(|_| resolved_path.clone());
let cap_check_path: std::borrow::Cow<std::path::Path> = if notifying_tgid != child_pid {
let notifying_prefix = format!("/proc/{}", notifying_tgid);
if let Ok(rel) = canonicalized.strip_prefix(¬ifying_prefix) {
let mut p = std::path::PathBuf::from(format!("/proc/{}", child_pid));
p.push(rel);
std::borrow::Cow::Owned(p)
} else {
std::borrow::Cow::Borrowed(canonicalized.as_path())
}
} else {
std::borrow::Cow::Borrowed(canonicalized.as_path())
};
let protected_root = crate::protected_paths::overlapping_protected_root(
&canonicalized,
false,
config.protected_roots,
)
.or_else(|| {
crate::protected_paths::overlapping_protected_root(
&resolved_path,
false,
config.protected_roots,
)
});
if let Some(protected_root) = protected_root {
debug!(
"Seccomp: path {} blocked by protected root {}",
canonicalized.display(),
protected_root.display()
);
record_denial(
denials,
DenialRecord {
path: canonicalized.clone(),
access,
reason: DenialReason::PolicyBlocked,
},
);
let _ = deny_notif(notify_fd, notif.id);
return Ok(());
}
match match_initial_capability(&cap_check_path, access, initial_caps) {
InitialCapabilityMatch::Insufficient(cap) => {
debug!(
"Seccomp: path {} matched initial capability {} but {} access was requested",
canonicalized.display(),
cap.path.display(),
access,
);
record_denial(
denials,
DenialRecord {
path: canonicalized.clone(),
access,
reason: DenialReason::InsufficientAccess,
},
);
let _ = deny_notif(notify_fd, notif.id);
return Ok(());
}
InitialCapabilityMatch::Sufficient(_) => {
if canonicalized.starts_with("/proc") {
match open_path_for_access(
&path,
&access,
config.protected_roots,
None,
Some(procfs_context),
) {
Ok(file) => {
if notif_id_valid(notify_fd, notif.id)? {
if let Err(e) = inject_fd(notify_fd, notif.id, file.as_raw_fd()) {
debug!(
"inject_fd failed for initial-set proc path {}: {}",
path.display(),
e
);
let _ = deny_notif(notify_fd, notif.id);
}
}
}
Err(e) => {
debug!(
"Failed to open initial-set proc path {}: {}",
path.display(),
e
);
if e.is_policy_blocked() {
record_denial(
denials,
DenialRecord {
path: canonicalized.clone(),
access,
reason: DenialReason::PolicyBlocked,
},
);
let _ = deny_notif(notify_fd, notif.id);
} else {
let _ = respond_notif_errno(notify_fd, notif.id, e.errno());
}
}
}
} else if notif_id_valid(notify_fd, notif.id)? {
if let Err(e) = continue_notif(notify_fd, notif.id) {
debug!(
"continue_notif failed for initial-set path {}: {}",
path.display(),
e
);
let _ = deny_notif(notify_fd, notif.id);
}
}
return Ok(());
}
InitialCapabilityMatch::None => {}
}
match std::fs::symlink_metadata(&path) {
Ok(_) => {}
Err(e)
if e.kind() == std::io::ErrorKind::NotFound
|| e.raw_os_error() == Some(libc::ENOTDIR) =>
{
if notif_id_valid(notify_fd, notif.id)? {
if let Err(send_err) = continue_notif(notify_fd, notif.id) {
debug!(
"continue_notif failed for missing path {}: {}",
path.display(),
send_err
);
let _ = deny_notif(notify_fd, notif.id);
}
}
return Ok(());
}
Err(_) => {}
}
if !rate_limiter.try_acquire() {
debug!("Rate limited seccomp notification for {}", path.display());
record_denial(
denials,
DenialRecord {
path: path.clone(),
access,
reason: DenialReason::RateLimited,
},
);
let _ = deny_notif(notify_fd, notif.id);
return Ok(());
}
let verified_digest: Option<String> = if let Some(trust_result) = trust_interceptor
.as_mut()
.and_then(|ti| ti.check_path(&path))
{
match trust_result {
Ok(verified) => {
debug!(
"Seccomp: instruction file {} verified (publisher: {})",
path.display(),
verified.publisher,
);
Some(verified.digest)
}
Err(reason) => {
debug!(
"Seccomp: instruction file {} failed trust verification: {}",
path.display(),
reason
);
record_denial(
denials,
DenialRecord {
path: path.clone(),
access,
reason: DenialReason::PolicyBlocked,
},
);
let _ = deny_notif(notify_fd, notif.id);
return Ok(());
}
}
} else {
None
};
let request = nono::supervisor::CapabilityRequest {
request_id: format!("seccomp-{}", unique_request_id()),
path: path.clone(),
access,
reason: Some("Sandbox intercepted file operation (seccomp-notify)".to_string()),
child_pid: child.as_raw() as u32,
session_id: config.session_id.to_string(),
};
let decision = match config.approval_backend.request_capability(&request) {
Ok(d) => {
if d.is_denied() {
record_denial(
denials,
DenialRecord {
path: path.clone(),
access,
reason: DenialReason::UserDenied,
},
);
}
d
}
Err(e) => {
warn!("Approval backend error for seccomp notification: {}", e);
record_denial(
denials,
DenialRecord {
path: path.clone(),
access,
reason: DenialReason::BackendError,
},
);
let _ = deny_notif(notify_fd, notif.id);
return Ok(());
}
};
if !notif_id_valid(notify_fd, notif.id)? {
debug!("Seccomp notification expired (second TOCTOU check)");
return Ok(());
}
if decision.is_granted() {
match open_path_for_access(
&path,
&access,
config.protected_roots,
verified_digest.as_deref(),
Some(procfs_context),
) {
Ok(file) => {
if let Err(e) = inject_fd(notify_fd, notif.id, file.as_raw_fd()) {
debug!(
"inject_fd failed for approved path {}: {}",
canonicalized.display(),
e
);
let _ = deny_notif(notify_fd, notif.id);
}
}
Err(e) => {
warn!(
"Failed to open approved path {}: {}",
canonicalized.display(),
e
);
if e.is_policy_blocked() {
let _ = deny_notif(notify_fd, notif.id);
} else {
let _ = respond_notif_errno(notify_fd, notif.id, e.errno());
}
}
}
} else {
let _ = deny_notif(notify_fd, notif.id);
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum NetworkDecision {
Allow,
Deny,
}
pub(super) fn decide_network_notification(
syscall: i32,
sockaddr: &nono::sandbox::SockaddrInfo,
config: &SupervisorConfig<'_>,
) -> NetworkDecision {
use nono::sandbox::{UnixSocketKind, SYS_BIND, SYS_CONNECT};
if sockaddr.family == libc::AF_UNIX as u16 {
match sockaddr.unix_kind {
Some(UnixSocketKind::Pathname) => {
debug!(
"Proxy seccomp: allowing AF_UNIX pathname syscall (nr={}); \
governed by Landlock fs rules",
syscall
);
return NetworkDecision::Allow;
}
Some(UnixSocketKind::Abstract) => {
debug!(
"Proxy seccomp: denying AF_UNIX abstract-namespace syscall (nr={}); \
not mediated by Landlock fs rules",
syscall
);
return NetworkDecision::Deny;
}
Some(UnixSocketKind::Unnamed) | None => {
debug!(
"Proxy seccomp: denying AF_UNIX unnamed/unclassified syscall (nr={})",
syscall
);
return NetworkDecision::Deny;
}
}
}
match syscall {
SYS_CONNECT => {
if sockaddr.is_loopback && sockaddr.port == config.proxy_port {
debug!(
"Proxy seccomp: allowing connect to loopback:{}",
sockaddr.port
);
NetworkDecision::Allow
} else {
debug!(
"Proxy seccomp: denying connect to family={} port={} loopback={}",
sockaddr.family, sockaddr.port, sockaddr.is_loopback
);
NetworkDecision::Deny
}
}
SYS_BIND => {
if config.proxy_bind_ports.contains(&sockaddr.port) {
debug!("Proxy seccomp: allowing bind on port {}", sockaddr.port);
NetworkDecision::Allow
} else {
debug!(
"Proxy seccomp: denying bind on port {} (allowed: {:?})",
sockaddr.port, config.proxy_bind_ports
);
NetworkDecision::Deny
}
}
other => {
warn!(
"Unexpected syscall {} in proxy seccomp handler, denying",
other
);
NetworkDecision::Deny
}
}
}
pub(super) fn handle_network_notification(
notify_fd: std::os::fd::RawFd,
config: &SupervisorConfig<'_>,
rate_limiter: &mut RateLimiter,
) -> nono::error::Result<()> {
use nono::sandbox::{
continue_notif, deny_notif, notif_id_valid, read_notif_sockaddr, recv_notif,
respond_notif_errno,
};
let notif = recv_notif(notify_fd)?;
if !rate_limiter.try_acquire() {
debug!("Rate limited network seccomp notification, denying");
let _ = deny_notif(notify_fd, notif.id);
return Ok(());
}
let sockaddr = match read_notif_sockaddr(notif.pid, notif.data.args[1], notif.data.args[2]) {
Ok(info) => info,
Err(e) => {
debug!("Failed to read sockaddr from seccomp notification: {}", e);
let _ = deny_notif(notify_fd, notif.id);
return Ok(());
}
};
if !notif_id_valid(notify_fd, notif.id)? {
debug!("Network seccomp notification expired (TOCTOU check)");
return Ok(());
}
match decide_network_notification(notif.data.nr, &sockaddr, config) {
NetworkDecision::Allow => {
if let Err(e) = continue_notif(notify_fd, notif.id) {
debug!("continue_notif failed for network notification: {}", e);
return deny_notif(notify_fd, notif.id);
}
}
NetworkDecision::Deny => {
respond_notif_errno(notify_fd, notif.id, libc::EACCES)?;
}
}
Ok(())
}
fn match_initial_capability<'a>(
path: &std::path::Path,
requested: AccessMode,
initial_caps: &'a [InitialCapability],
) -> InitialCapabilityMatch<'a> {
let mut best_covering: Option<&'a InitialCapability> = None;
let mut best_sufficient: Option<&'a InitialCapability> = None;
let mut best_covering_score = 0usize;
let mut best_sufficient_score = 0usize;
for cap in initial_caps {
let covers = if cap.is_file {
path == cap.path
} else {
path.starts_with(&cap.path)
};
if !covers {
continue;
}
let score = cap.path.as_os_str().len();
if score >= best_covering_score {
best_covering = Some(cap);
best_covering_score = score;
}
if cap.access.contains(requested) && score >= best_sufficient_score {
best_sufficient = Some(cap);
best_sufficient_score = score;
}
}
if let Some(cap) = best_sufficient {
InitialCapabilityMatch::Sufficient(cap)
} else if let Some(cap) = best_covering {
InitialCapabilityMatch::Insufficient(cap)
} else {
InitialCapabilityMatch::None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_rate_limiter_allows_burst() {
let mut limiter = RateLimiter::new(10, 5);
for _ in 0..5 {
assert!(limiter.try_acquire());
}
assert!(!limiter.try_acquire());
}
#[test]
fn test_rate_limiter_refills_over_time() {
let mut limiter = RateLimiter::new(10, 3);
for _ in 0..3 {
assert!(limiter.try_acquire());
}
assert!(!limiter.try_acquire());
limiter.last_refill -= std::time::Duration::from_millis(500);
assert!(limiter.try_acquire());
}
#[test]
fn test_file_capability_exact_match_only() {
let caps = vec![InitialCapability {
path: PathBuf::from("/home/user/config.json"),
access: AccessMode::Read,
is_file: true,
}];
assert!(matches!(
match_initial_capability(
&PathBuf::from("/home/user/config.json"),
AccessMode::Read,
&caps
),
InitialCapabilityMatch::Sufficient(_)
));
assert!(matches!(
match_initial_capability(
&PathBuf::from("/home/user/config.json/subpath"),
AccessMode::Read,
&caps
),
InitialCapabilityMatch::None
));
assert!(matches!(
match_initial_capability(
&PathBuf::from("/home/user/other.json"),
AccessMode::Read,
&caps
),
InitialCapabilityMatch::None
));
}
#[test]
fn test_directory_capability_allows_subpaths() {
let caps = vec![InitialCapability {
path: PathBuf::from("/home/user/project"),
access: AccessMode::Read,
is_file: false,
}];
assert!(matches!(
match_initial_capability(
&PathBuf::from("/home/user/project"),
AccessMode::Read,
&caps
),
InitialCapabilityMatch::Sufficient(_)
));
assert!(matches!(
match_initial_capability(
&PathBuf::from("/home/user/project/src/main.rs"),
AccessMode::Read,
&caps
),
InitialCapabilityMatch::Sufficient(_)
));
assert!(matches!(
match_initial_capability(&PathBuf::from("/home/user/other"), AccessMode::Read, &caps),
InitialCapabilityMatch::None
));
}
#[test]
fn test_file_capability_does_not_authorize_fake_subpath() {
let caps = vec![InitialCapability {
path: PathBuf::from("/foo/bar"),
access: AccessMode::Read,
is_file: true,
}];
assert!(matches!(
match_initial_capability(&PathBuf::from("/foo/bar"), AccessMode::Read, &caps),
InitialCapabilityMatch::Sufficient(_)
));
assert!(matches!(
match_initial_capability(&PathBuf::from("/foo/bar/subpath"), AccessMode::Read, &caps),
InitialCapabilityMatch::None
));
assert!(matches!(
match_initial_capability(
&PathBuf::from("/foo/bar/deep/nested/path"),
AccessMode::Read,
&caps
),
InitialCapabilityMatch::None
));
}
#[test]
fn test_mixed_file_and_directory_capabilities() {
let caps = vec![
InitialCapability {
path: PathBuf::from("/etc/passwd"),
access: AccessMode::Read,
is_file: true,
},
InitialCapability {
path: PathBuf::from("/home/user/project"),
access: AccessMode::Read,
is_file: false,
},
];
assert!(matches!(
match_initial_capability(&PathBuf::from("/etc/passwd"), AccessMode::Read, &caps),
InitialCapabilityMatch::Sufficient(_)
));
assert!(matches!(
match_initial_capability(&PathBuf::from("/etc/passwd/fake"), AccessMode::Read, &caps),
InitialCapabilityMatch::None
));
assert!(matches!(
match_initial_capability(
&PathBuf::from("/home/user/project"),
AccessMode::Read,
&caps
),
InitialCapabilityMatch::Sufficient(_)
));
assert!(matches!(
match_initial_capability(
&PathBuf::from("/home/user/project/src/lib.rs"),
AccessMode::Read,
&caps
),
InitialCapabilityMatch::Sufficient(_)
));
}
#[test]
fn test_directory_capability_reports_insufficient_access() {
let caps = vec![InitialCapability {
path: PathBuf::from("/home/user/project"),
access: AccessMode::Read,
is_file: false,
}];
assert!(matches!(
match_initial_capability(
&PathBuf::from("/home/user/project/output.txt"),
AccessMode::Write,
&caps
),
InitialCapabilityMatch::Insufficient(_)
));
}
mod network_decision {
use super::super::{decide_network_notification, NetworkDecision, SupervisorConfig};
use nix::libc;
use nono::sandbox::{SockaddrInfo, UnixSocketKind, SYS_BIND, SYS_CONNECT};
use nono::supervisor::{ApprovalDecision, CapabilityRequest};
use nono::ApprovalBackend;
struct DenyAllBackend;
impl ApprovalBackend for DenyAllBackend {
fn request_capability(
&self,
_req: &CapabilityRequest,
) -> nono::Result<ApprovalDecision> {
Ok(ApprovalDecision::Denied {
reason: "test".to_string(),
})
}
fn backend_name(&self) -> &str {
"deny-all-test"
}
}
fn make_config<'a>(
backend: &'a DenyAllBackend,
proxy_port: u16,
proxy_bind_ports: Vec<u16>,
) -> SupervisorConfig<'a> {
SupervisorConfig {
protected_roots: &[],
approval_backend: backend,
session_id: "test-net-decision",
attach_initial_client: false,
detach_sequence: None,
open_url_origins: &[],
open_url_allow_localhost: false,
audit_recorder: None,
allow_launch_services_active: false,
proxy_port,
proxy_bind_ports,
}
}
fn unix_pathname() -> SockaddrInfo {
SockaddrInfo {
family: libc::AF_UNIX as u16,
port: 0,
is_loopback: true,
unix_kind: Some(UnixSocketKind::Pathname),
}
}
fn unix_abstract() -> SockaddrInfo {
SockaddrInfo {
family: libc::AF_UNIX as u16,
port: 0,
is_loopback: true,
unix_kind: Some(UnixSocketKind::Abstract),
}
}
fn unix_unnamed() -> SockaddrInfo {
SockaddrInfo {
family: libc::AF_UNIX as u16,
port: 0,
is_loopback: true,
unix_kind: Some(UnixSocketKind::Unnamed),
}
}
fn inet_loopback(port: u16) -> SockaddrInfo {
SockaddrInfo {
family: libc::AF_INET as u16,
port,
is_loopback: true,
unix_kind: None,
}
}
fn inet_external(port: u16) -> SockaddrInfo {
SockaddrInfo {
family: libc::AF_INET as u16,
port,
is_loopback: false,
unix_kind: None,
}
}
#[test]
fn af_unix_pathname_bind_is_allowed() {
let backend = DenyAllBackend;
let config = make_config(&backend, 0, Vec::new());
assert_eq!(
decide_network_notification(SYS_BIND, &unix_pathname(), &config),
NetworkDecision::Allow,
"pathname AF_UNIX bind must be allowed so Landlock fs rules govern access"
);
}
#[test]
fn af_unix_pathname_connect_is_allowed() {
let backend = DenyAllBackend;
let config = make_config(&backend, 8080, Vec::new());
assert_eq!(
decide_network_notification(SYS_CONNECT, &unix_pathname(), &config),
NetworkDecision::Allow,
"pathname AF_UNIX connect must be allowed independent of proxy_port"
);
}
#[test]
fn af_unix_abstract_is_denied() {
let backend = DenyAllBackend;
let config = make_config(&backend, 0, Vec::new());
assert_eq!(
decide_network_notification(SYS_BIND, &unix_abstract(), &config),
NetworkDecision::Deny,
"abstract AF_UNIX must be denied — Landlock fs rules do not reach it"
);
assert_eq!(
decide_network_notification(SYS_CONNECT, &unix_abstract(), &config),
NetworkDecision::Deny,
);
}
#[test]
fn af_unix_unnamed_is_denied() {
let backend = DenyAllBackend;
let config = make_config(&backend, 0, Vec::new());
assert_eq!(
decide_network_notification(SYS_BIND, &unix_unnamed(), &config),
NetworkDecision::Deny
);
}
#[test]
fn af_inet_connect_to_external_host_denied() {
let backend = DenyAllBackend;
let config = make_config(&backend, 8080, Vec::new());
assert_eq!(
decide_network_notification(SYS_CONNECT, &inet_external(8080), &config),
NetworkDecision::Deny
);
}
#[test]
fn af_inet_bind_on_disallowed_port_denied() {
let backend = DenyAllBackend;
let config = make_config(&backend, 0, vec![3000]);
assert_eq!(
decide_network_notification(SYS_BIND, &inet_loopback(4000), &config),
NetworkDecision::Deny
);
}
}
}