Compare commits

...

22 Commits

Author SHA1 Message Date
starr-openai
3c5511e3e0 exec-server: fix process test ExecParams initializer
Co-authored-by: Codex <noreply@openai.com>
2026-04-07 19:11:53 -07:00
starr-openai
4a85ef45b5 codex: add exec-server managed-network follow-up
Split the remote managed-network wiring and verification fixes out of the main exec-server sandbox PR. This keeps the original review-response branch focused while carrying the exec-server-owned proxy startup, the unsupported approval-callback guard, and the added regression coverage in a stacked follow-up.

Co-authored-by: Codex <noreply@openai.com>
2026-04-07 19:11:53 -07:00
starr-openai
acb4d9abe0 sandboxing: annotate transform test literals
Co-authored-by: Codex <noreply@openai.com>
2026-04-07 16:51:36 -07:00
starr-openai
a4a0cfcc77 codex: fix PR 16736 CI failures
Co-authored-by: Codex <noreply@openai.com>
2026-04-07 16:44:55 -07:00
starr-openai
3d5f1a4e56 codex: dispatch exec-server sandbox helper via argv0
Teach the standalone exec-server binary to run the Linux sandbox helper when it is re-execed with the codex-linux-sandbox argv0 alias. Point the exec-server sandbox transform at the current executable on Linux instead of requiring an env-provided helper path.

