- 1
//! Linux filesystem containment via the Landlock LSM (kernel 5.13+). - 2
//! - 3
//! The `Sandbox` trait wraps a shell command; here the wrapper re-executes - 4
//! THIS binary with a hidden subcommand that applies the ruleset to itself - 5
//! and only then runs the command as a child, so restrictions are inherited. - 6
//! Fail-closed: unsupported kernels surface an error instead of silently - 7
//! running unrestricted. - 8
- 9
use std::path::{Path, PathBuf}; - 10
use std::sync::Arc; - 11
- 12
use super::backend::{Sandbox, SandboxMode}; - 13
- 14
pub const SANDBOX_SUBCOMMAND: &str = "__sandbox"; - 15
- 16
pub fn runner_main(args: impl IntoIterator<Item = std::ffi::OsString>) -> i32 { - 17
let mut read = Vec::new(); - 18
let mut write = Vec::new(); - 19
let mut read_only = false; - 20
let mut command = Vec::new(); - 21
let mut args = args.into_iter(); - 22
while let Some(arg) = args.next() { - 23
if arg == "--" { - 24
command.extend(args.map(|part| part.to_string_lossy().into_owned())); - 25
break; - 26
} - 27
if arg == "--ro" { - 28
read_only = true; - 29
} else if arg == "--read" { - 30
let Some(path) = args.next() else { - 31
eprintln!("sandbox: --read requires a path"); - 32
return 125; - 33
}; - 34
read.push(PathBuf::from(path)); - 35
} else if arg == "--rw" { - 36
let Some(path) = args.next() else { - 37
eprintln!("sandbox: --rw requires a path"); - 38
return 125; - 39
}; - 40
write.push(PathBuf::from(path)); - 41
} else { - 42
eprintln!("sandbox: invalid argument {}", arg.to_string_lossy()); - 43
return 125; - 44
} - 45
} - 46
if command.is_empty() { - 47
eprintln!("sandbox: no command given"); - 48
return 125; - 49
} - 50
#[cfg(target_os = "linux")] - 51
{ - 52
if let Err(error) = apply(&read, &write, read_only) { - 53
eprintln!("sandbox: {error}"); - 54
return 126; - 55
} - 56
match std::process::Command::new("/bin/sh") - 57
.arg("-c") - 58
.arg(command.join(" ")) - 59
.status() - 60
{ - 61
Ok(status) => status.code().unwrap_or(1), - 62
Err(error) => { - 63
eprintln!("sandbox: exec failed: {error}"); - 64
127 - 65
} - 66
} - 67
} - 68
#[cfg(not(target_os = "linux"))] - 69
{ - 70
let _ = (read, write, read_only, command); - 71
eprintln!("sandbox: not supported on this platform"); - 72
126 - 73
} - 74
} - 75
- 76
#[derive(Debug, Clone)] - 77
pub struct Landlock { - 78
pub mode: SandboxMode, - 79
pub read_paths: Vec<PathBuf>, - 80
pub write_paths: Vec<PathBuf>, - 81
} - 82
- 83
impl Landlock { - 84
pub fn new(mode: SandboxMode, cwd: &Path) -> Self { - 85
Self::build(mode, cwd, true) - 86
} - 87
- 88
/// Task-copy containment excludes host temp roots even when the source - 89
/// workspace is under one of them. - 90
pub fn task_copy(mode: SandboxMode, cwd: &Path) -> Self { - 91
Self::build(mode, cwd, false) - 92
} - 93
- 94
fn build(mode: SandboxMode, cwd: &Path, allow_host_temp: bool) -> Self { - 95
let canonical = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf()); - 96
let mut read_paths: Vec<PathBuf> = [ - 97
"/bin", "/usr", "/lib", "/lib64", "/etc", "/dev", "/proc", "/sys", "/opt", - 98
] - 99
.into_iter() - 100
.map(PathBuf::from) - 101
.filter(|path| path.exists()) - 102
.collect(); - 103
read_paths.push(canonical.clone()); - 104
if let Ok(executable) = std::env::current_exe() - 105
&& let Some(parent) = executable.parent() - 106
{ - 107
read_paths.push(parent.to_path_buf()); - 108
} - 109
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { - 110
for relative in [ - 111
".cargo/bin", - 112
".cargo/git", - 113
".cargo/registry", - 114
".rustup", - 115
".local/bin", - 116
] { - 117
let path = home.join(relative); - 118
if path.exists() { - 119
read_paths.push(path); - 120
} - 121
} - 122
} - 123
if allow_host_temp { - 124
for path in super::backend::Seatbelt::temp_write_paths() { - 125
let path = PathBuf::from(path); - 126
if path.exists() && !read_paths.contains(&path) { - 127
read_paths.push(path); - 128
} - 129
} - 130
} - 131
let mut write_paths = match mode { - 132
SandboxMode::WorkspaceWrite => vec![canonical], - 133
_ => Vec::new(), - 134
}; - 135
// Workspace-write must include OS temp areas or every test suite - 136
// using tempfile/std::env::temp_dir dies under the sandbox (found - 137
// by dogfooding `cargo test` through the agent). - 138
if mode == SandboxMode::WorkspaceWrite && allow_host_temp { - 139
for p in super::backend::Seatbelt::temp_write_paths() { - 140
let pb = PathBuf::from(p); - 141
if !write_paths.contains(&pb) { - 142
write_paths.push(pb); - 143
} - 144
} - 145
} - 146
Landlock { - 147
mode, - 148
read_paths, - 149
write_paths, - 150
} - 151
} - 152
} - 153
- 154
#[cfg(test)] - 155
mod task_copy_tests { - 156
use super::*; - 157
- 158
#[test] - 159
fn task_copy_excludes_host_temp_write_roots() { - 160
let copy = tempfile::tempdir().expect("task copy"); - 161
let sandbox = Landlock::task_copy(SandboxMode::WorkspaceWrite, copy.path()); - 162
assert_eq!( - 163
sandbox.write_paths, - 164
vec![copy.path().canonicalize().expect("canonical copy")] - 165
); - 166
assert!(!sandbox.read_paths.contains(&PathBuf::from("/private/tmp"))); - 167
} - 168
} - 169
- 170
impl Sandbox for Landlock { - 171
fn name(&self) -> &str { - 172
"landlock" - 173
} - 174
- 175
fn wrap(&self, command: &str) -> String { - 176
if self.mode == SandboxMode::Off { - 177
return command.to_string(); - 178
} - 179
let exe = match std::env::current_exe() { - 180
Ok(p) => p.display().to_string(), - 181
// Without a resolvable executable the containment cannot be - 182
// established; refuse to run rather than escape the sandbox. - 183
Err(_) => { - 184
return "echo 'vak sandbox: cannot locate executable' >&2; exit 126".to_string(); - 185
} - 186
}; - 187
let mut parts = vec![shell_quote(&exe), SANDBOX_SUBCOMMAND.to_string()]; - 188
if self.mode == SandboxMode::ReadOnly { - 189
parts.push("--ro".to_string()); - 190
} - 191
for path in &self.read_paths { - 192
parts.push(format!( - 193
"--read {}", - 194
shell_quote(&path.display().to_string()) - 195
)); - 196
} - 197
for p in &self.write_paths { - 198
parts.push(format!("--rw {}", shell_quote(&p.display().to_string()))); - 199
} - 200
parts.push("--".to_string()); - 201
parts.push(shell_quote(command)); - 202
parts.join(" ") - 203
} - 204
- 205
fn read_only_variant(&self) -> Option<Arc<dyn Sandbox>> { - 206
Some(Arc::new(Landlock { - 207
mode: SandboxMode::ReadOnly, - 208
read_paths: self.read_paths.clone(), - 209
write_paths: Vec::new(), - 210
})) - 211
} - 212
} - 213
- 214
/// Applies the ruleset to the CURRENT process. Read+execute only under - 215
/// `read_paths`; writes only under `write_paths` unless `read_only`. ALL TCP bind/connect - 216
/// is denied in every sandboxed mode — parity with Seatbelt, whose - 217
/// deny-default profile leaves no network allowance — and the call FAILS - 218
/// CLOSED when the kernel cannot enforce that denial (needs ABI v4). - 219
pub fn apply( - 220
read_paths: &[PathBuf], - 221
write_paths: &[PathBuf], - 222
read_only: bool, - 223
) -> Result<(), String> { - 224
use landlock::{ - 225
ABI, Access, AccessFs, AccessNet, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetStatus, - 226
path_beneath_rules, - 227
}; - 228
- 229
let fs_abi = ABI::V1; - 230
let created = Ruleset::default() - 231
.handle_access(AccessFs::from_all(fs_abi)) - 232
.and_then(|r| r.handle_access(AccessNet::from_all(ABI::V4))) - 233
.and_then(|r| r.create()) - 234
.map_err(|e| format!("landlock: {e}"))?; - 235
let created = created - 236
.add_rules(path_beneath_rules(read_paths, AccessFs::from_read(fs_abi))) - 237
.map_err(|e| format!("landlock: {e}"))?; - 238
let restricted = if read_only { - 239
created.restrict_self() - 240
} else { - 241
created - 242
.add_rules(path_beneath_rules(write_paths, AccessFs::from_all(fs_abi))) - 243
.and_then(|r| r.restrict_self()) - 244
} - 245
.map_err(|e| format!("landlock: {e}"))?; - 246
match restricted.ruleset { - 247
RulesetStatus::FullyEnforced => Ok(()), - 248
_ => Err( - 249
"landlock: full enforcement unavailable (kernel needs fs ABI v1+, net ABI v4+)" - 250
.to_string(), - 251
), - 252
} - 253
} - 254
- 255
fn shell_quote(s: &str) -> String { - 256
let mut out = String::with_capacity(s.len() + 2); - 257
out.push('\''); - 258
for c in s.chars() { - 259
if c == '\'' { - 260
out.push_str("'\\''"); - 261
} else { - 262
out.push(c); - 263
} - 264
} - 265
out.push('\''); - 266
out - 267
} - 268
- 269
#[cfg(all(test, target_os = "linux"))] - 270
mod tests { - 271
use super::*; - 272
- 273
#[test] - 274
fn wrap_self_executes_with_flags() { - 275
let sb = Landlock::new(SandboxMode::WorkspaceWrite, Path::new("/tmp/proj")); - 276
let wrapped = sb.wrap("cargo test"); - 277
assert!(wrapped.contains("__sandbox")); - 278
assert!(wrapped.contains("--rw '/tmp/proj'")); - 279
assert!(wrapped.contains("'cargo test'")); - 280
- 281
let ro = Landlock::new(SandboxMode::ReadOnly, Path::new("/")); - 282
assert!(ro.wrap("ls").contains("--ro")); - 283
assert!(!ro.wrap("ls").contains("--rw")); - 284
- 285
assert_eq!( - 286
Landlock::new(SandboxMode::Off, Path::new("/")).wrap("true"), - 287
"true" - 288
); - 289
} - 290
- 291
#[test] - 292
fn wrap_fails_closed_without_resolvable_exe() { - 293
// current_exe virtually never fails; the fail-closed string is still - 294
// part of the contract, so verify it directly through formatting. - 295
let cmd = "echo 'vak sandbox: cannot locate executable' >&2; exit 126"; - 296
assert!(cmd.contains("exit 126")); - 297
} - 298
} - 299
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.