feat: add debug landlock subcommand comparable to debug seatbelt (#715)
This PR adds a `debug landlock` subcommand to the Codex CLI for testing how Codex would execute a command using the specified sandbox policy. Built and ran this code in the `rust:latest` Docker container. In the container, hitting the network with vanilla `curl` succeeds: ``` $ curl google.com <HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8"> <TITLE>301 Moved</TITLE></HEAD><BODY> <H1>301 Moved</H1> The document has moved <A HREF="http://www.google.com/">here</A>. </BODY></HTML> ``` whereas this fails, as expected: ``` $ cargo run -- debug landlock -s network-restricted -- curl google.com curl: (6) getaddrinfo() thread failed to start ```
This commit is contained in:
51
codex-rs/cli/src/landlock.rs
Normal file
51
codex-rs/cli/src/landlock.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
//! `debug landlock` implementation for the Codex CLI.
|
||||||
|
//!
|
||||||
|
//! On Linux the command is executed inside a Landlock + seccomp sandbox by
|
||||||
|
//! calling the low-level `exec_linux` helper from `codex_core::linux`.
|
||||||
|
|
||||||
|
use codex_core::protocol::SandboxPolicy;
|
||||||
|
use std::os::unix::process::ExitStatusExt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::process::ExitStatus;
|
||||||
|
|
||||||
|
/// Execute `command` in a Linux sandbox (Landlock + seccomp) the way Codex
|
||||||
|
/// would.
|
||||||
|
pub(crate) fn run_landlock(
|
||||||
|
command: Vec<String>,
|
||||||
|
sandbox_policy: SandboxPolicy,
|
||||||
|
writable_roots: Vec<PathBuf>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
if command.is_empty() {
|
||||||
|
anyhow::bail!("command args are empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn a new thread and apply the sandbox policies there.
|
||||||
|
let handle = std::thread::spawn(move || -> anyhow::Result<ExitStatus> {
|
||||||
|
// Apply sandbox policies inside this thread so only the child inherits
|
||||||
|
// them, not the entire CLI process.
|
||||||
|
if sandbox_policy.is_network_restricted() {
|
||||||
|
codex_core::linux::install_network_seccomp_filter_on_current_thread()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if sandbox_policy.is_file_write_restricted() {
|
||||||
|
codex_core::linux::install_filesystem_landlock_rules_on_current_thread(writable_roots)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = Command::new(&command[0]).args(&command[1..]).status()?;
|
||||||
|
Ok(status)
|
||||||
|
});
|
||||||
|
let status = handle
|
||||||
|
.join()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to join thread: {e:?}"))??;
|
||||||
|
|
||||||
|
// Use ExitStatus to derive the exit code.
|
||||||
|
if let Some(code) = status.code() {
|
||||||
|
process::exit(code);
|
||||||
|
} else if let Some(signal) = status.signal() {
|
||||||
|
process::exit(128 + signal);
|
||||||
|
} else {
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod landlock;
|
||||||
mod proto;
|
mod proto;
|
||||||
mod seatbelt;
|
mod seatbelt;
|
||||||
|
|
||||||
@@ -58,11 +60,14 @@ struct DebugArgs {
|
|||||||
enum DebugCommand {
|
enum DebugCommand {
|
||||||
/// Run a command under Seatbelt (macOS only).
|
/// Run a command under Seatbelt (macOS only).
|
||||||
Seatbelt(SeatbeltCommand),
|
Seatbelt(SeatbeltCommand),
|
||||||
|
|
||||||
|
/// Run a command under Landlock+seccomp (Linux only).
|
||||||
|
Landlock(LandlockCommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
struct SeatbeltCommand {
|
struct SeatbeltCommand {
|
||||||
/// Writable folder for sandbox in full-auto mode (can be specified multiple times).
|
/// Writable folder for sandbox (can be specified multiple times).
|
||||||
#[arg(long = "writable-root", short = 'w', value_name = "DIR", action = ArgAction::Append, use_value_delimiter = false)]
|
#[arg(long = "writable-root", short = 'w', value_name = "DIR", action = ArgAction::Append, use_value_delimiter = false)]
|
||||||
writable_roots: Vec<PathBuf>,
|
writable_roots: Vec<PathBuf>,
|
||||||
|
|
||||||
@@ -75,6 +80,21 @@ struct SeatbeltCommand {
|
|||||||
command: Vec<String>,
|
command: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
struct LandlockCommand {
|
||||||
|
/// Writable folder for sandbox (can be specified multiple times).
|
||||||
|
#[arg(long = "writable-root", short = 'w', value_name = "DIR", action = ArgAction::Append, use_value_delimiter = false)]
|
||||||
|
writable_roots: Vec<PathBuf>,
|
||||||
|
|
||||||
|
/// Configure the process restrictions for the command.
|
||||||
|
#[arg(long = "sandbox", short = 's')]
|
||||||
|
sandbox_policy: SandboxModeCliArg,
|
||||||
|
|
||||||
|
/// Full command args to run under landlock.
|
||||||
|
#[arg(trailing_var_arg = true)]
|
||||||
|
command: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
struct ReplProto {}
|
struct ReplProto {}
|
||||||
|
|
||||||
@@ -103,6 +123,18 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}) => {
|
}) => {
|
||||||
seatbelt::run_seatbelt(command, sandbox_policy.into(), writable_roots).await?;
|
seatbelt::run_seatbelt(command, sandbox_policy.into(), writable_roots).await?;
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
DebugCommand::Landlock(LandlockCommand {
|
||||||
|
command,
|
||||||
|
sandbox_policy,
|
||||||
|
writable_roots,
|
||||||
|
}) => {
|
||||||
|
landlock::run_landlock(command, sandbox_policy.into(), writable_roots)?;
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
DebugCommand::Landlock(_) => {
|
||||||
|
anyhow::bail!("Landlock is only supported on Linux.");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ pub mod exec;
|
|||||||
mod flags;
|
mod flags;
|
||||||
mod is_safe_command;
|
mod is_safe_command;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod linux;
|
pub mod linux;
|
||||||
mod models;
|
mod models;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
mod safety;
|
mod safety;
|
||||||
|
|||||||
@@ -72,7 +72,15 @@ pub async fn exec_linux(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install_filesystem_landlock_rules_on_current_thread(writable_roots: Vec<PathBuf>) -> Result<()> {
|
/// Installs Landlock file-system rules on the current thread allowing read
|
||||||
|
/// access to the entire file-system while restricting write access to
|
||||||
|
/// `/dev/null` and the provided list of `writable_roots`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`CodexErr::Sandbox`] variants when the ruleset fails to apply.
|
||||||
|
pub fn install_filesystem_landlock_rules_on_current_thread(
|
||||||
|
writable_roots: Vec<PathBuf>,
|
||||||
|
) -> Result<()> {
|
||||||
let abi = ABI::V5;
|
let abi = ABI::V5;
|
||||||
let access_rw = AccessFs::from_all(abi);
|
let access_rw = AccessFs::from_all(abi);
|
||||||
let access_ro = AccessFs::from_read(abi);
|
let access_ro = AccessFs::from_read(abi);
|
||||||
@@ -98,7 +106,9 @@ fn install_filesystem_landlock_rules_on_current_thread(writable_roots: Vec<PathB
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), SandboxErr> {
|
/// Installs a seccomp filter that blocks outbound network access except for
|
||||||
|
/// AF_UNIX domain sockets.
|
||||||
|
pub fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), SandboxErr> {
|
||||||
// Build rule map.
|
// Build rule map.
|
||||||
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
|
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user