Co-authored-by: Codex <noreply@openai.com>
2026-04-07 15:24:33 -07:00
starr-openai
4e90a1a891 codex: avoid reading sandbox type back from remote start
Co-authored-by: Codex <noreply@openai.com>
2026-04-07 15:09:36 -07:00
starr-openai
e1556d4393 codex: fix sandbox launch config callsites
Co-authored-by: Codex <noreply@openai.com>
2026-04-07 15:06:53 -07:00
starr-openai
ddf532509e codex: fix core build after exec-server merge
Co-authored-by: Codex <noreply@openai.com>
2026-04-07 14:51:57 -07:00
starr-openai
603215b378 codex: remove unused exec-server network-proxy dependency
Co-authored-by: Codex <noreply@openai.com>
2026-04-07 14:44:53 -07:00
starr-openai
fe18e32d9b Merge origin/main into exec-server-sandbox-0403
Co-authored-by: Codex <noreply@openai.com>
2026-04-07 14:40:12 -07:00
starr-openai
0695fe1d02 codex: address remaining sandbox review feedback (#16736)
Co-authored-by: Codex <noreply@openai.com>
2026-04-07 14:36:33 -07:00
starr-openai
b61a0d0976 codex: drop dead exec-server sandbox override hook
Co-authored-by: Codex <noreply@openai.com>
2026-04-07 13:52:52 -07:00
starr-openai
ed55c3d55b codex: simplify remote sandbox launch payload
Co-authored-by: Codex <noreply@openai.com>
2026-04-07 13:50:44 -07:00
starr-openai
dd82e260ee codex: avoid remote sandbox type round-trip
Use the locally known sandbox type when constructing the remote unified-exec process in the fallback exec-env path. This removes the extra dependency on the exec-server reported sandbox type that reviewers called out.

Co-authored-by: Codex <noreply@openai.com>
2026-04-07 11:41:08 -07:00
starr-openai
2fb1b2de43 Address exec-server sandbox review feedback
Co-authored-by: Codex <noreply@openai.com>
2026-04-07 10:19:15 -07:00
starr-openai
21ae6e9b3b codex: narrow exec-server sandbox wire smoke test 2026-04-06 16:53:25 -07:00
starr-openai
ac76523450 codex: wire linux sandbox helper into exec-server tests 2026-04-06 16:39:17 -07:00
starr-openai
f0889e6518 codex: add exec-server sandbox wire test 2026-04-06 16:08:17 -07:00
starr-openai
b277ad611c codex: preserve remote unified exec fallback inputs 2026-04-06 14:08:59 -07:00
starr-openai
ab132113f9 codex: tighten remote unified exec launch path 2026-04-06 12:38:59 -07:00
starr-openai
da8e0233b4 codex: fix CI failure on PR #16736 2026-04-06 12:07:45 -07:00
starr-openai
cc915eb13e codex: move unified exec sandbox launch to exec-server 2026-04-06 12:07:45 -07:00
30 changed files with 890 additions and 242 deletions

5
codex-rs/Cargo.lock generated
View File

@@ -2063,6 +2063,10 @@ dependencies = [
"base64 0.22.1",
"clap",
"codex-app-server-protocol",
"codex-linux-sandbox",
"codex-network-proxy",
"codex-protocol",
"codex-sandboxing",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-pty",
@@ -2643,6 +2647,7 @@ dependencies = [
"dunce",
"libc",
"pretty_assertions",
"serde",
"serde_json",
"tempfile",
"tracing",

View File

@@ -1279,6 +1279,18 @@ impl Session {
}
}
pub(crate) async fn managed_network_proxy_spec(
&self,
) -> Option<crate::config::NetworkProxySpec> {
let state = self.state.lock().await;
state
.session_configuration
.original_config_do_not_use
.permissions
.network
.clone()
}
/// Builds the `x-codex-beta-features` header value for this session.
///
/// `ModelClient` is session-scoped and intentionally does not depend on the full `Config`, so

View File

@@ -78,6 +78,25 @@ impl NetworkProxySpec {
self.config.network.enabled
}
pub(crate) fn config(&self) -> &NetworkProxyConfig {
&self.config
}
pub(crate) fn constraints(&self) -> &NetworkProxyConstraints {
&self.constraints
}
pub(crate) fn requires_remote_approval_callbacks(
&self,
sandbox_policy: &SandboxPolicy,
) -> bool {
!self.hard_deny_allowlist_misses
&& matches!(
sandbox_policy,
SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. }
)
}
pub fn proxy_host_and_port(&self) -> String {
host_and_port_from_network_addr(&self.config.network.proxy_url, /*default_port*/ 3128)
}

View File

@@ -38,6 +38,32 @@ fn build_state_with_audit_metadata_threads_metadata_to_state() {
assert_eq!(state.audit_metadata(), &metadata);
}
#[test]
fn requires_remote_approval_callbacks_only_for_restricted_expandable_managed_network() {
let expandable_spec = NetworkProxySpec {
config: NetworkProxyConfig::default(),
constraints: NetworkProxyConstraints::default(),
hard_deny_allowlist_misses: false,
};
assert!(
expandable_spec.requires_remote_approval_callbacks(&SandboxPolicy::new_read_only_policy())
);
assert!(
expandable_spec
.requires_remote_approval_callbacks(&SandboxPolicy::new_workspace_write_policy())
);
assert!(!expandable_spec.requires_remote_approval_callbacks(&SandboxPolicy::DangerFullAccess));
let hard_deny_spec = NetworkProxySpec {
config: NetworkProxyConfig::default(),
constraints: NetworkProxyConstraints::default(),
hard_deny_allowlist_misses: true,
};
assert!(
!hard_deny_spec.requires_remote_approval_callbacks(&SandboxPolicy::new_read_only_policy())
);
}
#[test]
fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() {
let mut config = NetworkProxyConfig::default();

View File

@@ -39,8 +39,8 @@ use codex_protocol::protocol::ExecCommandOutputDeltaEvent;
use codex_protocol::protocol::ExecOutputStream;
use codex_protocol::protocol::SandboxPolicy;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxLaunchConfig;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxType;
use codex_sandboxing::SandboxablePreference;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -283,21 +283,25 @@ pub fn build_exec_request(
expiration,
capture_policy,
};
let sandbox_launch_config = SandboxLaunchConfig {
sandbox: sandbox_type,
policy: sandbox_policy.clone(),
file_system_policy: file_system_sandbox_policy.clone(),
network_policy: network_sandbox_policy,
sandbox_policy_cwd: sandbox_cwd.to_path_buf(),
additional_permissions: None,
enforce_managed_network,
windows_sandbox_level,
windows_sandbox_private_desktop,
use_legacy_landlock,
};
let mut exec_req = manager
.transform(SandboxTransformRequest {
.transform(
command,
policy: sandbox_policy,
file_system_policy: file_system_sandbox_policy,
network_policy: network_sandbox_policy,
sandbox: sandbox_type,
enforce_managed_network,
network: network.as_ref(),
sandbox_policy_cwd: sandbox_cwd,
codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_ref(),
use_legacy_landlock,
windows_sandbox_level,
windows_sandbox_private_desktop,
})
&sandbox_launch_config,
network.as_ref(),
codex_linux_sandbox_exe.as_ref(),
)
.map(|request| ExecRequest::from_sandbox_exec_request(request, options))
.map_err(CodexErr::from)?;
exec_req.windows_restricted_token_filesystem_overlay =

View File

@@ -42,8 +42,8 @@ use crate::sandboxing::ExecOptions;
use crate::tools::ToolRouter;
use crate::tools::context::SharedTurnDiffTracker;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxLaunchConfig;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxablePreference;
use codex_tools::ToolSpec;
use codex_utils_output_truncation::TruncationPolicy;
@@ -1058,24 +1058,28 @@ impl JsReplManager {
expiration: ExecExpiration::DefaultTimeout,
capture_policy: ExecCapturePolicy::ShellTool,
};
let sandbox_launch_config = SandboxLaunchConfig {
sandbox: sandbox_type,
policy: turn.sandbox_policy.get().clone(),
file_system_policy: turn.file_system_sandbox_policy.clone(),
network_policy: turn.network_sandbox_policy,
sandbox_policy_cwd: turn.cwd.to_path_buf(),
additional_permissions: None,
enforce_managed_network: has_managed_network_requirements,
windows_sandbox_level: turn.windows_sandbox_level,
windows_sandbox_private_desktop: turn
.config
.permissions
.windows_sandbox_private_desktop,
use_legacy_landlock: turn.features.use_legacy_landlock(),
};
let exec_env = sandbox
.transform(SandboxTransformRequest {
.transform(
command,
policy: &turn.sandbox_policy,
file_system_policy: &turn.file_system_sandbox_policy,
network_policy: turn.network_sandbox_policy,
sandbox: sandbox_type,
enforce_managed_network: has_managed_network_requirements,
network: None,
sandbox_policy_cwd: &turn.cwd,
codex_linux_sandbox_exe: turn.codex_linux_sandbox_exe.as_ref(),
use_legacy_landlock: turn.features.use_legacy_landlock(),
windows_sandbox_level: turn.windows_sandbox_level,
windows_sandbox_private_desktop: turn
.config
.permissions
.windows_sandbox_private_desktop,
})
&sandbox_launch_config,
/*network*/ None,
turn.codex_linux_sandbox_exe.as_ref(),
)
.map(|request| {
crate::sandboxing::ExecRequest::from_sandbox_exec_request(request, options)
})

View File

@@ -33,8 +33,8 @@ use codex_protocol::protocol::NetworkPolicyRuleAction;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxLaunchConfig;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxType;
use codex_sandboxing::SandboxablePreference;
use codex_shell_command::bash::parse_shell_lc_plain_commands;
@@ -835,20 +835,24 @@ impl CoreShellCommandExecutor {
expiration: ExecExpiration::DefaultTimeout,
capture_policy: ExecCapturePolicy::ShellTool,
};
let exec_request = sandbox_manager.transform(SandboxTransformRequest {
command,
policy: sandbox_policy,
file_system_policy: file_system_sandbox_policy,
network_policy: network_sandbox_policy,
let sandbox_launch_config = SandboxLaunchConfig {
sandbox,
policy: sandbox_policy.clone(),
file_system_policy: file_system_sandbox_policy.clone(),
network_policy: network_sandbox_policy,
sandbox_policy_cwd: self.sandbox_policy_cwd.clone(),
additional_permissions: None,
enforce_managed_network: self.network.is_some(),
network: self.network.as_ref(),
sandbox_policy_cwd: &self.sandbox_policy_cwd,
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_ref(),
use_legacy_landlock: self.use_legacy_landlock,
windows_sandbox_level: self.windows_sandbox_level,
windows_sandbox_private_desktop: false,
})?;
use_legacy_landlock: self.use_legacy_landlock,
};
let exec_request = sandbox_manager.transform(
command,
&sandbox_launch_config,
self.network.as_ref(),
self.codex_linux_sandbox_exe.as_ref(),
)?;
let mut exec_request =
crate::sandboxing::ExecRequest::from_sandbox_exec_request(exec_request, options);
if let Some(network) = exec_request.network.as_ref() {

View File

@@ -33,11 +33,14 @@ use crate::unified_exec::NoopSpawnLifecycle;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::UnifiedExecProcess;
use crate::unified_exec::UnifiedExecProcessManager;
use codex_exec_server::ManagedNetworkConfig;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::NetworkProxyAuditMetadata;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::ReviewDecision;
use codex_sandboxing::SandboxLaunchConfig;
use codex_sandboxing::SandboxablePreference;
use codex_shell_command::powershell::prefix_powershell_script_with_utf8;
use codex_tools::UnifiedExecShellMode;
@@ -82,6 +85,62 @@ pub struct UnifiedExecRuntime<'a> {
shell_mode: UnifiedExecShellMode,
}
struct RemoteManagedNetworkLaunch {
config: ManagedNetworkConfig,
requires_approval_callbacks: bool,
}
fn build_remote_exec_sandbox_config(
attempt: &SandboxAttempt<'_>,
additional_permissions: Option<PermissionProfile>,
) -> SandboxLaunchConfig {
if matches!(attempt.sandbox, codex_sandboxing::SandboxType::None) {
return SandboxLaunchConfig::no_sandbox(attempt.sandbox_cwd.to_path_buf());
}
SandboxLaunchConfig {
sandbox: attempt.sandbox,
policy: attempt.policy.clone(),
file_system_policy: attempt.file_system_policy.clone(),
network_policy: attempt.network_policy,
sandbox_policy_cwd: attempt.sandbox_cwd.to_path_buf(),
additional_permissions,
enforce_managed_network: attempt.enforce_managed_network,
windows_sandbox_level: attempt.windows_sandbox_level,
windows_sandbox_private_desktop: attempt.windows_sandbox_private_desktop,
use_legacy_landlock: attempt.use_legacy_landlock,
}
}
async fn build_remote_exec_managed_network_launch(
session: &crate::codex::Session,
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
) -> Option<RemoteManagedNetworkLaunch> {
let spec = session.managed_network_proxy_spec().await?;
let spec = spec
.with_exec_policy_network_rules(session.services.exec_policy.current().as_ref())
.map_err(|err| {
tracing::warn!(
"failed to apply execpolicy network rules to remote managed proxy; continuing with configured network policy: {err}"
);
err
})
.unwrap_or_else(|_| spec.clone());
Some(RemoteManagedNetworkLaunch {
config: ManagedNetworkConfig {
config: spec.config().clone(),
constraints: spec.constraints().clone(),
audit_metadata: NetworkProxyAuditMetadata {
conversation_id: Some(session.conversation_id.to_string()),
app_version: Some(env!("CARGO_PKG_VERSION").to_string()),
..NetworkProxyAuditMetadata::default()
},
},
requires_approval_callbacks: spec.requires_remote_approval_callbacks(sandbox_policy),
})
}
impl<'a> UnifiedExecRuntime<'a> {
/// Creates a runtime bound to the shared unified-exec process manager.
pub fn new(manager: &'a UnifiedExecProcessManager, shell_mode: UnifiedExecShellMode) -> Self {
@@ -215,10 +274,75 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
command
};
let mut env = req.env.clone();
let base_env = req.env.clone();
if let Some(environment) = ctx
.turn
.environment
.as_ref()
.filter(|environment| environment.exec_server_url().is_some())
{
if let UnifiedExecShellMode::ZshFork(_) = &self.shell_mode {
return Err(ToolError::Rejected(
"unified_exec zsh-fork is not supported when exec_server_url is configured"
.to_string(),
));
}
let managed_network = if req.network.is_some() {
let managed_network =
build_remote_exec_managed_network_launch(ctx.session.as_ref(), attempt.policy)
.await
.ok_or_else(|| {
ToolError::Rejected(
"remote unified_exec is missing managed-network proxy configuration"
.to_string(),
)
})?;
if managed_network.requires_approval_callbacks {
return Err(ToolError::Rejected(
"remote unified_exec does not yet support managed-network approval callbacks"
.to_string(),
));
}
Some(managed_network.config)
} else {
None
};
let exec_params = codex_exec_server::ExecParams {
process_id: req.process_id.to_string().into(),
argv: command,
cwd: req.cwd.clone(),
env: base_env.clone(),
tty: req.tty,
arg0: None,
sandbox: build_remote_exec_sandbox_config(
attempt,
req.additional_permissions.clone(),
),
managed_network,
};
return self
.manager
.open_session_with_remote_exec(exec_params, environment.as_ref())
.await
.map_err(|err| match err {
UnifiedExecError::SandboxDenied { output, .. } => {
ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output: Box::new(output),
network_policy_decision: None,
}))
}
other => ToolError::Rejected(other.to_string()),
});
}
let mut env = base_env;
if let Some(network) = req.network.as_ref() {
network.apply_to_env(&mut env);
}
let Some(environment) = ctx.turn.environment.as_ref() else {
return Err(ToolError::Rejected(
"exec_command is unavailable in this session".to_string(),
));
};
if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode {
let command =
build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone())
@@ -240,16 +364,6 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
.await?
{
Some(prepared) => {
let Some(environment) = ctx.turn.environment.as_ref() else {
return Err(ToolError::Rejected(
"exec_command is unavailable in this session".to_string(),
));
};
if environment.is_remote() {
return Err(ToolError::Rejected(
"unified_exec zsh-fork is not supported when exec_server_url is configured".to_string(),
));
}
return self
.manager
.open_session_with_exec_env(
@@ -287,11 +401,6 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
let exec_env = attempt
.env_for(command, options, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
let Some(environment) = ctx.turn.environment.as_ref() else {
return Err(ToolError::Rejected(
"exec_command is unavailable in this session".to_string(),
));
};
self.manager
.open_session_with_exec_env(
req.process_id,

View File

@@ -22,9 +22,9 @@ use codex_protocol::protocol::ReviewDecision;
#[cfg(test)]
use codex_protocol::protocol::SandboxPolicy;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxLaunchConfig;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxTransformError;
use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxType;
use codex_sandboxing::SandboxablePreference;
use futures::Future;
@@ -338,21 +338,25 @@ impl<'a> SandboxAttempt<'a> {
options: ExecOptions,
network: Option<&NetworkProxy>,
) -> Result<crate::sandboxing::ExecRequest, SandboxTransformError> {
let sandbox_launch_config = SandboxLaunchConfig {
sandbox: self.sandbox,
policy: self.policy.clone(),
file_system_policy: self.file_system_policy.clone(),
network_policy: self.network_policy,
sandbox_policy_cwd: self.sandbox_cwd.to_path_buf(),
additional_permissions: None,
enforce_managed_network: self.enforce_managed_network,
windows_sandbox_level: self.windows_sandbox_level,
windows_sandbox_private_desktop: self.windows_sandbox_private_desktop,
use_legacy_landlock: self.use_legacy_landlock,
};
self.manager
.transform(SandboxTransformRequest {
.transform(
command,
policy: self.policy,
file_system_policy: self.file_system_policy,
network_policy: self.network_policy,
sandbox: self.sandbox,
enforce_managed_network: self.enforce_managed_network,
&sandbox_launch_config,
network,
sandbox_policy_cwd: self.sandbox_cwd,
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe,
use_legacy_landlock: self.use_legacy_landlock,
windows_sandbox_level: self.windows_sandbox_level,
windows_sandbox_private_desktop: self.windows_sandbox_private_desktop,
})
self.codex_linux_sandbox_exe,
)
.map(|request| {
crate::sandboxing::ExecRequest::from_sandbox_exec_request(request, options)
})

View File

@@ -12,7 +12,7 @@
//! Flow at a glance (open process)
//! 1) Build a small request `{ command, cwd }`.
//! 2) Orchestrator: approval (bypass/cache/prompt) → select sandbox → run.
//! 3) Runtime: transform `SandboxTransformRequest` -> `ExecRequest` -> spawn PTY.
//! 3) Runtime: transform sandbox config -> `ExecRequest` -> spawn PTY.
//! 4) If denial, orchestrator retries with `SandboxType::None`.
//! 5) Process handle is returned with streaming output + metadata.
//!

View File

@@ -609,6 +609,8 @@ impl UnifiedExecProcessManager {
env: request.env.clone(),
tty,
arg0: request.arg0.clone(),
sandbox: codex_sandboxing::SandboxLaunchConfig::no_sandbox(request.cwd.clone()),
managed_network: None,
})
.await
.map_err(|err| UnifiedExecError::create_process(err.to_string()))?;
@@ -643,6 +645,20 @@ impl UnifiedExecProcessManager {
UnifiedExecProcess::from_spawned(spawned, request.sandbox, spawn_lifecycle).await
}
pub(crate) async fn open_session_with_remote_exec(
&self,
params: codex_exec_server::ExecParams,
environment: &codex_exec_server::Environment,
) -> Result<UnifiedExecProcess, UnifiedExecError> {
let sandbox_type = params.sandbox.sandbox;
let started = environment
.get_exec_backend()
.start(params)
.await
.map_err(|err| UnifiedExecError::create_process(err.to_string()))?;
UnifiedExecProcess::from_remote_started(started, sandbox_type).await
}
pub(super) async fn open_session_with_sandbox(
&self,
request: &ExecCommandRequest,

View File

@@ -74,6 +74,7 @@ async fn remote_process(write_status: WriteStatus) -> UnifiedExecProcess {
read_responses: Mutex::new(VecDeque::new()),
wake_tx,
}),
sandbox_type: SandboxType::None,
};
UnifiedExecProcess::from_remote_started(started, SandboxType::None)
@@ -126,6 +127,7 @@ async fn remote_process_waits_for_early_exit_event() {
}])),
wake_tx: wake_tx.clone(),
}),
sandbox_type: SandboxType::None,
};
tokio::spawn(async move {

View File

@@ -15,10 +15,14 @@ path = "src/bin/codex-exec-server.rs"
workspace = true
[dependencies]
anyhow = { workspace = true }
arc-swap = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-network-proxy = { workspace = true }
codex-protocol = { workspace = true }
codex-sandboxing = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-pty = { workspace = true }
@@ -40,6 +44,9 @@ tokio = { workspace = true, features = [
tokio-tungstenite = { workspace = true }
tracing = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
codex-linux-sandbox = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
codex-utils-cargo-bin = { workspace = true }

View File

@@ -1,4 +1,9 @@
#[cfg(target_os = "linux")]
use std::path::Path;
use clap::Parser;
#[cfg(target_os = "linux")]
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
#[derive(Debug, Parser)]
struct ExecServerArgs {
@@ -11,8 +16,30 @@ struct ExecServerArgs {
listen: String,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let args = ExecServerArgs::parse();
codex_exec_server::run_main_with_listen_url(&args.listen).await
fn main() -> anyhow::Result<()> {
dispatch_arg0();
let runtime = tokio::runtime::Runtime::new()?;
runtime.block_on(async {
let args = ExecServerArgs::parse();
codex_exec_server::run_main_with_listen_url(&args.listen)
.await
.map_err(|err| anyhow::Error::msg(err.to_string()))
})
}
#[cfg(target_os = "linux")]
fn dispatch_arg0() {
let argv0 = std::env::args_os().next().unwrap_or_default();
let exe_name = Path::new(&argv0)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
if exe_name == CODEX_LINUX_SANDBOX_ARG0 {
codex_linux_sandbox::run_main();
}
}
#[cfg(not(target_os = "linux"))]
fn dispatch_arg0() {}

View File

@@ -209,6 +209,7 @@ mod tests {
use super::Environment;
use super::EnvironmentManager;
use crate::ProcessId;
use codex_sandboxing::SandboxLaunchConfig;
use pretty_assertions::assert_eq;
#[tokio::test]
@@ -276,6 +277,10 @@ mod tests {
env: Default::default(),
tty: false,
arg0: None,
sandbox: SandboxLaunchConfig::no_sandbox(
std::env::current_dir().expect("read current dir"),
),
managed_network: None,
})
.await
.expect("start process");

View File

@@ -54,6 +54,7 @@ pub use protocol::ExecParams;
pub use protocol::ExecResponse;
pub use protocol::InitializeParams;
pub use protocol::InitializeResponse;
pub use protocol::ManagedNetworkConfig;
pub use protocol::ReadParams;
pub use protocol::ReadResponse;
pub use protocol::TerminateParams;

View File

@@ -1,5 +1,7 @@
use std::collections::HashMap;
use std::collections::VecDeque;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
@@ -7,6 +9,16 @@ use std::time::Duration;
use async_trait::async_trait;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_network_proxy::ConfigReloader;
use codex_network_proxy::ConfigState;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::NetworkProxyHandle;
use codex_network_proxy::NetworkProxyState;
use codex_network_proxy::build_config_state;
use codex_network_proxy::validate_policy_against_constraints;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxExecRequest;
use codex_sandboxing::SandboxType;
use codex_utils_pty::ExecCommandSession;
use codex_utils_pty::TerminalSize;
use tokio::sync::Mutex;
@@ -57,6 +69,7 @@ struct RetainedOutputChunk {
struct RunningProcess {
session: ExecCommandSession,
_managed_network: Option<ManagedNetworkRuntime>,
tty: bool,
output: VecDeque<RetainedOutputChunk>,
retained_bytes: usize,
@@ -78,6 +91,7 @@ struct Inner {
processes: Mutex<HashMap<ProcessId, ProcessEntry>>,
initialize_requested: AtomicBool,
initialized: AtomicBool,
runtime: ExecServerRuntimeConfig,
}
#[derive(Clone)]
@@ -91,6 +105,61 @@ struct LocalExecProcess {
wake_tx: watch::Sender<u64>,
}
#[derive(Clone, Debug, Default)]
struct ExecServerRuntimeConfig {
codex_linux_sandbox_exe: Option<PathBuf>,
}
impl ExecServerRuntimeConfig {
fn detect() -> Self {
Self {
// The Codex CLI and codex-exec-server both dispatch the Linux
// sandbox helper from their own executable via argv[0].
codex_linux_sandbox_exe: if cfg!(target_os = "linux") {
std::env::current_exe().ok()
} else {
None
},
}
}
}
struct StartedProcess {
process_id: ProcessId,
sandbox_type: SandboxType,
wake_tx: watch::Sender<u64>,
}
struct ManagedNetworkRuntime {
proxy: NetworkProxy,
_handle: NetworkProxyHandle,
}
#[derive(Clone)]
struct StaticNetworkProxyReloader {
state: ConfigState,
}
#[async_trait]
impl ConfigReloader for StaticNetworkProxyReloader {
async fn maybe_reload(&self) -> anyhow::Result<Option<ConfigState>> {
Ok(None)
}
async fn reload_now(&self) -> anyhow::Result<ConfigState> {
Ok(self.state.clone())
}
fn source_label(&self) -> String {
"ExecServerStaticNetworkProxyReloader".to_string()
}
}
struct PreparedExecLaunch {
request: SandboxExecRequest,
managed_network: Option<ManagedNetworkRuntime>,
}
impl Default for LocalProcess {
fn default() -> Self {
let (outgoing_tx, mut outgoing_rx) =
@@ -108,6 +177,7 @@ impl LocalProcess {
processes: Mutex::new(HashMap::new()),
initialize_requested: AtomicBool::new(false),
initialized: AtomicBool::new(false),
runtime: ExecServerRuntimeConfig::detect(),
}),
}
}
@@ -162,14 +232,13 @@ impl LocalProcess {
Ok(())
}
async fn start_process(
&self,
params: ExecParams,
) -> Result<(ExecResponse, watch::Sender<u64>), JSONRPCErrorError> {
async fn start_process(&self, params: ExecParams) -> Result<StartedProcess, JSONRPCErrorError> {
self.require_initialized_for("exec")?;
let process_id = params.process_id.clone();
let (program, args) = params
.argv
let launch = prepare_exec_launch(&params, &self.inner.runtime).await?;
let (program, args) = launch
.request
.command
.split_first()
.ok_or_else(|| invalid_params("argv must not be empty".to_string()))?;
@@ -188,8 +257,8 @@ impl LocalProcess {
program,
args,
params.cwd.as_path(),
&params.env,
&params.arg0,
&launch.request.env,
&launch.request.arg0,
TerminalSize::default(),
)
.await
@@ -198,8 +267,8 @@ impl LocalProcess {
program,
args,
params.cwd.as_path(),
&params.env,
&params.arg0,
&launch.request.env,
&launch.request.arg0,
)
.await
};
@@ -222,6 +291,7 @@ impl LocalProcess {
process_id.clone(),
ProcessEntry::Running(Box::new(RunningProcess {
session: spawned.session,
_managed_network: launch.managed_network,
tty: params.tty,
output: VecDeque::new(),
retained_bytes: 0,
@@ -264,13 +334,19 @@ impl LocalProcess {
output_notify,
));
Ok((ExecResponse { process_id }, wake_tx))
Ok(StartedProcess {
process_id,
sandbox_type: launch.request.sandbox,
wake_tx,
})
}
pub(crate) async fn exec(&self, params: ExecParams) -> Result<ExecResponse, JSONRPCErrorError> {
self.start_process(params)
.await
.map(|(response, _)| response)
.map(|started| ExecResponse {
process_id: started.process_id,
})
}
pub(crate) async fn exec_read(
@@ -414,16 +490,17 @@ impl LocalProcess {
#[async_trait]
impl ExecBackend for LocalProcess {
async fn start(&self, params: ExecParams) -> Result<StartedExecProcess, ExecServerError> {
let (response, wake_tx) = self
let started = self
.start_process(params)
.await
.map_err(map_handler_error)?;
Ok(StartedExecProcess {
process: Arc::new(LocalExecProcess {
process_id: response.process_id,
process_id: started.process_id,
backend: self.clone(),
wake_tx,
wake_tx: started.wake_tx,
}),
sandbox_type: started.sandbox_type,
})
}
}
@@ -458,6 +535,90 @@ impl ExecProcess for LocalExecProcess {
}
}
fn build_sandbox_command(
argv: &[String],
cwd: &Path,
env: &HashMap<String, String>,
additional_permissions: Option<codex_protocol::models::PermissionProfile>,
) -> Result<SandboxCommand, JSONRPCErrorError> {
let (program, args) = argv
.split_first()
.ok_or_else(|| invalid_params("argv must not be empty".to_string()))?;
Ok(SandboxCommand {
program: program.clone().into(),
args: args.to_vec(),
cwd: cwd.to_path_buf(),
env: env.clone(),
additional_permissions,
})
}
async fn start_managed_network_runtime(
config: &crate::protocol::ManagedNetworkConfig,
) -> Result<ManagedNetworkRuntime, JSONRPCErrorError> {
validate_policy_against_constraints(&config.config, &config.constraints)
.map_err(|err| internal_error(format!("invalid managed network config: {err}")))?;
let state =
build_config_state(config.config.clone(), config.constraints.clone()).map_err(|err| {
internal_error(format!(
"failed to build managed network proxy state: {err}"
))
})?;
let reloader_state = state.clone();
let state = NetworkProxyState::with_reloader_and_audit_metadata(
state,
Arc::new(StaticNetworkProxyReloader {
state: reloader_state,
}),
config.audit_metadata.clone(),
);
let proxy = NetworkProxy::builder()
.state(Arc::new(state))
.build()
.await
.map_err(|err| internal_error(format!("failed to build managed network proxy: {err}")))?;
let handle = proxy
.run()
.await
.map_err(|err| internal_error(format!("failed to run managed network proxy: {err}")))?;
Ok(ManagedNetworkRuntime {
proxy,
_handle: handle,
})
}
async fn prepare_exec_launch(
params: &ExecParams,
runtime: &ExecServerRuntimeConfig,
) -> Result<PreparedExecLaunch, JSONRPCErrorError> {
let managed_network = match params.managed_network.as_ref() {
Some(config) => Some(start_managed_network_runtime(config).await?),
None => None,
};
let mut env = params.env.clone();
if let Some(network) = managed_network.as_ref() {
network.proxy.apply_to_env(&mut env);
}
let command = build_sandbox_command(
&params.argv,
params.cwd.as_path(),
&env,
params.sandbox.additional_permissions.clone(),
)?;
let request = params
.sandbox
.transform(
command,
managed_network.as_ref().map(|network| &network.proxy),
runtime.codex_linux_sandbox_exe.as_ref(),
)
.map_err(|err| internal_error(format!("failed to build sandbox launch: {err}")))?;
Ok(PreparedExecLaunch {
request,
managed_network,
})
}
impl LocalProcess {
async fn read(
&self,

View File

@@ -1,6 +1,7 @@
use std::sync::Arc;
use async_trait::async_trait;
use codex_sandboxing::SandboxType;
use tokio::sync::watch;
use crate::ExecServerError;
@@ -11,6 +12,7 @@ use crate::protocol::WriteResponse;
pub struct StartedExecProcess {
pub process: Arc<dyn ExecProcess>,
pub sandbox_type: SandboxType,
}
#[async_trait]

View File

@@ -2,6 +2,10 @@ use std::collections::HashMap;
use std::path::PathBuf;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_network_proxy::NetworkProxyAuditMetadata;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkProxyConstraints;
use codex_sandboxing::SandboxLaunchConfig;
use serde::Deserialize;
use serde::Serialize;
@@ -50,6 +54,14 @@ pub struct InitializeParams {
#[serde(rename_all = "camelCase")]
pub struct InitializeResponse {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ManagedNetworkConfig {
pub config: NetworkProxyConfig,
pub constraints: NetworkProxyConstraints,
pub audit_metadata: NetworkProxyAuditMetadata,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecParams {
@@ -61,6 +73,8 @@ pub struct ExecParams {
pub env: HashMap<String, String>,
pub tty: bool,
pub arg0: Option<String>,
pub sandbox: SandboxLaunchConfig,
pub managed_network: Option<ManagedNetworkConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View File

@@ -34,14 +34,19 @@ impl RemoteProcess {
impl ExecBackend for RemoteProcess {
async fn start(&self, params: ExecParams) -> Result<StartedExecProcess, ExecServerError> {
let process_id = params.process_id.clone();
let sandbox_type = params.sandbox.sandbox;
let session = self.client.register_session(&process_id).await?;
if let Err(err) = self.client.exec(params).await {
session.unregister().await;
return Err(err);
match self.client.exec(params).await {
Ok(_) => {}
Err(err) => {
session.unregister().await;
return Err(err);
}
}
Ok(StartedExecProcess {
process: Arc::new(RemoteExecProcess { session }),
sandbox_type,
})
}
}

View File

@@ -12,6 +12,7 @@ use crate::protocol::InitializeResponse;
use crate::protocol::TerminateParams;
use crate::protocol::TerminateResponse;
use crate::rpc::RpcNotificationSender;
use codex_sandboxing::SandboxLaunchConfig;
fn exec_params(process_id: &str) -> ExecParams {
let mut env = HashMap::new();
@@ -29,6 +30,8 @@ fn exec_params(process_id: &str) -> ExecParams {
env,
tty: false,
arg0: None,
sandbox: SandboxLaunchConfig::no_sandbox(std::env::current_dir().expect("cwd")),
managed_network: None,
}
}

View File

@@ -9,10 +9,21 @@ use codex_exec_server::Environment;
use codex_exec_server::ExecBackend;
use codex_exec_server::ExecParams;
use codex_exec_server::ExecProcess;
use codex_exec_server::ManagedNetworkConfig;
use codex_exec_server::ProcessId;
use codex_exec_server::ReadResponse;
use codex_exec_server::StartedExecProcess;
use codex_network_proxy::NetworkProxyAuditMetadata;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkProxyConstraints;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
use codex_sandboxing::SandboxLaunchConfig;
use codex_sandboxing::SandboxType;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use test_case::test_case;
use tokio::sync::watch;
use tokio::time::Duration;
@@ -45,15 +56,18 @@ async fn create_process_context(use_remote: bool) -> Result<ProcessContext> {
async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> {
let context = create_process_context(use_remote).await?;
let cwd = std::env::current_dir()?;
let session = context
.backend
.start(ExecParams {
process_id: ProcessId::from("proc-1"),
argv: vec!["true".to_string()],
cwd: std::env::current_dir()?,
cwd: cwd.clone(),
env: Default::default(),
tty: false,
arg0: None,
sandbox: SandboxLaunchConfig::no_sandbox(cwd),
managed_network: None,
})
.await?;
assert_eq!(session.process.process_id().as_str(), "proc-1");
@@ -116,6 +130,7 @@ async fn collect_process_output_from_reads(
async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> {
let context = create_process_context(use_remote).await?;
let cwd = std::env::current_dir()?;
let process_id = "proc-stream".to_string();
let session = context
.backend
@@ -126,15 +141,17 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> {
"-c".to_string(),
"sleep 0.05; printf 'session output\\n'".to_string(),
],
cwd: std::env::current_dir()?,
cwd: cwd.clone(),
env: Default::default(),
tty: false,
arg0: None,
sandbox: SandboxLaunchConfig::no_sandbox(cwd),
managed_network: None,
})
.await?;
assert_eq!(session.process.process_id().as_str(), process_id);
let StartedExecProcess { process } = session;
let StartedExecProcess { process, .. } = session;
let wake_rx = process.subscribe_wake();
let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?;
assert_eq!(output, "session output\n");
@@ -145,6 +162,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> {
async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> {
let context = create_process_context(use_remote).await?;
let cwd = std::env::current_dir()?;
let process_id = "proc-stdin".to_string();
let session = context
.backend
@@ -155,17 +173,19 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> {
"-c".to_string(),
"import sys; line = sys.stdin.readline(); sys.stdout.write(f'from-stdin:{line}'); sys.stdout.flush()".to_string(),
],
cwd: std::env::current_dir()?,
cwd: cwd.clone(),
env: Default::default(),
tty: true,
arg0: None,
sandbox: SandboxLaunchConfig::no_sandbox(cwd),
managed_network: None,
})
.await?;
assert_eq!(session.process.process_id().as_str(), process_id);
tokio::time::sleep(Duration::from_millis(200)).await;
session.process.write(b"hello\n".to_vec()).await?;
let StartedExecProcess { process } = session;
let StartedExecProcess { process, .. } = session;
let wake_rx = process.subscribe_wake();
let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?;
@@ -182,6 +202,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe(
use_remote: bool,
) -> Result<()> {
let context = create_process_context(use_remote).await?;
let cwd = std::env::current_dir()?;
let session = context
.backend
.start(ExecParams {
@@ -191,16 +212,18 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe(
"-c".to_string(),
"printf 'queued output\\n'".to_string(),
],
cwd: std::env::current_dir()?,
cwd: cwd.clone(),
env: Default::default(),
tty: false,
arg0: None,
sandbox: SandboxLaunchConfig::no_sandbox(cwd),
managed_network: None,
})
.await?;
tokio::time::sleep(Duration::from_millis(200)).await;
let StartedExecProcess { process } = session;
let StartedExecProcess { process, .. } = session;
let wake_rx = process.subscribe_wake();
let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?;
assert_eq!(output, "queued output\n");
@@ -209,6 +232,143 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe(
Ok(())
}
fn platform_sandbox_type() -> SandboxType {
if cfg!(target_os = "macos") {
SandboxType::MacosSeatbelt
} else if cfg!(target_os = "linux") {
SandboxType::LinuxSeccomp
} else {
unreachable!("unix exec-server tests only run on macOS and Linux");
}
}
fn write_outside_workspace_sandbox(workspace_root: &std::path::Path) -> SandboxLaunchConfig {
let mut policy = SandboxPolicy::new_workspace_write_policy();
if let SandboxPolicy::WorkspaceWrite {
exclude_tmpdir_env_var,
exclude_slash_tmp,
..
} = &mut policy
{
*exclude_tmpdir_env_var = true;
*exclude_slash_tmp = true;
}
SandboxLaunchConfig {
sandbox: platform_sandbox_type(),
policy: policy.clone(),
file_system_policy: FileSystemSandboxPolicy::from_legacy_sandbox_policy(
&policy,
workspace_root,
),
network_policy: NetworkSandboxPolicy::from(&policy),
sandbox_policy_cwd: workspace_root.to_path_buf(),
additional_permissions: None,
enforce_managed_network: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
use_legacy_landlock: false,
}
}
fn managed_network_config() -> ManagedNetworkConfig {
let mut config = NetworkProxyConfig::default();
config.network.enabled = true;
ManagedNetworkConfig {
config,
constraints: NetworkProxyConstraints {
enabled: Some(true),
..Default::default()
},
audit_metadata: NetworkProxyAuditMetadata {
conversation_id: Some("exec-server-smoke".to_string()),
..Default::default()
},
}
}
async fn assert_exec_process_sandbox_denies_write_outside_workspace(
use_remote: bool,
) -> Result<()> {
let temp_dir = TempDir::new()?;
let workspace_root = temp_dir.path().join("workspace");
std::fs::create_dir(&workspace_root)?;
let blocked_dir = tempfile::Builder::new()
.prefix("exec-server-sandbox-blocked-")
.tempdir_in(std::env::current_dir()?)?;
let blocked_path = blocked_dir.path().join("blocked.txt");
let context = create_process_context(use_remote).await?;
let session = context
.backend
.start(ExecParams {
process_id: ProcessId::from("proc-sandbox-denied"),
argv: vec![
"/usr/bin/python3".to_string(),
"-c".to_string(),
"from pathlib import Path; import sys; Path(sys.argv[1]).write_text('blocked')"
.to_string(),
blocked_path.to_string_lossy().into_owned(),
],
cwd: workspace_root.clone(),
env: Default::default(),
tty: false,
arg0: None,
sandbox: write_outside_workspace_sandbox(&workspace_root),
managed_network: None,
})
.await?;
assert_eq!(session.sandbox_type, platform_sandbox_type());
let StartedExecProcess { process, .. } = session;
let wake_rx = process.subscribe_wake();
let (_output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?;
assert_ne!(exit_code, Some(0));
assert!(closed);
assert!(
!blocked_path.exists(),
"sandboxed process unexpectedly wrote outside the workspace root"
);
Ok(())
}
async fn assert_remote_exec_process_applies_managed_network_proxy_env() -> Result<()> {
let context = create_process_context(/*use_remote*/ true).await?;
let session = context
.backend
.start(ExecParams {
process_id: ProcessId::from("proc-managed-network"),
argv: vec![
"/usr/bin/python3".to_string(),
"-c".to_string(),
"import os; print('HTTP_PROXY=' + os.environ['HTTP_PROXY']); print('HTTPS_PROXY=' + os.environ['HTTPS_PROXY'])"
.to_string(),
],
cwd: std::env::current_dir()?,
env: Default::default(),
tty: false,
arg0: None,
sandbox: SandboxLaunchConfig::no_sandbox(std::env::current_dir()?),
managed_network: Some(managed_network_config()),
})
.await?;
let StartedExecProcess { process, .. } = session;
let wake_rx = process.subscribe_wake();
let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?;
assert!(
output.contains("HTTP_PROXY=http://127.0.0.1:"),
"expected HTTP proxy env from managed network runtime, got {output:?}"
);
assert!(
output.contains("HTTPS_PROXY=http://127.0.0.1:"),
"expected HTTPS proxy env from managed network runtime, got {output:?}"
);
assert_eq!(exit_code, Some(0));
assert!(closed);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_exec_process_reports_transport_disconnect() -> Result<()> {
let mut context = create_process_context(/*use_remote*/ true).await?;
@@ -225,6 +385,10 @@ async fn remote_exec_process_reports_transport_disconnect() -> Result<()> {
env: Default::default(),
tty: false,
arg0: None,
sandbox: SandboxLaunchConfig::no_sandbox(
std::env::current_dir().expect("read current dir"),
),
managed_network: None,
})
.await?;
@@ -279,3 +443,13 @@ async fn exec_process_write_then_read(use_remote: bool) -> Result<()> {
async fn exec_process_preserves_queued_events_before_subscribe(use_remote: bool) -> Result<()> {
assert_exec_process_preserves_queued_events_before_subscribe(use_remote).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_exec_process_sandbox_denies_write_outside_workspace() -> Result<()> {
assert_exec_process_sandbox_denies_write_outside_workspace(/*use_remote*/ true).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_exec_process_applies_managed_network_proxy_env() -> Result<()> {
assert_remote_exec_process_applies_managed_network_proxy_env().await
}

View File

@@ -4,15 +4,17 @@ mod common;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCResponse;
use codex_exec_server::ExecParams;
use codex_exec_server::ExecResponse;
use codex_exec_server::InitializeParams;
use codex_exec_server::ProcessId;
use codex_sandboxing::SandboxLaunchConfig;
use common::exec_server::exec_server;
use pretty_assertions::assert_eq;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> {
let mut server = exec_server().await?;
async fn initialize_server(
server: &mut common::exec_server::ExecServerHarness,
) -> anyhow::Result<()> {
let initialize_id = server
.send_request(
"initialize",
@@ -34,17 +36,27 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> {
.send_notification("initialized", serde_json::json!({}))
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> {
let mut server = exec_server().await?;
initialize_server(&mut server).await?;
let process_start_id = server
.send_request(
"process/start",
serde_json::json!({
"processId": "proc-1",
"argv": ["true"],
"cwd": std::env::current_dir()?,
"env": {},
"tty": false,
"arg0": null
}),
serde_json::to_value(ExecParams {
process_id: ProcessId::from("proc-1"),
argv: vec!["true".to_string()],
cwd: std::env::current_dir()?,
env: Default::default(),
tty: false,
arg0: None,
sandbox: SandboxLaunchConfig::no_sandbox(std::env::current_dir()?),
managed_network: None,
})?,
)
.await?;
let response = server
@@ -63,7 +75,7 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> {
assert_eq!(
process_start_response,
ExecResponse {
process_id: ProcessId::from("proc-1")
process_id: ProcessId::from("proc-1"),
}
);

View File

@@ -19,6 +19,7 @@ use anyhow::Result;
use async_trait::async_trait;
use codex_utils_absolute_path::AbsolutePathBuf;
use globset::GlobSet;
use serde::Deserialize;
use serde::Serialize;
use std::collections::HashSet;
use std::collections::VecDeque;
@@ -39,7 +40,7 @@ const MAX_BLOCKED_EVENTS: usize = 200;
const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
const NETWORK_POLICY_VIOLATION_PREFIX: &str = "CODEX_NETWORK_POLICY_VIOLATION";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NetworkProxyAuditMetadata {
pub conversation_id: Option<String>,
pub app_version: Option<String>,

View File

@@ -9,6 +9,7 @@ use crate::policy::compile_denylist_globset;
use crate::policy::is_global_wildcard_domain_pattern;
use crate::runtime::ConfigState;
use serde::Deserialize;
use serde::Serialize;
use std::collections::HashSet;
use std::sync::Arc;
@@ -19,7 +20,7 @@ pub use crate::runtime::NetworkProxyState;
#[cfg(test)]
pub(crate) use crate::runtime::network_proxy_state_for_policy;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NetworkProxyConstraints {
pub enabled: Option<bool>,
pub mode: Option<NetworkMode>,

View File

@@ -17,6 +17,7 @@ codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
dunce = { workspace = true }
libc = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tracing = { workspace = true, features = ["log"] }
url = { workspace = true }

View File

@@ -12,9 +12,9 @@ pub use bwrap::find_system_bwrap_in_path;
pub use bwrap::system_bwrap_warning;
pub use manager::SandboxCommand;
pub use manager::SandboxExecRequest;
pub use manager::SandboxLaunchConfig;
pub use manager::SandboxManager;
pub use manager::SandboxTransformError;
pub use manager::SandboxTransformRequest;
pub use manager::SandboxType;
pub use manager::SandboxablePreference;
pub use manager::get_platform_sandbox;

View File

@@ -15,12 +15,15 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
use serde::Deserialize;
use serde::Serialize;
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::Path;
use std::path::PathBuf;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SandboxType {
None,
MacosSeatbelt,
@@ -46,6 +49,47 @@ pub enum SandboxablePreference {
Forbid,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxLaunchConfig {
pub sandbox: SandboxType,
pub policy: SandboxPolicy,
pub file_system_policy: FileSystemSandboxPolicy,
pub network_policy: NetworkSandboxPolicy,
pub sandbox_policy_cwd: PathBuf,
pub additional_permissions: Option<PermissionProfile>,
pub enforce_managed_network: bool,
pub windows_sandbox_level: WindowsSandboxLevel,
pub windows_sandbox_private_desktop: bool,
pub use_legacy_landlock: bool,
}
impl SandboxLaunchConfig {
pub fn no_sandbox(sandbox_policy_cwd: PathBuf) -> Self {
Self {
sandbox: SandboxType::None,
policy: SandboxPolicy::DangerFullAccess,
file_system_policy: FileSystemSandboxPolicy::unrestricted(),
network_policy: NetworkSandboxPolicy::Enabled,
sandbox_policy_cwd,
additional_permissions: None,
enforce_managed_network: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
use_legacy_landlock: false,
}
}
pub fn transform(
&self,
command: SandboxCommand,
network: Option<&NetworkProxy>,
codex_linux_sandbox_exe: Option<&PathBuf>,
) -> Result<SandboxExecRequest, SandboxTransformError> {
SandboxManager::new().transform(command, self, network, codex_linux_sandbox_exe)
}
}
pub fn get_platform_sandbox(windows_sandbox_enabled: bool) -> Option<SandboxType> {
if cfg!(target_os = "macos") {
Some(SandboxType::MacosSeatbelt)
@@ -86,26 +130,6 @@ pub struct SandboxExecRequest {
pub arg0: Option<String>,
}
/// Bundled arguments for sandbox transformation.
///
/// This keeps call sites self-documenting when several fields are optional.
pub struct SandboxTransformRequest<'a> {
pub command: SandboxCommand,
pub policy: &'a SandboxPolicy,
pub file_system_policy: &'a FileSystemSandboxPolicy,
pub network_policy: NetworkSandboxPolicy,
pub sandbox: SandboxType,
pub enforce_managed_network: bool,
// TODO(viyatb): Evaluate switching this to Option<Arc<NetworkProxy>>
// to make shared ownership explicit across runtime/sandbox plumbing.
pub network: Option<&'a NetworkProxy>,
pub sandbox_policy_cwd: &'a Path,
pub codex_linux_sandbox_exe: Option<&'a PathBuf>,
pub use_legacy_landlock: bool,
pub windows_sandbox_level: WindowsSandboxLevel,
pub windows_sandbox_private_desktop: bool,
}
#[derive(Debug)]
pub enum SandboxTransformError {
MissingLinuxSandboxExecutable,
@@ -166,37 +190,28 @@ impl SandboxManager {
pub fn transform(
&self,
request: SandboxTransformRequest<'_>,
mut command: SandboxCommand,
launch: &SandboxLaunchConfig,
network: Option<&NetworkProxy>,
codex_linux_sandbox_exe: Option<&PathBuf>,
) -> Result<SandboxExecRequest, SandboxTransformError> {
let SandboxTransformRequest {
mut command,
policy,
file_system_policy,
network_policy,
sandbox,
enforce_managed_network,
network,
sandbox_policy_cwd,
codex_linux_sandbox_exe,
use_legacy_landlock,
windows_sandbox_level,
windows_sandbox_private_desktop,
} = request;
let additional_permissions = command.additional_permissions.take();
let EffectiveSandboxPermissions {
sandbox_policy: effective_policy,
} = EffectiveSandboxPermissions::new(policy, additional_permissions.as_ref());
} = EffectiveSandboxPermissions::new(&launch.policy, additional_permissions.as_ref());
let effective_file_system_policy = effective_file_system_sandbox_policy(
file_system_policy,
&launch.file_system_policy,
additional_permissions.as_ref(),
);
let effective_network_policy = effective_network_sandbox_policy(
launch.network_policy,
additional_permissions.as_ref(),
);
let effective_network_policy =
effective_network_sandbox_policy(network_policy, additional_permissions.as_ref());
let mut argv = Vec::with_capacity(1 + command.args.len());
argv.push(command.program);
argv.extend(command.args.into_iter().map(OsString::from));
let (argv, arg0_override) = match sandbox {
let (argv, arg0_override) = match launch.sandbox {
SandboxType::None => (os_argv_to_strings(argv), None),
#[cfg(target_os = "macos")]
SandboxType::MacosSeatbelt => {
@@ -204,8 +219,8 @@ impl SandboxManager {
os_argv_to_strings(argv),
&effective_file_system_policy,
effective_network_policy,
sandbox_policy_cwd,
enforce_managed_network,
launch.sandbox_policy_cwd.as_path(),
launch.enforce_managed_network,
network,
);
let mut full_command = Vec::with_capacity(1 + args.len());
@@ -218,15 +233,15 @@ impl SandboxManager {
SandboxType::LinuxSeccomp => {
let exe = codex_linux_sandbox_exe
.ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?;
let allow_proxy_network = allow_network_for_proxy(enforce_managed_network);
let allow_proxy_network = allow_network_for_proxy(launch.enforce_managed_network);
let mut args = create_linux_sandbox_command_args_for_policies(
os_argv_to_strings(argv),
command.cwd.as_path(),
&effective_policy,
&effective_file_system_policy,
effective_network_policy,
sandbox_policy_cwd,
use_legacy_landlock,
launch.sandbox_policy_cwd.as_path(),
launch.use_legacy_landlock,
allow_proxy_network,
);
let mut full_command = Vec::with_capacity(1 + args.len());
@@ -248,9 +263,9 @@ impl SandboxManager {
cwd: command.cwd,
env: command.env,
network: network.cloned(),
sandbox,
windows_sandbox_level,
windows_sandbox_private_desktop,
sandbox: launch.sandbox,
windows_sandbox_level: launch.windows_sandbox_level,
windows_sandbox_private_desktop: launch.windows_sandbox_private_desktop,
sandbox_policy: effective_policy,
file_system_sandbox_policy: effective_file_system_policy,
network_sandbox_policy: effective_network_policy,

View File

@@ -1,6 +1,6 @@
use super::SandboxCommand;
use super::SandboxLaunchConfig;
use super::SandboxManager;
use super::SandboxTransformRequest;
use super::SandboxType;
use super::SandboxablePreference;
use super::get_platform_sandbox;
@@ -21,8 +21,30 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use dunce::canonicalize;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::path::Path;
use tempfile::TempDir;
fn test_launch_config(
cwd: &Path,
policy: SandboxPolicy,
file_system_policy: FileSystemSandboxPolicy,
network_policy: NetworkSandboxPolicy,
sandbox: SandboxType,
) -> SandboxLaunchConfig {
SandboxLaunchConfig {
sandbox,
policy,
file_system_policy,
network_policy,
sandbox_policy_cwd: cwd.to_path_buf(),
additional_permissions: None,
enforce_managed_network: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
use_legacy_landlock: false,
}
}
#[test]
fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() {
let manager = SandboxManager::new();
@@ -76,28 +98,26 @@ fn transform_preserves_unrestricted_file_system_policy_for_restricted_network()
let manager = SandboxManager::new();
let cwd = std::env::current_dir().expect("current dir");
let exec_request = manager
.transform(SandboxTransformRequest {
command: SandboxCommand {
.transform(
SandboxCommand {
program: "true".into(),
args: Vec::new(),
cwd: cwd.clone(),
env: HashMap::new(),
additional_permissions: None,
},
policy: &SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
},
file_system_policy: &FileSystemSandboxPolicy::unrestricted(),
network_policy: NetworkSandboxPolicy::Restricted,
sandbox: SandboxType::None,
enforce_managed_network: false,
network: None,
sandbox_policy_cwd: cwd.as_path(),
codex_linux_sandbox_exe: None,
use_legacy_landlock: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
})
&test_launch_config(
cwd.as_path(),
SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
},
FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Restricted,
SandboxType::None,
),
/*network*/ None,
/*codex_linux_sandbox_exe*/ None,
)
.expect("transform");
assert_eq!(
@@ -120,8 +140,8 @@ fn transform_additional_permissions_enable_network_for_external_sandbox() {
)
.expect("absolute temp dir");
let exec_request = manager
.transform(SandboxTransformRequest {
command: SandboxCommand {
.transform(
SandboxCommand {
program: "true".into(),
args: Vec::new(),
cwd: cwd.clone(),
@@ -136,20 +156,18 @@ fn transform_additional_permissions_enable_network_for_external_sandbox() {
}),
}),
},
policy: &SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
},
file_system_policy: &FileSystemSandboxPolicy::unrestricted(),
network_policy: NetworkSandboxPolicy::Restricted,
sandbox: SandboxType::None,
enforce_managed_network: false,
network: None,
sandbox_policy_cwd: cwd.as_path(),
codex_linux_sandbox_exe: None,
use_legacy_landlock: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
})
&test_launch_config(
cwd.as_path(),
SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
},
FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Restricted,
SandboxType::None,
),
/*network*/ None,
/*codex_linux_sandbox_exe*/ None,
)
.expect("transform");
assert_eq!(
@@ -176,8 +194,8 @@ fn transform_additional_permissions_preserves_denied_entries() {
let allowed_path = workspace_root.join("allowed");
let denied_path = workspace_root.join("denied");
let exec_request = manager
.transform(SandboxTransformRequest {
command: SandboxCommand {
.transform(
SandboxCommand {
program: "true".into(),
args: Vec::new(),
cwd: cwd.clone(),
@@ -190,34 +208,32 @@ fn transform_additional_permissions_preserves_denied_entries() {
..Default::default()
}),
},
policy: &SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
network_access: false,
},
file_system_policy: &FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
&test_launch_config(
cwd.as_path(),
SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
network_access: false,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: denied_path.clone(),
FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
access: FileSystemAccessMode::None,
},
]),
network_policy: NetworkSandboxPolicy::Restricted,
sandbox: SandboxType::None,
enforce_managed_network: false,
network: None,
sandbox_policy_cwd: cwd.as_path(),
codex_linux_sandbox_exe: None,
use_legacy_landlock: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
})
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: denied_path.clone(),
},
access: FileSystemAccessMode::None,
},
]),
NetworkSandboxPolicy::Restricted,
SandboxType::None,
),
/*network*/ None,
/*codex_linux_sandbox_exe*/ None,
)
.expect("transform");
assert_eq!(
@@ -252,26 +268,24 @@ fn transform_linux_seccomp_request(
let manager = SandboxManager::new();
let cwd = std::env::current_dir().expect("current dir");
manager
.transform(SandboxTransformRequest {
command: SandboxCommand {
.transform(
SandboxCommand {
program: "true".into(),
args: Vec::new(),
cwd: cwd.clone(),
env: HashMap::new(),
additional_permissions: None,
},
policy: &SandboxPolicy::DangerFullAccess,
file_system_policy: &FileSystemSandboxPolicy::unrestricted(),
network_policy: NetworkSandboxPolicy::Enabled,
sandbox: SandboxType::LinuxSeccomp,
enforce_managed_network: false,
network: None,
sandbox_policy_cwd: cwd.as_path(),
codex_linux_sandbox_exe: Some(codex_linux_sandbox_exe),
use_legacy_landlock: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
})
&test_launch_config(
cwd.as_path(),
SandboxPolicy::DangerFullAccess,
FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Enabled,
SandboxType::LinuxSeccomp,
),
/*network*/ None,
Some(codex_linux_sandbox_exe),
)
.expect("transform")
}

View File

@@ -27,9 +27,9 @@ use tempfile::TempDir;
fn assert_seatbelt_denied(stderr: &[u8], path: &Path) {
let stderr = String::from_utf8_lossy(stderr);
let expected = format!("bash: {}: Operation not permitted\n", path.display());
assert!(
stderr == expected
(stderr.contains(&path.display().to_string())
&& stderr.contains("Operation not permitted"))
|| stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted"),
"unexpected stderr: {stderr}"
);