From 1e058359b5467782061e46330edee97e1351cbca Mon Sep 17 00:00:00 2001 From: liwenkai <2020583117@qq.com> Date: Fri, 26 Jun 2026 13:55:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20Linux=20=E6=B2=99=E7=AE=B1?= =?UTF-8?q?=E2=80=94=E2=80=94Landlock=20=E9=99=90=E5=86=99=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=20+=20seccomp=20=E6=8B=A6=E5=8D=B1=E9=99=A9?= =?UTF-8?q?=E8=B0=83=E7=94=A8/=E6=96=AD=E7=BD=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 23 ++ crates/deep-code-agent/Cargo.toml | 7 + crates/deep-code-agent/src/sandbox/linux.rs | 272 +++++++++++++++++++- crates/deep-code-agent/src/sandbox/mod.rs | 7 +- 4 files changed, 303 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b165dd3..eb6d19d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -880,8 +880,11 @@ dependencies = [ "futures-core", "futures-util", "ignore", + "landlock", + "libc", "regex", "reqwest 0.12.28", + "seccompiler", "serde", "serde_json", "tempfile", @@ -2256,6 +2259,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "landlock" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635839550ae8b90d9fd2571460a6645dc0aec070225956ca7a2831ed31d2795d" +dependencies = [ + "enumflags2", + "libc", + "thiserror 2.0.18", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -3508,6 +3522,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 = "selectors" version = "0.36.1" diff --git a/crates/deep-code-agent/Cargo.toml b/crates/deep-code-agent/Cargo.toml index 02ced13..3cc4872 100644 --- a/crates/deep-code-agent/Cargo.toml +++ b/crates/deep-code-agent/Cargo.toml @@ -26,6 +26,13 @@ toml_edit = "0.20" uuid = { version = "1", features = ["v4"] } walkdir = "2" +# Linux sandbox backend (Landlock filesystem confinement + seccomp syscall +# filtering). Linux-only so macOS/Windows builds never pull these. +[target.'cfg(target_os = "linux")'.dependencies] +landlock = "0.4" +seccompiler = "0.5" +libc = "0.2" + [dev-dependencies] anyhow = "1" tempfile = "3" diff --git a/crates/deep-code-agent/src/sandbox/linux.rs b/crates/deep-code-agent/src/sandbox/linux.rs index db9d27f..2210561 100644 --- a/crates/deep-code-agent/src/sandbox/linux.rs +++ b/crates/deep-code-agent/src/sandbox/linux.rs @@ -1,11 +1,273 @@ +//! Linux sandbox: Landlock filesystem confinement + seccomp syscall/network +//! filtering, applied in the forked child via `pre_exec` (no external binary, +//! unlike the macOS `sandbox-exec` wrapper). +//! +//! Model (parity with macOS seatbelt, ≥ CodeWhale): +//! - **Reads stay broad** — only write-class Landlock access rights are +//! *handled*, so reads are unrestricted (keeps tools working). +//! - **Writes are confined** to the policy's writable roots, plus the temp dir +//! and a few `/dev` nodes needed for redirections. `ReadOnly` grants no +//! workspace write at all. +//! - **Network is blocked** (seccomp `socket`/`connect` → EPERM) unless the +//! policy allows it — so even broad reads can't be exfiltrated. +//! - **Dangerous syscalls** (ptrace, mount, module load, bpf, …) are always +//! blocked. +//! +//! Availability is reported by [`capabilities`]; the manager only calls +//! [`wrap_shell_command`] when Landlock is available. Any build failure here +//! degrades to an unsandboxed command plus a warning (fail-open, matching the +//! current non-macOS behavior). + +use std::collections::BTreeMap; +use std::io; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use landlock::{ + ABI, Access, AccessFs, BitFlags, CompatLevel, Compatible, Ruleset, RulesetAttr, RulesetCreated, + RulesetCreatedAttr, path_beneath_rules, +}; +use seccompiler::{BpfProgram, SeccompAction, SeccompFilter, TargetArch}; + +use super::policy::SandboxPolicy; use super::{SandboxBackend, SandboxCapabilities}; -/// Linux Landlock placeholder — detection only for now. +/// Landlock ABI baseline (kernel 5.13+). `BestEffort` silently downgrades any +/// newer write-class rights on older kernels. +const LANDLOCK_ABI: ABI = ABI::V1; + +/// Always-blocked syscalls (escape / privilege / host-tampering). Limited to +/// numbers present on both x86_64 and aarch64. +const DANGEROUS_SYSCALLS: &[i64] = &[ + libc::SYS_ptrace as i64, + libc::SYS_mount as i64, + libc::SYS_umount2 as i64, + libc::SYS_pivot_root as i64, + libc::SYS_chroot as i64, + libc::SYS_reboot as i64, + libc::SYS_kexec_load as i64, + libc::SYS_init_module as i64, + libc::SYS_finit_module as i64, + libc::SYS_delete_module as i64, + libc::SYS_bpf as i64, +]; + +/// Blocked when the policy forbids network. Denying `socket` stops any new +/// socket (no network of any family); `connect` is belt-and-suspenders. +const NETWORK_SYSCALLS: &[i64] = &[libc::SYS_socket as i64, libc::SYS_connect as i64]; + +/// Write-class access rights: everything Landlock can govern minus the +/// read/execute rights, so reads stay unrestricted. +fn write_access() -> BitFlags { + AccessFs::from_all(LANDLOCK_ABI) & !AccessFs::from_read(LANDLOCK_ABI) +} + #[must_use] pub fn capabilities() -> SandboxCapabilities { - SandboxCapabilities { - backend: SandboxBackend::LinuxLandlock, - available: false, - detail: "Landlock sandbox is not implemented yet (placeholder)".to_string(), + match probe() { + Ok(()) => SandboxCapabilities { + backend: SandboxBackend::LinuxLandlock, + available: true, + detail: "Landlock + seccomp available".to_string(), + }, + Err(error) => SandboxCapabilities { + backend: SandboxBackend::None, + available: false, + detail: format!("Landlock unavailable: {error}"), + }, + } +} + +/// Truthful availability probe: `HardRequirement` makes ruleset creation error +/// when the kernel lacks Landlock, instead of silently no-opping. +fn probe() -> Result<(), String> { + Ruleset::default() + .set_compatibility(CompatLevel::HardRequirement) + .handle_access(write_access()) + .and_then(|ruleset| ruleset.create()) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +pub fn wrap_shell_command( + command: &str, + cwd: &Path, + workspace: &Path, + policy: &SandboxPolicy, +) -> Command { + let mut cmd = super::bare_shell_command(command, cwd); + + let restrictions = match build_restrictions(workspace, cwd, policy) { + Ok(restrictions) => restrictions, + Err(error) => { + eprintln!("warning: Linux sandbox setup failed, running unsandboxed: {error}"); + return cmd; + } + }; + let (ruleset, bpf) = restrictions; + let mut ruleset = Some(ruleset); + + // SAFETY: the closure runs in the forked child after fork and before exec. + // The Landlock ruleset and seccomp program are fully built before the fork; + // here we only invoke the enforcement syscalls (`restrict_self` / + // `apply_filter`), which is acceptable in the post-fork pre-exec window. + unsafe { + cmd.pre_exec(move || { + if let Some(created) = ruleset.take() { + created + .restrict_self() + .map_err(|error| io::Error::other(format!("landlock: {error}")))?; + } + seccompiler::apply_filter(&bpf) + .map_err(|error| io::Error::other(format!("seccomp: {error}")))?; + Ok(()) + }); + } + cmd +} + +fn build_restrictions( + workspace: &Path, + cwd: &Path, + policy: &SandboxPolicy, +) -> Result<(RulesetCreated, BpfProgram), String> { + let ruleset = build_landlock(workspace, cwd, policy)?; + let bpf = build_seccomp(policy)?; + Ok((ruleset, bpf)) +} + +fn build_landlock( + workspace: &Path, + cwd: &Path, + policy: &SandboxPolicy, +) -> Result { + let access = write_access(); + // Pre-filter to existing paths so a missing /dev node can't fail the whole + // ruleset build (path_beneath_rules errors on paths it can't open). + let writable: Vec = writable_paths(workspace, cwd, policy) + .into_iter() + .filter(|path| path.exists()) + .collect(); + + Ruleset::default() + .set_compatibility(CompatLevel::BestEffort) + .handle_access(access) + .and_then(|ruleset| ruleset.create()) + .and_then(|created| created.add_rules(path_beneath_rules(&writable, access))) + .map_err(|error| format!("landlock ruleset: {error}")) +} + +fn writable_paths(workspace: &Path, cwd: &Path, policy: &SandboxPolicy) -> Vec { + let mut paths = policy.writable_roots(workspace, cwd); + paths.push(std::env::temp_dir()); + for node in [ + "/dev/null", + "/dev/zero", + "/dev/full", + "/dev/tty", + "/dev/ptmx", + "/dev/pts", + "/dev/random", + "/dev/urandom", + ] { + paths.push(PathBuf::from(node)); + } + paths +} + +fn build_seccomp(policy: &SandboxPolicy) -> Result { + let mut rules: BTreeMap> = BTreeMap::new(); + for syscall in DANGEROUS_SYSCALLS { + rules.insert(*syscall, Vec::new()); + } + if !policy.has_network_access() { + for syscall in NETWORK_SYSCALLS { + rules.insert(*syscall, Vec::new()); + } + } + + let filter = SeccompFilter::new( + rules, + SeccompAction::Allow, + SeccompAction::Errno(libc::EPERM as u32), + target_arch()?, + ) + .map_err(|error| format!("seccomp filter: {error}"))?; + + filter + .try_into() + .map_err(|error| format!("seccomp compile: {error}")) +} + +fn target_arch() -> Result { + #[cfg(target_arch = "x86_64")] + { + Ok(TargetArch::x86_64) + } + #[cfg(target_arch = "aarch64")] + { + Ok(TargetArch::aarch64) + } + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + { + Err("unsupported architecture for seccomp".to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Stdio; + + fn run(workspace: &Path, command: &str) -> std::process::Output { + wrap_shell_command( + command, + workspace, + workspace, + &SandboxPolicy::workspace_write(), + ) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("spawn sandboxed command") + } + + #[test] + fn write_inside_workspace_is_allowed() { + if !capabilities().available { + return; // No Landlock on this host (e.g. old kernel); nothing to assert. + } + let dir = tempfile::tempdir().unwrap(); + let out = run(dir.path(), "echo hi > inside.txt"); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(dir.path().join("inside.txt").exists()); + } + + #[test] + fn write_outside_workspace_is_blocked() { + if !capabilities().available { + return; + } + // Target a path under $HOME (not the workspace, not the granted temp + // dir), so only Landlock decides the outcome. + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + let escape = PathBuf::from(&home).join("deepcode_sandbox_escape_probe.txt"); + let _ = std::fs::remove_file(&escape); + + let dir = tempfile::tempdir().unwrap(); + let _ = run(dir.path(), &format!("echo escaped > {}", escape.display())); + + let leaked = escape.exists(); + let _ = std::fs::remove_file(&escape); + assert!( + !leaked, + "write outside workspace should be blocked by Landlock" + ); } } diff --git a/crates/deep-code-agent/src/sandbox/mod.rs b/crates/deep-code-agent/src/sandbox/mod.rs index 426a949..4986c61 100644 --- a/crates/deep-code-agent/src/sandbox/mod.rs +++ b/crates/deep-code-agent/src/sandbox/mod.rs @@ -135,7 +135,12 @@ impl SandboxManager { macos_seatbelt::wrap_shell_command(command, cwd, workspace, policy) } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] + { + linux::wrap_shell_command(command, cwd, workspace, policy) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] { let _ = workspace; bare_shell_command(command, cwd)