Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions crates/deep-code-agent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
272 changes: 267 additions & 5 deletions crates/deep-code-agent/src/sandbox/linux.rs
Original file line number Diff line number Diff line change
@@ -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> {
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<RulesetCreated, String> {
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<PathBuf> = 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<PathBuf> {
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<BpfProgram, String> {
let mut rules: BTreeMap<i64, Vec<seccompiler::SeccompRule>> = 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<TargetArch, String> {
#[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"
);
}
}
7 changes: 6 additions & 1 deletion crates/deep-code-agent/src/sandbox/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading