diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index be492b4..618abc2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -97,7 +97,7 @@ jobs: run: rustup component add llvm-tools-preview - name: Run tests with coverage - run: cargo llvm-cov --lcov --output-path lcov.info + run: cargo llvm-cov --workspace --lcov --output-path lcov.info - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/Cargo.lock b/Cargo.lock index e9cefc1..abddc32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -441,6 +441,32 @@ dependencies = [ "encoding_rs", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "equivalent" version = "1.0.2" @@ -799,6 +825,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "landlock" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affe8b77dce5b172f8e290bd801b12832a77cd1942d1ea98259916e89d5829d6" +dependencies = [ + "enumflags2", + "libc", + "thiserror 2.0.18", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1163,6 +1200,22 @@ dependencies = [ "thiserror 2.0.18", "tree-sitter", "tree-sitter-bash", + "which", +] + +[[package]] +name = "runok-linux-sandbox" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "landlock", + "libc", + "rstest", + "seccompiler", + "serde", + "serde_json", + "thiserror 2.0.18", ] [[package]] @@ -1222,6 +1275,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "seccompiler" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" +dependencies = [ + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -1754,6 +1816,17 @@ dependencies = [ "semver", ] +[[package]] +name = "which" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +dependencies = [ + "env_home", + "rustix", + "winsafe", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1852,6 +1925,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 45e8d54..0c2c1fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,7 @@ +[workspace] +members = [".", "crates/runok-linux-sandbox"] +default-members = ["."] + [package] name = "runok" version = "0.1.0" @@ -18,6 +22,7 @@ shlex = "=1.3.0" thiserror = "=2.0.18" tree-sitter = "=0.25.10" tree-sitter-bash = "=0.25.1" +which = "=8.0.0" [dev-dependencies] indoc = "=2.0.7" diff --git a/crates/runok-linux-sandbox/Cargo.toml b/crates/runok-linux-sandbox/Cargo.toml new file mode 100644 index 0000000..3fa3440 --- /dev/null +++ b/crates/runok-linux-sandbox/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "runok-linux-sandbox" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "=1.0.101" +clap = { version = "=4.5.58", features = ["derive"] } +landlock = "=0.4.3" +libc = "=0.2.182" +seccompiler = "=0.5.0" +serde = { version = "=1.0.228", features = ["derive"] } +serde_json = "=1.0.149" +thiserror = "=2.0.18" + +[dev-dependencies] +rstest = "=0.26.1" + +[lints.clippy] +allow_attributes = "warn" +allow_attributes_without_reason = "warn" +disallowed_macros = "deny" +expect_used = "deny" +panic = "deny" +unwrap_used = "deny" diff --git a/crates/runok-linux-sandbox/src/bwrap.rs b/crates/runok-linux-sandbox/src/bwrap.rs new file mode 100644 index 0000000..b879710 --- /dev/null +++ b/crates/runok-linux-sandbox/src/bwrap.rs @@ -0,0 +1,337 @@ +use std::path::Path; + +use crate::policy::SandboxPolicy; + +/// Build bubblewrap command-line arguments for namespace isolation. +/// +/// The bubblewrap arguments set up: +/// - Bind-mount the root filesystem read-only +/// - Bind-mount writable roots as read-write +/// - Re-bind read-only subpaths as read-only (overriding writable) +/// - Bind-mount /proc, /dev, /tmp as needed +/// - Set working directory +/// - Execute the helper binary itself with --apply-sandbox-then-exec for stage 2 +pub fn build_bwrap_args( + policy: &SandboxPolicy, + cwd: &Path, + helper_binary: &Path, + policy_json: &str, + command: &[String], +) -> Vec { + let mut args = Vec::new(); + + // Bind-mount root filesystem read-only + args.extend(["--ro-bind".to_string(), "/".to_string(), "/".to_string()]); + + // Mount /proc (needed by many programs) + args.extend(["--proc".to_string(), "/proc".to_string()]); + + // Mount /dev (basic device nodes) + args.extend(["--dev".to_string(), "/dev".to_string()]); + + // Bind-mount writable roots as read-write + for root in &policy.writable_roots { + let path = root.to_string_lossy().to_string(); + args.extend(["--bind".to_string(), path.clone(), path]); + } + + // Re-bind read-only subpaths (overrides writable mounts) + for subpath in &policy.read_only_subpaths { + let path_str = subpath.to_string_lossy().to_string(); + // Skip glob patterns - bubblewrap operates on actual paths + if path_str.contains('*') || path_str.contains('?') { + continue; + } + // Only bind if the path exists + if subpath.exists() { + args.extend(["--ro-bind".to_string(), path_str.clone(), path_str]); + } + } + + // /tmp should be writable (tmpfs) unless a writable root is /tmp itself, + // a parent of /tmp (e.g. "/"), or a child under /tmp (e.g. "/tmp/myproject"). + // In the child case, --tmpfs /tmp would mount over the writable bind and hide it. + let tmp = std::path::Path::new("/tmp"); + let tmp_overlaps_writable = policy + .writable_roots + .iter() + .any(|r| tmp.starts_with(r) || r.starts_with(tmp)); + if !tmp_overlaps_writable { + args.extend(["--tmpfs".to_string(), "/tmp".to_string()]); + } + + // Unshare all namespaces + args.push("--unshare-all".to_string()); + + // Share network namespace if network is allowed + if policy.network_allowed { + args.push("--share-net".to_string()); + } + + // Die with parent + args.push("--die-with-parent".to_string()); + + // Set working directory + args.extend(["--chdir".to_string(), cwd.to_string_lossy().to_string()]); + + // Execute the helper binary in stage 2 mode + args.push("--".to_string()); + args.push(helper_binary.to_string_lossy().to_string()); + args.push("--apply-sandbox-then-exec".to_string()); + args.push("--policy".to_string()); + args.push(policy_json.to_string()); + args.push("--cwd".to_string()); + args.push(cwd.to_string_lossy().to_string()); + args.push("--".to_string()); + args.extend_from_slice(command); + + args +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::{fixture, rstest}; + use std::path::PathBuf; + + #[fixture] + fn test_policy() -> SandboxPolicy { + SandboxPolicy { + writable_roots: vec![PathBuf::from("/home/user/project")], + read_only_subpaths: vec![PathBuf::from("/home/user/project/.git")], + network_allowed: false, + } + } + + #[rstest] + fn bwrap_args_contain_ro_bind_root(test_policy: SandboxPolicy) { + let policy = test_policy; + let args = build_bwrap_args( + &policy, + Path::new("/home/user/project"), + Path::new("/usr/bin/runok-linux-sandbox"), + "{}", + &["ls".to_string()], + ); + assert!( + args.windows(3).any(|w| w == ["--ro-bind", "/", "/"]), + "should have --ro-bind / /" + ); + } + + #[rstest] + fn bwrap_args_contain_writable_bind(test_policy: SandboxPolicy) { + let args = build_bwrap_args( + &test_policy, + Path::new("/home/user/project"), + Path::new("/usr/bin/runok-linux-sandbox"), + "{}", + &["ls".to_string()], + ); + assert!( + args.windows(3) + .any(|w| w == ["--bind", "/home/user/project", "/home/user/project"]), + "should have --bind for writable root" + ); + } + + #[rstest] + fn bwrap_args_contain_unshare_all(test_policy: SandboxPolicy) { + let args = build_bwrap_args( + &test_policy, + Path::new("/home/user/project"), + Path::new("/usr/bin/runok-linux-sandbox"), + "{}", + &["ls".to_string()], + ); + assert!( + args.iter().any(|a| a == "--unshare-all"), + "should have --unshare-all" + ); + } + + #[rstest] + #[case::network_blocked(false, false)] + #[case::network_allowed(true, true)] + fn bwrap_args_share_net(#[case] network_allowed: bool, #[case] should_have_share_net: bool) { + let policy = SandboxPolicy { + writable_roots: vec![PathBuf::from("/tmp")], + read_only_subpaths: vec![], + network_allowed, + }; + let args = build_bwrap_args( + &policy, + Path::new("/tmp"), + Path::new("/usr/bin/runok-linux-sandbox"), + "{}", + &["ls".to_string()], + ); + assert_eq!( + args.iter().any(|a| a == "--share-net"), + should_have_share_net + ); + } + + #[rstest] + fn bwrap_args_contain_stage2_flag(test_policy: SandboxPolicy) { + let args = build_bwrap_args( + &test_policy, + Path::new("/home/user/project"), + Path::new("/usr/bin/runok-linux-sandbox"), + "{}", + &["git".to_string(), "status".to_string()], + ); + assert!( + args.iter().any(|a| a == "--apply-sandbox-then-exec"), + "should contain --apply-sandbox-then-exec for stage 2" + ); + } + + #[rstest] + fn bwrap_args_contain_command(test_policy: SandboxPolicy) { + let args = build_bwrap_args( + &test_policy, + Path::new("/home/user/project"), + Path::new("/usr/bin/runok-linux-sandbox"), + "{}", + &["git".to_string(), "status".to_string()], + ); + // The command should appear after the last "--" + let last_separator = args + .iter() + .rposition(|a| a == "--") + .expect("should have -- separator"); + assert_eq!(args[last_separator + 1], "git"); + assert_eq!(args[last_separator + 2], "status"); + } + + #[rstest] + fn bwrap_args_contain_chdir(test_policy: SandboxPolicy) { + let args = build_bwrap_args( + &test_policy, + Path::new("/home/user/project"), + Path::new("/usr/bin/runok-linux-sandbox"), + "{}", + &["ls".to_string()], + ); + assert!( + args.windows(2) + .any(|w| w == ["--chdir", "/home/user/project"]), + "should have --chdir" + ); + } + + #[rstest] + fn bwrap_args_tmpfs_when_tmp_not_writable() { + let policy = SandboxPolicy { + writable_roots: vec![PathBuf::from("/home/user")], + read_only_subpaths: vec![], + network_allowed: true, + }; + let args = build_bwrap_args( + &policy, + Path::new("/home/user"), + Path::new("/usr/bin/runok-linux-sandbox"), + "{}", + &["ls".to_string()], + ); + assert!( + args.windows(2).any(|w| w == ["--tmpfs", "/tmp"]), + "should have --tmpfs /tmp when /tmp is not in writable_roots" + ); + } + + #[rstest] + fn bwrap_args_no_tmpfs_when_tmp_is_writable() { + let policy = SandboxPolicy { + writable_roots: vec![PathBuf::from("/tmp")], + read_only_subpaths: vec![], + network_allowed: true, + }; + let args = build_bwrap_args( + &policy, + Path::new("/tmp"), + Path::new("/usr/bin/runok-linux-sandbox"), + "{}", + &["ls".to_string()], + ); + assert!( + !args.windows(2).any(|w| w == ["--tmpfs", "/tmp"]), + "should NOT have --tmpfs /tmp when /tmp is in writable_roots" + ); + } + + #[rstest] + fn bwrap_args_no_tmpfs_when_parent_is_writable() { + let policy = SandboxPolicy { + writable_roots: vec![PathBuf::from("/")], + read_only_subpaths: vec![], + network_allowed: true, + }; + let args = build_bwrap_args( + &policy, + Path::new("/"), + Path::new("/usr/bin/runok-linux-sandbox"), + "{}", + &["ls".to_string()], + ); + assert!( + !args.windows(2).any(|w| w == ["--tmpfs", "/tmp"]), + "should NOT have --tmpfs /tmp when a parent of /tmp is in writable_roots" + ); + } + + #[rstest] + fn bwrap_args_no_tmpfs_when_child_of_tmp_is_writable() { + let policy = SandboxPolicy { + writable_roots: vec![PathBuf::from("/tmp/myproject")], + read_only_subpaths: vec![], + network_allowed: true, + }; + let args = build_bwrap_args( + &policy, + Path::new("/tmp/myproject"), + Path::new("/usr/bin/runok-linux-sandbox"), + "{}", + &["ls".to_string()], + ); + assert!( + !args.windows(2).any(|w| w == ["--tmpfs", "/tmp"]), + "should NOT have --tmpfs /tmp when a child of /tmp is in writable_roots" + ); + } + + #[rstest] + fn bwrap_args_proc_and_dev(test_policy: SandboxPolicy) { + let args = build_bwrap_args( + &test_policy, + Path::new("/home/user/project"), + Path::new("/usr/bin/runok-linux-sandbox"), + "{}", + &["ls".to_string()], + ); + assert!( + args.windows(2).any(|w| w == ["--proc", "/proc"]), + "should have --proc /proc" + ); + assert!( + args.windows(2).any(|w| w == ["--dev", "/dev"]), + "should have --dev /dev" + ); + } + + #[rstest] + fn bwrap_args_die_with_parent(test_policy: SandboxPolicy) { + let args = build_bwrap_args( + &test_policy, + Path::new("/home/user/project"), + Path::new("/usr/bin/runok-linux-sandbox"), + "{}", + &["ls".to_string()], + ); + assert!( + args.iter().any(|a| a == "--die-with-parent"), + "should have --die-with-parent" + ); + } +} diff --git a/crates/runok-linux-sandbox/src/error.rs b/crates/runok-linux-sandbox/src/error.rs new file mode 100644 index 0000000..bb0bf15 --- /dev/null +++ b/crates/runok-linux-sandbox/src/error.rs @@ -0,0 +1,13 @@ +#[derive(Debug, thiserror::Error)] +pub enum SandboxError { + #[error("landlock restriction failed: {0}")] + Landlock(String), + #[error("seccomp filter failed: {0}")] + Seccomp(String), + #[error("bubblewrap execution failed: {0}")] + Bubblewrap(String), + #[error("command execution failed: {0}")] + Exec(#[from] std::io::Error), + #[error("invalid policy: {0}")] + InvalidPolicy(String), +} diff --git a/crates/runok-linux-sandbox/src/landlock_sandbox.rs b/crates/runok-linux-sandbox/src/landlock_sandbox.rs new file mode 100644 index 0000000..204a8ff --- /dev/null +++ b/crates/runok-linux-sandbox/src/landlock_sandbox.rs @@ -0,0 +1,173 @@ +use landlock::{ + ABI, Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr, + RulesetCreatedAttr, +}; + +use crate::error::SandboxError; +use crate::policy::SandboxPolicy; + +/// Apply landlock restrictions based on the sandbox policy. +/// +/// Strategy: +/// 1. Handle all filesystem access types for ABI V5 (BestEffort for newer features) +/// 2. Default: entire filesystem is governed by the ruleset (no rule = no access) +/// 3. Add read-only access to root `/` as the baseline +/// 4. Add read-write access to writable_roots +/// 5. Always allow read-write to /dev/null (needed by many programs) +/// +/// Note: Landlock rules within a single ruleset are additive (union of permissions). +/// Read-only enforcement on subpaths of writable_roots is handled by bubblewrap's +/// mount ordering (`--ro-bind` after `--bind`), not by landlock. +pub fn apply_landlock(policy: &SandboxPolicy) -> Result<(), SandboxError> { + let abi = ABI::V5; + let access_rw = AccessFs::from_all(abi); + let access_ro = AccessFs::from_read(abi); + + let mut ruleset = Ruleset::default() + .set_compatibility(CompatLevel::BestEffort) + .handle_access(access_rw) + .map_err(|e| SandboxError::Landlock(format!("handle_access failed: {e}")))? + .create() + .map_err(|e| SandboxError::Landlock(format!("create ruleset failed: {e}")))? + .set_no_new_privs(true); + + // Root filesystem is read-only by default + ruleset = add_path_rule(ruleset, std::path::Path::new("/"), access_ro)?; + + // /dev/null must be read-write for many programs + ruleset = add_path_rule(ruleset, std::path::Path::new("/dev/null"), access_rw)?; + + // /dev/zero, /dev/urandom, /dev/random are commonly needed + for dev_path in &["/dev/zero", "/dev/urandom", "/dev/random"] { + ruleset = add_path_rule_if_exists(ruleset, std::path::Path::new(dev_path), access_ro)?; + } + + // /tmp is often needed for temporary files; only add if not already covered + // by a writable root. Skip when /tmp itself, a parent (e.g. "/"), or a child + // under /tmp (e.g. "/tmp/myproject") is a writable root. Adding /tmp write + // access when only a child is writable would grant overly broad permissions. + let tmp = std::path::Path::new("/tmp"); + let tmp_overlaps_writable = policy + .writable_roots + .iter() + .any(|r| tmp.starts_with(r) || r.starts_with(tmp)); + if !tmp_overlaps_writable { + ruleset = add_path_rule_if_exists(ruleset, std::path::Path::new("/tmp"), access_rw)?; + } + + // Add writable roots + for root in &policy.writable_roots { + ruleset = add_path_rule(ruleset, root, access_rw)?; + } + + ruleset + .restrict_self() + .map_err(|e| SandboxError::Landlock(format!("restrict_self failed: {e}")))?; + + Ok(()) +} + +fn add_path_rule( + ruleset: landlock::RulesetCreated, + path: &std::path::Path, + access: landlock::BitFlags, +) -> Result { + let fd = PathFd::new(path).map_err(|e| { + SandboxError::Landlock(format!("cannot open path '{}': {e}", path.display())) + })?; + ruleset.add_rule(PathBeneath::new(fd, access)).map_err(|e| { + SandboxError::Landlock(format!("add_rule for '{}' failed: {e}", path.display())) + }) +} + +fn add_path_rule_if_exists( + ruleset: landlock::RulesetCreated, + path: &std::path::Path, + access: landlock::BitFlags, +) -> Result { + if !path.exists() { + return Ok(ruleset); + } + add_path_rule(ruleset, path, access) +} + +/// Build the list of landlock rules for inspection/testing purposes. +/// Returns pairs of (path, is_writable). +/// +/// Note: read_only_subpaths are not included because landlock rules are +/// additive. Read-only enforcement is handled by bubblewrap's mount ordering. +pub fn build_landlock_rules(policy: &SandboxPolicy) -> Vec<(String, bool)> { + let mut rules = Vec::new(); + + // Root is read-only + rules.push(("/".to_string(), false)); + + // /dev/null is read-write + rules.push(("/dev/null".to_string(), true)); + + // Writable roots + for root in &policy.writable_roots { + rules.push((root.to_string_lossy().to_string(), true)); + } + + rules +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use std::path::PathBuf; + + #[rstest] + #[case::basic_policy( + SandboxPolicy { + writable_roots: vec![PathBuf::from("/tmp"), PathBuf::from("/home/user/project")], + read_only_subpaths: vec![PathBuf::from("/home/user/project/.git")], + network_allowed: true, + }, + vec![ + ("/", false), + ("/dev/null", true), + ("/tmp", true), + ("/home/user/project", true), + ] + )] + #[case::no_writable( + SandboxPolicy { + writable_roots: vec![], + read_only_subpaths: vec![], + network_allowed: false, + }, + vec![ + ("/", false), + ("/dev/null", true), + ] + )] + #[case::multiple_writable( + SandboxPolicy { + writable_roots: vec![ + PathBuf::from("/workspace"), + PathBuf::from("/var/tmp"), + ], + read_only_subpaths: vec![ + PathBuf::from("/workspace/.git"), + ], + network_allowed: false, + }, + vec![ + ("/", false), + ("/dev/null", true), + ("/workspace", true), + ("/var/tmp", true), + ] + )] + fn build_rules_from_policy(#[case] policy: SandboxPolicy, #[case] expected: Vec<(&str, bool)>) { + let rules = build_landlock_rules(&policy); + let expected_owned: Vec<(String, bool)> = expected + .into_iter() + .map(|(p, w)| (p.to_string(), w)) + .collect(); + assert_eq!(rules, expected_owned); + } +} diff --git a/crates/runok-linux-sandbox/src/lib.rs b/crates/runok-linux-sandbox/src/lib.rs new file mode 100644 index 0000000..4c6a779 --- /dev/null +++ b/crates/runok-linux-sandbox/src/lib.rs @@ -0,0 +1,5 @@ +pub mod bwrap; +pub mod error; +pub mod landlock_sandbox; +pub mod policy; +pub mod seccomp_sandbox; diff --git a/crates/runok-linux-sandbox/src/main.rs b/crates/runok-linux-sandbox/src/main.rs new file mode 100644 index 0000000..5a5caff --- /dev/null +++ b/crates/runok-linux-sandbox/src/main.rs @@ -0,0 +1,205 @@ +mod bwrap; +mod error; +mod landlock_sandbox; +mod policy; +mod seccomp_sandbox; + +use std::ffi::CString; +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::Parser; + +use error::SandboxError; +use policy::SandboxPolicy; + +/// Linux sandbox helper for runok. +/// +/// Applies bubblewrap namespace isolation, landlock filesystem restrictions, +/// and seccomp network filtering before executing a command. +/// +/// This binary operates in two stages: +/// - Stage 1 (default): Sets up bubblewrap and re-invokes itself inside the sandbox +/// - Stage 2 (--apply-sandbox-then-exec): Applies landlock + seccomp, then execvp +#[derive(Parser, Debug)] +#[command(name = "runok-linux-sandbox")] +struct Args { + /// Sandbox policy as JSON string. + #[arg(long)] + policy: String, + + /// Working directory for the sandboxed command. + #[arg(long)] + cwd: PathBuf, + + /// Stage 2 mode: apply landlock + seccomp, then exec the command. + /// Used internally when re-invoked inside bubblewrap. + #[arg(long)] + apply_sandbox_then_exec: bool, + + /// The command and its arguments to execute. + #[arg(last = true, required = true)] + command: Vec, +} + +fn main() -> ExitCode { + let args = Args::parse(); + + let policy: SandboxPolicy = match serde_json::from_str(&args.policy) { + Ok(p) => p, + Err(e) => { + eprintln!("runok-linux-sandbox: invalid policy JSON: {e}"); + return ExitCode::from(1); + } + }; + + if args.apply_sandbox_then_exec { + // Stage 2: apply landlock + seccomp, then exec + if let Err(e) = run_stage2(&policy, &args.command) { + eprintln!("runok-linux-sandbox: {e}"); + return ExitCode::from(1); + } + // exec_command never returns on success + unreachable!() + } + + // Stage 1: set up bubblewrap and re-invoke + match run_stage1(&policy, &args.cwd, &args.policy, &args.command) { + Ok(code) => ExitCode::from(code as u8), + Err(e) => { + eprintln!("runok-linux-sandbox: {e}"); + ExitCode::from(1) + } + } +} + +/// Stage 1: Build bubblewrap arguments and execute bwrap. +fn run_stage1( + policy: &SandboxPolicy, + cwd: &std::path::Path, + policy_json: &str, + command: &[String], +) -> Result { + let helper_binary = std::env::current_exe().map_err(SandboxError::Exec)?; + + let bwrap_args = bwrap::build_bwrap_args(policy, cwd, &helper_binary, policy_json, command); + + let status = std::process::Command::new("bwrap") + .args(&bwrap_args) + .status() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + SandboxError::Bubblewrap( + "bubblewrap (bwrap) not found. Install it with your package manager." + .to_string(), + ) + } else { + SandboxError::Exec(e) + } + })?; + + Ok(exit_code_from_status(status)) +} + +/// Stage 2: Apply landlock and seccomp restrictions, then exec the command. +fn run_stage2(policy: &SandboxPolicy, command: &[String]) -> Result<(), SandboxError> { + // Apply landlock filesystem restrictions + landlock_sandbox::apply_landlock(policy)?; + + // Apply seccomp network restrictions + seccomp_sandbox::apply_seccomp(policy.network_allowed)?; + + // exec the target command + exec_command(command) +} + +/// Extract the exit code from a process exit status. +/// +/// If the process was killed by a signal, return 128 + signal number +/// (standard shell convention). +fn exit_code_from_status(status: std::process::ExitStatus) -> i32 { + if let Some(code) = status.code() { + return code; + } + + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return 128 + signal; + } + } + + 1 +} + +/// Replace the current process with the given command via execvp. +fn exec_command(command: &[String]) -> Result<(), SandboxError> { + if command.is_empty() { + return Err(SandboxError::InvalidPolicy("empty command".to_string())); + } + + let c_args: Vec = command + .iter() + .map(|arg| { + CString::new(arg.as_bytes()).map_err(|e| { + SandboxError::InvalidPolicy(format!("argument contains NUL byte: {e}")) + }) + }) + .collect::, _>>()?; + + let c_arg_ptrs: Vec<*const libc::c_char> = c_args + .iter() + .map(|a| a.as_ptr()) + .chain(std::iter::once(std::ptr::null())) + .collect(); + + // SAFETY: c_arg_ptrs is a null-terminated array of valid C string pointers. + // The CString values in c_args are alive for the duration of this call. + unsafe { + libc::execvp(c_args[0].as_ptr(), c_arg_ptrs.as_ptr()); + } + + // execvp only returns on error + Err(SandboxError::Exec(std::io::Error::last_os_error())) +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + // === exec_command error paths === + + #[rstest] + fn exec_command_rejects_empty_command() { + let err = exec_command(&[]).unwrap_err(); + assert!( + matches!(err, SandboxError::InvalidPolicy(_)), + "empty command should return InvalidPolicy, got: {err}" + ); + } + + #[rstest] + fn exec_command_rejects_nul_byte_in_argument() { + let err = exec_command(&["hello\0world".to_string()]).unwrap_err(); + assert!( + matches!(err, SandboxError::InvalidPolicy(_)), + "NUL byte should return InvalidPolicy, got: {err}" + ); + } + + // === exit_code_from_status === + + #[rstest] + #[case::success(&["true"], 0)] + #[case::failure(&["false"], 1)] + #[case::exit_42(&["sh", "-c", "exit 42"], 42)] + fn exit_code_from_status_returns_code(#[case] cmd: &[&str], #[case] expected: i32) { + let status = std::process::Command::new(cmd[0]) + .args(&cmd[1..]) + .status() + .expect("command should run"); + assert_eq!(exit_code_from_status(status), expected); + } +} diff --git a/crates/runok-linux-sandbox/src/policy.rs b/crates/runok-linux-sandbox/src/policy.rs new file mode 100644 index 0000000..2b9bf65 --- /dev/null +++ b/crates/runok-linux-sandbox/src/policy.rs @@ -0,0 +1,11 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// Sandbox policy passed from the runok main binary via JSON. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SandboxPolicy { + pub writable_roots: Vec, + pub read_only_subpaths: Vec, + pub network_allowed: bool, +} diff --git a/crates/runok-linux-sandbox/src/seccomp_sandbox.rs b/crates/runok-linux-sandbox/src/seccomp_sandbox.rs new file mode 100644 index 0000000..261ccc1 --- /dev/null +++ b/crates/runok-linux-sandbox/src/seccomp_sandbox.rs @@ -0,0 +1,140 @@ +use std::collections::BTreeMap; +use std::convert::TryInto; + +use seccompiler::{ + BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter, + SeccompRule, +}; + +use crate::error::SandboxError; + +/// Apply seccomp filters to block network access (except AF_UNIX). +/// +/// When network is not allowed: +/// - socket(2) with domain != AF_UNIX returns EPERM +/// - All other syscalls are allowed +/// +/// When network is allowed, no seccomp filter is applied. +pub fn apply_seccomp(network_allowed: bool) -> Result<(), SandboxError> { + if network_allowed { + return Ok(()); + } + + let filter = build_seccomp_filter()?; + let bpf_prog: BpfProgram = filter + .try_into() + .map_err(|e| SandboxError::Seccomp(format!("BPF compilation failed: {e}")))?; + + seccompiler::apply_filter(&bpf_prog) + .map_err(|e| SandboxError::Seccomp(format!("apply_filter failed: {e}")))?; + + Ok(()) +} + +/// Build the seccomp filter that blocks non-AF_UNIX sockets. +/// +/// The filter uses: +/// - mismatch_action: Allow (all non-listed syscalls are permitted) +/// - match_action: Errno(EPERM) (matched rules return permission denied) +/// +/// Rules: +/// - socket(2): block when arg0 (domain) != AF_UNIX +pub fn build_seccomp_filter() -> Result { + let mut rules: BTreeMap> = BTreeMap::new(); + + // socket(2): block non-AF_UNIX domains + // The rule matches when domain != AF_UNIX, and the match_action (Errno) is applied. + let socket_rule = SeccompRule::new(vec![ + SeccompCondition::new( + 0, // arg0 = domain + SeccompCmpArgLen::Dword, + SeccompCmpOp::Ne, + libc::AF_UNIX as u64, + ) + .map_err(|e| SandboxError::Seccomp(format!("SeccompCondition creation failed: {e}")))?, + ]) + .map_err(|e| SandboxError::Seccomp(format!("SeccompRule creation failed: {e}")))?; + + rules.insert(libc::SYS_socket, vec![socket_rule]); + + let arch = std::env::consts::ARCH + .try_into() + .map_err(|e| SandboxError::Seccomp(format!("unsupported architecture: {e}")))?; + + SeccompFilter::new( + rules, + SeccompAction::Allow, // non-listed syscalls are allowed + SeccompAction::Errno(libc::EPERM as u32), // matched rules return EPERM + arch, + ) + .map_err(|e| SandboxError::Seccomp(format!("SeccompFilter creation failed: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + /// Description of a seccomp rule for testing/inspection. + #[derive(Debug, Clone, PartialEq, Eq)] + struct SeccompRuleDescription { + syscall: String, + syscall_nr: i64, + condition: String, + action: String, + } + + /// Inspect the seccomp filter configuration for testing. + fn describe_seccomp_rules(network_allowed: bool) -> Vec { + if network_allowed { + return vec![]; + } + + vec![SeccompRuleDescription { + syscall: "socket".to_string(), + syscall_nr: libc::SYS_socket, + condition: "arg0 (domain) != AF_UNIX".to_string(), + action: "Errno(EPERM)".to_string(), + }] + } + + #[rstest] + fn build_filter_succeeds() { + let filter = build_seccomp_filter(); + assert!(filter.is_ok(), "filter should build successfully"); + } + + #[rstest] + fn build_filter_can_convert_to_bpf() { + let filter = build_seccomp_filter().expect("filter should build"); + let bpf: Result = filter.try_into(); + assert!(bpf.is_ok(), "should convert to BPF program"); + } + + #[rstest] + #[case::network_blocked(false, 1)] + #[case::network_allowed(true, 0)] + fn describe_rules_count(#[case] network_allowed: bool, #[case] expected_count: usize) { + let rules = describe_seccomp_rules(network_allowed); + assert_eq!(rules.len(), expected_count); + } + + #[rstest] + fn apply_seccomp_skips_filter_when_network_allowed() { + // When network is allowed, apply_seccomp should return Ok immediately + // without installing any seccomp filter. + assert!(apply_seccomp(true).is_ok()); + } + + #[rstest] + fn describe_rules_network_blocked_has_socket_rule() { + let rules = describe_seccomp_rules(false); + assert_eq!(rules.len(), 1); + + let rule = &rules[0]; + assert_eq!(rule.syscall, "socket"); + assert_eq!(rule.syscall_nr, libc::SYS_socket); + assert_eq!(rule.condition, "arg0 (domain) != AF_UNIX"); + assert_eq!(rule.action, "Errno(EPERM)"); + } +} diff --git a/src/exec/command_executor.rs b/src/exec/command_executor.rs index ba3bc08..46a1d19 100644 --- a/src/exec/command_executor.rs +++ b/src/exec/command_executor.rs @@ -48,7 +48,7 @@ impl CommandInput { } /// Sandbox policy defining filesystem and network restrictions for command execution. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct SandboxPolicy { /// Writable root directories (cwd is always included by the caller). pub writable_roots: Vec, @@ -185,8 +185,10 @@ fn canonicalize_path(path: &str) -> Result { /// Trait for executing commands in a sandboxed environment. /// -/// Phase 2 will provide real implementations (macOS seatbelt, Linux landlock/seccomp). -/// For now, only a stub is provided. +/// Platform-specific implementations: +/// - Linux: `LinuxSandboxExecutor` (bubblewrap + landlock + seccomp via helper binary) +/// - macOS: Not yet implemented (planned: seatbelt) +/// - Fallback: `StubSandboxExecutor` (returns unsupported error) pub trait SandboxExecutor { /// Execute a command within a sandbox, returning the exit code. fn exec_sandboxed(&self, command: &[String], policy: &SandboxPolicy) -> Result; @@ -208,6 +210,78 @@ impl SandboxExecutor for StubSandboxExecutor { } } +/// Linux sandbox executor that delegates to the runok-linux-sandbox helper binary. +/// +/// The helper binary applies bubblewrap (namespace isolation), landlock (filesystem +/// access control), and seccomp (network control) before executing the command. +#[cfg(target_os = "linux")] +pub struct LinuxSandboxExecutor { + helper_path: PathBuf, +} + +#[cfg(target_os = "linux")] +impl LinuxSandboxExecutor { + /// Create a new LinuxSandboxExecutor by finding the helper binary. + /// + /// Search order: + /// 1. Same directory as the current executable + /// 2. PATH lookup via `which` + pub fn new() -> Result { + let helper_path = + find_linux_sandbox_helper().ok_or(super::error::SandboxError::NotSupported)?; + Ok(Self { helper_path }) + } +} + +#[cfg(target_os = "linux")] +impl SandboxExecutor for LinuxSandboxExecutor { + fn exec_sandboxed(&self, command: &[String], policy: &SandboxPolicy) -> Result { + let policy_json = serde_json::to_string(policy).map_err(|e| { + ExecError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("failed to serialize sandbox policy: {e}"), + )) + })?; + + let cwd = std::env::current_dir().map_err(ExecError::Io)?; + + let status = Command::new(&self.helper_path) + .arg("--policy") + .arg(&policy_json) + .arg("--cwd") + .arg(&cwd) + .arg("--") + .args(command) + .status() + .map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => { + ExecError::NotFound(self.helper_path.display().to_string()) + } + _ => ExecError::Io(e), + })?; + + Ok(exit_code_from_status(status)) + } +} + +/// Find the runok-linux-sandbox helper binary. +/// +/// This is a standalone function for use in non-Linux builds (e.g., testing path +/// discovery logic). On Linux, prefer `LinuxSandboxExecutor::new()`. +pub fn find_linux_sandbox_helper() -> Option { + // 1. Same directory as the current executable + if let Ok(exe) = std::env::current_exe() + && let Some(dir) = exe.parent() + { + let helper = dir.join("runok-linux-sandbox"); + if helper.exists() { + return Some(helper); + } + } + // 2. PATH lookup + which::which("runok-linux-sandbox").ok() +} + impl SandboxPolicy { /// Build a `SandboxPolicy` from a `MergedSandboxPolicy` (config layer). /// @@ -1218,4 +1292,76 @@ mod tests { assert_eq!(result, input); } } + + // === SandboxPolicy serialization === + + #[rstest] + fn sandbox_policy_round_trip_serialization() { + let policy = SandboxPolicy { + writable_roots: vec![PathBuf::from("/tmp"), PathBuf::from("/home/user/project")], + read_only_subpaths: vec![ + PathBuf::from("/home/user/project/.git"), + PathBuf::from(".env*"), + ], + network_allowed: false, + }; + + let json = serde_json::to_string(&policy).expect("should serialize"); + let deserialized: SandboxPolicy = serde_json::from_str(&json).expect("should deserialize"); + + assert_eq!(policy, deserialized); + } + + #[rstest] + fn sandbox_policy_deserialize_from_json() { + let json = r#"{ + "writable_roots": ["/tmp"], + "read_only_subpaths": [".git"], + "network_allowed": true + }"#; + + let policy: SandboxPolicy = serde_json::from_str(json).expect("should deserialize"); + assert_eq!(policy.writable_roots, vec![PathBuf::from("/tmp")]); + assert_eq!(policy.read_only_subpaths, vec![PathBuf::from(".git")]); + assert!(policy.network_allowed); + } + + // === Helper binary discovery === + + #[rstest] + fn find_linux_sandbox_helper_returns_none_when_not_installed() { + // The helper binary is not installed in the test environment, + // so the function exercises both the exe-dir check and PATH + // fallback, returning None. + let result = super::find_linux_sandbox_helper(); + assert!(result.is_none()); + } + + // === DryRunError conversion and display === + + #[rstest] + #[case::not_found( + ExecError::NotFound("foo".into()), + DryRunError::NotFound("foo".into()), + "command not found: foo" + )] + #[case::permission_denied( + ExecError::PermissionDenied("bar".into()), + DryRunError::PermissionDenied("bar".into()), + "permission denied: bar" + )] + #[case::io_error( + ExecError::Io(std::io::Error::other("disk failure")), + DryRunError::Io("disk failure".into()), + "io error: disk failure" + )] + fn dry_run_error_from_exec_error( + #[case] exec_err: ExecError, + #[case] expected: DryRunError, + #[case] display: &str, + ) { + let converted: DryRunError = exec_err.into(); + assert_eq!(converted, expected); + assert_eq!(converted.to_string(), display); + } } diff --git a/src/main.rs b/src/main.rs index 35f6f1d..7213b51 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,28 @@ use clap::Parser; use cli::{CheckRoute, Cli, Commands, route_check}; use runok::adapter::{self, RunOptions}; use runok::config::{ConfigLoader, DefaultConfigLoader}; -use runok::exec::command_executor::ProcessCommandExecutor; +#[cfg(target_os = "linux")] +use runok::exec::command_executor::LinuxSandboxExecutor; +use runok::exec::command_executor::{CommandExecutor, ProcessCommandExecutor}; + +/// Create the appropriate command executor for the current platform. +/// +/// On Linux, attempts to find the runok-linux-sandbox helper binary and use +/// the LinuxSandboxExecutor. Falls back to the stub executor if not found. +/// On other platforms, always uses the stub executor. +fn create_executor() -> Box { + #[cfg(target_os = "linux")] + { + match LinuxSandboxExecutor::new() { + Ok(sandbox) => return Box::new(ProcessCommandExecutor::new(sandbox)), + Err(_) => { + // Fall through to stub + } + } + } + + Box::new(ProcessCommandExecutor::new_without_sandbox()) +} fn main() -> ExitCode { let cli = Cli::parse(); @@ -41,11 +62,11 @@ fn run_command(command: Commands, cwd: &std::path::Path, stdin: impl std::io::Re dry_run: args.dry_run, verbose: args.verbose, }; - let executor = ProcessCommandExecutor::new_without_sandbox(); + let executor = create_executor(); let endpoint = runok::adapter::exec_adapter::ExecAdapter::new( args.command, args.sandbox, - Box::new(executor), + executor, ); adapter::run_with_options(&endpoint, &config, &options) } @@ -80,7 +101,7 @@ fn run_command(command: Commands, cwd: &std::path::Path, stdin: impl std::io::Re #[cfg(test)] mod tests { use super::*; - use cli::CheckArgs; + use cli::{CheckArgs, ExecArgs}; use indoc::indoc; use rstest::rstest; @@ -136,6 +157,26 @@ mod tests { assert_eq!(exit_code, 0); } + #[rstest] + fn create_executor_returns_executor() { + let executor = create_executor(); + // Verify executor works by validating a known command + assert!(executor.validate(&["sh".to_string()]).is_ok()); + } + + #[rstest] + fn run_command_exec_with_dry_run() { + let cmd = Commands::Exec(ExecArgs { + command: vec!["echo".into(), "hello".into()], + sandbox: None, + dry_run: true, + verbose: false, + }); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let exit_code = run_command(cmd, &cwd, std::io::empty()); + assert_eq!(exit_code, 0); + } + #[rstest] fn run_command_check_with_multiline_plaintext_stdin_returns_zero() { let cmd = Commands::Check(CheckArgs {