Files
codex/codex-rs/core/src/landlock.rs
viyatb-oai db7e02c739 fix: canonicalize symlinked Linux sandbox cwd (#14849)
## Problem
On Linux, Codex can be launched from a workspace path that is a symlink
(for example, a symlinked checkout or a symlinked parent directory).

Our sandbox policy intentionally canonicalizes writable/readable roots
to the real filesystem path before building the bubblewrap mounts. That
part is correct and needed for safety.

The remaining bug was that bubblewrap could still inherit the helper
process's logical cwd, which might be the symlinked alias instead of the
mounted canonical path. In that case, the sandbox starts in a cwd that
does not exist inside the sandbox namespace even though the real
workspace is mounted. This can cause sandboxed commands to fail in
symlinked workspaces.

## Fix
This PR keeps the sandbox policy behavior the same, but separates two
concepts that were previously conflated:

- the canonical cwd used to define sandbox mounts and permissions
- the caller's logical cwd used when launching the command

On the Linux bubblewrap path, we now thread the logical command cwd
through the helper explicitly and only add `--chdir <canonical path>`
when the logical cwd differs from the mounted canonical path.

That means:
- permissions are still computed from canonical paths
- bubblewrap starts the command from a cwd that definitely exists inside
the sandbox
- we do not widen filesystem access or undo the earlier symlink
hardening

## Why This Is Safe
This is a narrow Linux-only launch fix, not a policy change.

- Writable/readable root canonicalization stays intact.
- Protected metadata carveouts still operate on canonical roots.
- We only override bubblewrap's inherited cwd when the logical path
would otherwise point at a symlink alias that is not mounted in the
sandbox.

## Tests
- kept the existing protocol/core regression coverage for symlink
canonicalization
- added regression coverage for symlinked cwd handling in the Linux
bubblewrap builder/helper path

Local validation:
- `just fmt`
- `cargo test -p codex-protocol`
- `cargo test -p codex-core
normalize_additional_permissions_canonicalizes_symlinked_write_paths`
- `cargo clippy -p codex-linux-sandbox -p codex-protocol -p codex-core
--tests -- -D warnings`
- `cargo build --bin codex`

## Context
This is related to #14694. The earlier writable-root symlink fix
addressed the mount/permission side; this PR fixes the remaining
symlinked-cwd launch mismatch in the Linux sandbox path.
2026-03-16 22:39:18 -07:00

171 lines
5.9 KiB
Rust

use crate::protocol::SandboxPolicy;
use crate::spawn::SpawnChildRequest;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use codex_network_proxy::NetworkProxy;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use tokio::process::Child;
/// Spawn a shell tool command under the Linux sandbox helper
/// (codex-linux-sandbox), which defaults to bubblewrap for filesystem
/// isolation plus seccomp for network restrictions.
///
/// Unlike macOS Seatbelt where we directly embed the policy text, the Linux
/// helper is a separate executable. We pass the legacy [`SandboxPolicy`] plus
/// split filesystem/network policies as JSON so the helper can migrate
/// incrementally without breaking older call sites.
#[allow(clippy::too_many_arguments)]
pub async fn spawn_command_under_linux_sandbox<P>(
codex_linux_sandbox_exe: P,
command: Vec<String>,
command_cwd: PathBuf,
sandbox_policy: &SandboxPolicy,
sandbox_policy_cwd: &Path,
use_legacy_landlock: bool,
stdio_policy: StdioPolicy,
network: Option<&NetworkProxy>,
env: HashMap<String, String>,
) -> std::io::Result<Child>
where
P: AsRef<Path>,
{
let file_system_sandbox_policy =
FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd);
let network_sandbox_policy = NetworkSandboxPolicy::from(sandbox_policy);
let args = create_linux_sandbox_command_args_for_policies(
command,
command_cwd.as_path(),
sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
sandbox_policy_cwd,
use_legacy_landlock,
allow_network_for_proxy(/*enforce_managed_network*/ false),
);
let arg0 = Some("codex-linux-sandbox");
spawn_child_async(SpawnChildRequest {
program: codex_linux_sandbox_exe.as_ref().to_path_buf(),
args,
arg0,
cwd: command_cwd,
network_sandbox_policy,
network,
stdio_policy,
env,
})
.await
}
pub(crate) fn allow_network_for_proxy(enforce_managed_network: bool) -> bool {
// When managed network requirements are active, request proxy-only
// networking from the Linux sandbox helper. Without managed requirements,
// preserve existing behavior.
enforce_managed_network
}
/// Converts the sandbox policies into the CLI invocation for
/// `codex-linux-sandbox`.
///
/// The helper performs the actual sandboxing (bubblewrap by default + seccomp) after
/// parsing these arguments. Policy JSON flags are emitted before helper feature
/// flags so the argv order matches the helper's CLI shape. See
/// `docs/linux_sandbox.md` for the Linux semantics.
#[allow(clippy::too_many_arguments)]
pub(crate) fn create_linux_sandbox_command_args_for_policies(
command: Vec<String>,
command_cwd: &Path,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox_policy_cwd: &Path,
use_legacy_landlock: bool,
allow_network_for_proxy: bool,
) -> Vec<String> {
let sandbox_policy_json = serde_json::to_string(sandbox_policy)
.unwrap_or_else(|err| panic!("failed to serialize sandbox policy: {err}"));
let file_system_policy_json = serde_json::to_string(file_system_sandbox_policy)
.unwrap_or_else(|err| panic!("failed to serialize filesystem sandbox policy: {err}"));
let network_policy_json = serde_json::to_string(&network_sandbox_policy)
.unwrap_or_else(|err| panic!("failed to serialize network sandbox policy: {err}"));
let sandbox_policy_cwd = sandbox_policy_cwd
.to_str()
.unwrap_or_else(|| panic!("cwd must be valid UTF-8"))
.to_string();
let command_cwd = command_cwd
.to_str()
.unwrap_or_else(|| panic!("command cwd must be valid UTF-8"))
.to_string();
let mut linux_cmd: Vec<String> = vec![
"--sandbox-policy-cwd".to_string(),
sandbox_policy_cwd,
"--command-cwd".to_string(),
command_cwd,
"--sandbox-policy".to_string(),
sandbox_policy_json,
"--file-system-sandbox-policy".to_string(),
file_system_policy_json,
"--network-sandbox-policy".to_string(),
network_policy_json,
];
if use_legacy_landlock {
linux_cmd.push("--use-legacy-landlock".to_string());
}
if allow_network_for_proxy {
linux_cmd.push("--allow-network-for-proxy".to_string());
}
linux_cmd.push("--".to_string());
linux_cmd.extend(command);
linux_cmd
}
/// Converts the sandbox cwd and execution options into the CLI invocation for
/// `codex-linux-sandbox`.
#[cfg(test)]
pub(crate) fn create_linux_sandbox_command_args(
command: Vec<String>,
command_cwd: &Path,
sandbox_policy_cwd: &Path,
use_legacy_landlock: bool,
allow_network_for_proxy: bool,
) -> Vec<String> {
let command_cwd = command_cwd
.to_str()
.unwrap_or_else(|| panic!("command cwd must be valid UTF-8"))
.to_string();
let sandbox_policy_cwd = sandbox_policy_cwd
.to_str()
.unwrap_or_else(|| panic!("cwd must be valid UTF-8"))
.to_string();
let mut linux_cmd: Vec<String> = vec![
"--sandbox-policy-cwd".to_string(),
sandbox_policy_cwd,
"--command-cwd".to_string(),
command_cwd,
];
if use_legacy_landlock {
linux_cmd.push("--use-legacy-landlock".to_string());
}
if allow_network_for_proxy {
linux_cmd.push("--allow-network-for-proxy".to_string());
}
// Separator so that command arguments starting with `-` are not parsed as
// options of the helper itself.
linux_cmd.push("--".to_string());
// Append the original tool command.
linux_cmd.extend(command);
linux_cmd
}
#[cfg(test)]
#[path = "landlock_tests.rs"]
mod tests;